diff --git a/Cargo.lock b/Cargo.lock index 948ce5ce..4ebbd2f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "agent-spec" version = "0.1.0" @@ -104,6 +110,18 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -188,6 +206,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -263,6 +287,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "const-oid" version = "0.10.2" @@ -567,6 +608,17 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -694,6 +746,19 @@ dependencies = [ "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", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -713,9 +778,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -827,6 +894,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1113,12 +1196,29 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[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.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1153,6 +1253,16 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1408,6 +1518,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1463,6 +1583,62 @@ dependencies = [ "syn 2.0.119", ] +[[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", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -1530,6 +1706,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1586,23 +1771,45 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", + "webpki-roots", +] + +[[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]] @@ -1630,6 +1837,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1768,6 +2010,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1808,10 +2056,13 @@ dependencies = [ "opentelemetry-appender-tracing", "opentelemetry-otlp", "opentelemetry_sdk", + "parking_lot", "serde", "serde_json", "sha2", "st2-resource-protocol", + "st2-resource-providers", + "st2-resource-wasip2", "st2-wire", "tempfile", "toml", @@ -1819,6 +2070,27 @@ dependencies = [ "tracing-opentelemetry", "tracing-subscriber", "tungstenite", + "wat", +] + +[[package]] +name = "st2-github-issue-component" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "wit-bindgen", +] + +[[package]] +name = "st2-pty-stats-component" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "wit-bindgen", ] [[package]] @@ -1830,6 +2102,21 @@ dependencies = [ "sha2", ] +[[package]] +name = "st2-resource-providers" +version = "0.1.0" +dependencies = [ + "libc", + "parking_lot", + "reqwest", + "serde_json", + "st2-resource-protocol", + "st2-resource-wasip2", + "tempfile", + "tokio", + "wasmtime", +] + [[package]] name = "st2-resource-wasip2" version = "0.1.0" @@ -1864,6 +2151,12 @@ 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.119" @@ -1973,19 +2266,57 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1997,6 +2328,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -2078,12 +2422,17 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -2225,6 +2574,18 @@ 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 = "url" version = "2.5.8" @@ -2350,6 +2711,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser 0.247.0", +] + [[package]] name = "wasm-encoder" version = "0.254.0" @@ -2370,6 +2741,18 @@ dependencies = [ "wasmparser 0.258.0", ] +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.247.0", + "wasmparser 0.247.0", +] + [[package]] name = "wasm-metadata" version = "0.254.0" @@ -2382,6 +2765,31 @@ dependencies = [ "wasmparser 0.254.0", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", +] + [[package]] name = "wasmparser" version = "0.254.0" @@ -2502,7 +2910,7 @@ dependencies = [ "syn 2.0.119", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser", + "wit-parser 0.254.0", ] [[package]] @@ -2616,8 +3024,8 @@ dependencies = [ "bitflags", "heck", "indexmap", - "wit-component", - "wit-parser", + "wit-component 0.254.0", + "wit-parser 0.254.0", ] [[package]] @@ -2662,6 +3070,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -2677,13 +3094,22 @@ 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", + "windows-targets 0.53.5", ] [[package]] @@ -2695,6 +3121,22 @@ 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" @@ -2702,58 +3144,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "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" @@ -2780,6 +3270,72 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata 0.247.0", + "wit-bindgen-core", + "wit-component 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.247.0", + "wasm-metadata 0.247.0", + "wasmparser 0.247.0", + "wit-parser 0.247.0", +] [[package]] name = "wit-component" @@ -2795,9 +3351,28 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.254.0", - "wasm-metadata", + "wasm-metadata 0.254.0", "wasmparser 0.254.0", - "wit-parser", + "wit-parser 0.254.0", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.247.0", ] [[package]] @@ -2889,6 +3464,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -2922,6 +3503,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 478d3ae1..da39f63b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,16 @@ # `package.version` out of this file as the single source of truth for the # build, and a virtual root has no `[package]` to read. [workspace] -members = ["crates/agent-spec", "crates/st2-resource-protocol", "crates/st2-resource-wasip2", "crates/st2-wire", "crates/demo-resolver-wasm"] +members = [ + "components/github-issue", + "components/pty-stats", + "crates/agent-spec", + "crates/demo-resolver-wasm", + "crates/st2-resource-protocol", + "crates/st2-resource-providers", + "crates/st2-resource-wasip2", + "crates/st2-wire", +] default-members = [".", "crates/agent-spec", "crates/st2-resource-protocol", "crates/st2-wire"] [package] @@ -34,6 +43,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" st2-resource-protocol = { path = "crates/st2-resource-protocol" } +st2-resource-providers = { path = "crates/st2-resource-providers", optional = true } +st2-resource-wasip2 = { path = "crates/st2-resource-wasip2", optional = true, features = ["runtime"] } st2-wire = { path = "crates/st2-wire" } tempfile = "3" toml = "0.9" @@ -50,9 +61,18 @@ tungstenite = "0.30" [dev-dependencies] libc = "0.2" +parking_lot = "0.12" toml = "0.9" +wat = "1" [features] # Sandbox wasm resource-profile resolvers (`agent-spec/profile_wasm`). Off by default: the # runner binary carries no wasmtime unless a build opts in. wasm-resolver = ["agent-spec/wasm-resolver"] +# Typed Component Model resource providers. This is the sole feature that admits Wasmtime for +# observation; default members and the default st2 binary remain Wasmtime-free. +wasip2-provider-runtime = [ + "wasm-resolver", + "dep:st2-resource-providers", + "dep:st2-resource-wasip2", +] diff --git a/components/github-issue/Cargo.toml b/components/github-issue/Cargo.toml new file mode 100644 index 00000000..d3fcd6a0 --- /dev/null +++ b/components/github-issue/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "st2-github-issue-component" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +wit-bindgen = "0.57.1" diff --git a/components/github-issue/src/lib.rs b/components/github-issue/src/lib.rs new file mode 100644 index 00000000..c359466c --- /dev/null +++ b/components/github-issue/src/lib.rs @@ -0,0 +1,162 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +wit_bindgen::generate!({ + path: "../../wit/github-issue", + world: "github-issue-provider", + with: { + "compoundingtech:st2-github-issue/github-issue@0.1.0": generate, + }, +}); + +use compoundingtech::st2_github_issue::github_issue; +use exports::st2::resource_provider::provider_api; + +const SELECTOR_SCHEMA: &str = r#"{ + "type": "object", + "properties": { + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "number": { "type": "integer" }, + "etag": { "type": "string" }, + "topics": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "required": ["owner", "repo", "number"], + "additionalProperties": false +}"#; + +struct Component; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Selector { + owner: String, + repo: String, + number: u64, + #[serde(default)] + etag: Option, + #[serde(default)] + topics: Vec, +} + +#[derive(Deserialize)] +struct GitHubIssue { + number: u64, + state: String, + title: String, + updated_at: String, + html_url: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Carrier<'a> { + resource: &'static str, + owner: &'a str, + repo: &'a str, + number: u64, + state: &'a str, + title: &'a str, + updated_at: &'a str, + html_url: &'a str, +} + +impl provider_api::Guest for Component { + fn describe() -> Result { + Ok(provider_api::ProviderDescriptor { + capabilities: vec![provider_api::SchedulingCapability::Demand], + selector_schema_json: SELECTOR_SCHEMA.into(), + default_selector_json: "{}".into(), + topics: vec!["issue".into()], + snapshot_media_type: "application/json".into(), + snapshot_schema_id: "st2.resource.github-issue.v1".into(), + }) + } + + fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult { + observe(request).unwrap_or_else(|diagnostic| { + provider_api::ObservationResult::Failed(Some(diagnostic)) + }) + } +} + +fn observe( + request: provider_api::ObserveRequest, +) -> Result { + let selector: Selector = serde_json::from_str(&request.selector_json) + .map_err(|_| "invalid GitHub issue selector".to_owned())?; + if selector.owner.is_empty() || selector.repo.is_empty() || selector.number == 0 { + return Err("GitHub issue selector fields must be non-empty".into()); + } + let response = github_issue::get(&github_issue::IssueRequest { + owner: selector.owner.clone(), + repo: selector.repo.clone(), + number: selector.number, + etag: selector.etag, + }) + .map_err(map_source_error)?; + let (etag, body) = match response { + github_issue::IssueResponse::NotModified(_) => { + return Ok(provider_api::ObservationResult::Unchanged); + } + github_issue::IssueResponse::Ok(value) => value, + }; + let issue: GitHubIssue = serde_json::from_slice(&body) + .map_err(|_| "GitHub response was invalid".to_owned())?; + if issue.number != selector.number { + return Err("GitHub response did not match the requested issue".into()); + } + let bytes = serde_json::to_vec(&Carrier { + resource: "github-issue", + owner: &selector.owner, + repo: &selector.repo, + number: issue.number, + state: &issue.state, + title: &issue.title, + updated_at: &issue.updated_at, + html_url: &issue.html_url, + }) + .map_err(|_| "GitHub response normalization failed".to_owned())?; + let digest = Sha256::digest(&bytes); + if request.prior_digest.as_deref() == Some(digest.as_slice()) { + return Ok(provider_api::ObservationResult::Unchanged); + } + let _ = (request.uri, request.demand_watermark, selector.topics); + let facts = vec![ + provider_api::Fact { + key: "state".into(), + before: provider_api::FactValue::Omitted, + after: provider_api::FactValue::Value(issue.state), + }, + provider_api::Fact { + key: "etag".into(), + before: provider_api::FactValue::Omitted, + after: etag.map_or(provider_api::FactValue::Null, provider_api::FactValue::Value), + }, + ]; + Ok(provider_api::ObservationResult::Published( + provider_api::Publication { + schema_id: "st2.resource.github-issue.v1".into(), + media_type: "application/json".into(), + bytes, + topics: vec!["issue".into()], + facts: Some(facts), + }, + )) +} + +fn map_source_error(error: github_issue::IssueError) -> String { + match error { + github_issue::IssueError::Denied => "GitHub issue scope denied", + github_issue::IssueError::Unavailable => "GitHub is unavailable", + github_issue::IssueError::ResourceExhausted => "GitHub response exceeded limits", + github_issue::IssueError::DeadlineExceeded => "GitHub request deadline exceeded", + } + .into() +} + +export!(Component); diff --git a/components/pty-stats/Cargo.toml b/components/pty-stats/Cargo.toml new file mode 100644 index 00000000..eeb7b134 --- /dev/null +++ b/components/pty-stats/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "st2-pty-stats-component" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +wit-bindgen = "0.57.1" diff --git a/components/pty-stats/src/lib.rs b/components/pty-stats/src/lib.rs new file mode 100644 index 00000000..c032d3e4 --- /dev/null +++ b/components/pty-stats/src/lib.rs @@ -0,0 +1,140 @@ +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +wit_bindgen::generate!({ + path: "../../wit/pty-stats", + world: "pty-stats-provider", + with: { + "compoundingtech:st2-pty-stats/pty-stats@0.1.0": generate, + }, +}); + +use compoundingtech::st2_pty_stats::pty_stats; +use exports::st2::resource_provider::provider_api; + +const SELECTOR_SCHEMA: &str = r#"{ + "type": "object", + "properties": { + "session": { "type": "string" }, + "topics": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "additionalProperties": false +}"#; + +struct Component; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Selector { + #[serde(default)] + session: Option, + #[serde(default)] + topics: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Carrier<'a> { + resource: &'static str, + scope: Scope<'a>, + stats: &'a serde_json::Value, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +enum Scope<'a> { + All, + Session(&'a str), +} + +impl provider_api::Guest for Component { + fn describe() -> Result { + Ok(provider_api::ProviderDescriptor { + capabilities: vec![provider_api::SchedulingCapability::Demand], + selector_schema_json: SELECTOR_SCHEMA.into(), + default_selector_json: "{}".into(), + topics: vec!["stats".into()], + snapshot_media_type: "application/json".into(), + snapshot_schema_id: "st2.resource.pty-stats.v1".into(), + }) + } + + fn observe(request: provider_api::ObserveRequest) -> provider_api::ObservationResult { + observe(request).unwrap_or_else(|diagnostic| { + provider_api::ObservationResult::Failed(Some(diagnostic)) + }) + } +} + +fn observe( + request: provider_api::ObserveRequest, +) -> Result { + let selector: Selector = serde_json::from_str(&request.selector_json) + .map_err(|_| "invalid PTY stats selector".to_owned())?; + if selector.session.as_deref().is_some_and(str::is_empty) { + return Err("PTY session scope must be non-empty".into()); + } + let scope = selector.session.as_ref().map_or(pty_stats::Scope::All, |session| { + pty_stats::Scope::Session(session.clone()) + }); + let outcome = pty_stats::get(&scope).map_err(map_source_error)?; + if outcome.stdout_truncated || outcome.stderr_truncated { + return Err("PTY stats output exceeded limits".into()); + } + match outcome.exit { + pty_stats::ExitStatus::Code(0) => {} + pty_stats::ExitStatus::Code(_) | pty_stats::ExitStatus::Signal(_) => { + return Ok(provider_api::ObservationResult::Failed(Some( + "pty stats exited unsuccessfully".into(), + ))); + } + } + let stats: serde_json::Value = serde_json::from_slice(&outcome.stdout) + .map_err(|_| "pty stats returned invalid JSON".to_owned())?; + let carrier_scope = selector + .session + .as_deref() + .map_or(Scope::All, Scope::Session); + let scope_fact = selector.session.clone().unwrap_or_else(|| "all".into()); + let bytes = serde_json::to_vec(&Carrier { + resource: "pty-stats", + scope: carrier_scope, + stats: &stats, + }) + .map_err(|_| "PTY stats normalization failed".to_owned())?; + let digest = Sha256::digest(&bytes); + if request.prior_digest.as_deref() == Some(digest.as_slice()) { + return Ok(provider_api::ObservationResult::Unchanged); + } + let _ = (request.uri, request.demand_watermark, selector.topics); + Ok(provider_api::ObservationResult::Published( + provider_api::Publication { + schema_id: "st2.resource.pty-stats.v1".into(), + media_type: "application/json".into(), + bytes, + topics: vec!["stats".into()], + facts: Some(vec![provider_api::Fact { + key: "scope".into(), + before: provider_api::FactValue::Omitted, + after: provider_api::FactValue::Value(scope_fact), + }]), + }, + )) +} + +fn map_source_error(error: pty_stats::PtyStatsError) -> String { + match error { + pty_stats::PtyStatsError::Denied => "PTY stats scope denied", + pty_stats::PtyStatsError::Unavailable => "PTY stats is unavailable", + pty_stats::PtyStatsError::ResourceExhausted => "PTY stats output exceeded limits", + pty_stats::PtyStatsError::DeadlineExceeded => "PTY stats deadline exceeded", + pty_stats::PtyStatsError::Cancelled => "PTY stats was cancelled", + } + .into() +} + +export!(Component); diff --git a/crates/st2-resource-providers/Cargo.toml b/crates/st2-resource-providers/Cargo.toml new file mode 100644 index 00000000..4653a471 --- /dev/null +++ b/crates/st2-resource-providers/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "st2-resource-providers" +version = "0.1.0" +edition = "2024" +description = "Typed host capabilities for the st2 GitHub Issue and PTY stats resource components." +license = "MIT" +publish = false + +[dependencies] +libc = "0.2" +parking_lot = "0.12" +reqwest = { version = "0.12", default-features = false, features = ["gzip", "rustls-tls", "stream"] } +serde_json = "1" +st2-resource-wasip2 = { path = "../st2-resource-wasip2", features = ["runtime"] } +wasmtime = { version = "=48.0.1", default-features = false, features = ["component-model", "runtime", "std"] } +tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } + +[dev-dependencies] +st2-resource-protocol = { path = "../st2-resource-protocol" } +tempfile = "3" diff --git a/crates/st2-resource-providers/src/github_issue.rs b/crates/st2-resource-providers/src/github_issue.rs new file mode 100644 index 00000000..cd14eeca --- /dev/null +++ b/crates/st2-resource-providers/src/github_issue.rs @@ -0,0 +1,478 @@ +use std::collections::BTreeMap; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs as _}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use st2_resource_wasip2::{ + CapabilityContext, CapabilityModule, CapabilityPhase, InterruptionReason, InvocationControl, + InvocationStore, +}; +use wasmtime::component::{HasSelf, Linker}; + +mod bindings { + wasmtime::component::bindgen!({ + path: "../../wit/github-issue", + world: "github-issue-provider", + }); +} + +use bindings::compoundingtech::st2_github_issue::github_issue::{ + Host, IssueError, IssueRequest, IssueResponse, +}; + +const IMPORT_NAME: &str = "compoundingtech:st2-github-issue/github-issue@0.1.0"; +const API_HOST: &str = "api.github.com"; +const API_PORT: u16 = 443; +const MAX_HEADERS_BYTES: usize = 16 * 1024; +const MAX_BODY_BYTES: usize = 256 * 1024; +const MAX_ETAG_BYTES: usize = 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitHubIssueConfig { + pub owner: String, + pub repo: String, + pub number: u64, + pub connect_timeout: Duration, + pub total_timeout: Duration, +} + +impl GitHubIssueConfig { + pub fn validate(&self) -> Result<(), &'static str> { + if !valid_slug(&self.owner) || !valid_slug(&self.repo) || self.number == 0 { + return Err("GitHub issue scope is invalid"); + } + if self.connect_timeout.is_zero() + || self.total_timeout.is_zero() + || self.connect_timeout > self.total_timeout + || self.total_timeout > Duration::from_secs(60) + { + return Err("GitHub issue deadlines are invalid"); + } + Ok(()) + } +} + +#[derive(Clone)] +pub struct GitHubIssueModule { + config: GitHubIssueConfig, + cache: Arc>>, +} + +impl GitHubIssueModule { + pub fn new(config: GitHubIssueConfig) -> Result { + config.validate()?; + Ok(Self { + config, + cache: Arc::new(Mutex::new(BTreeMap::new())), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct IssueKey { + owner: String, + repo: String, + number: u64, +} + +#[derive(Debug, Clone)] +struct CachedIssue { + etag: Option, + body: Vec, +} + +pub struct GitHubIssueInvocation { + config: GitHubIssueConfig, + cache: Arc>>, + has_authoritative_prior: bool, + control: InvocationControl, +} + +impl CapabilityModule for GitHubIssueModule { + type Invocation = GitHubIssueInvocation; + + fn import_names(&self) -> &'static [&'static str] { + &[IMPORT_NAME] + } + + fn add_to_linker( + &self, + linker: &mut Linker>, + ) -> Result<(), wasmtime::Error> { + bindings::GithubIssueProvider::add_to_linker::<_, HasSelf<_>>(linker, |state| state) + } + + fn begin(&self, context: CapabilityContext<'_>) -> Self::Invocation { + let has_authoritative_prior = match context.phase() { + CapabilityPhase::Describe => false, + CapabilityPhase::Observe(request) => request.prior_digest.is_some(), + }; + GitHubIssueInvocation { + config: self.config.clone(), + cache: Arc::clone(&self.cache), + has_authoritative_prior, + control: context.control().clone(), + } + } +} + +impl Host for InvocationStore { + fn get(&mut self, request: IssueRequest) -> Result { + self.capability_mut().get(request) + } +} + +impl GitHubIssueInvocation { + fn get(&mut self, request: IssueRequest) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| IssueError::Unavailable)?; + runtime.block_on(self.get_async(request)) + } + + async fn get_async(&mut self, request: IssueRequest) -> Result { + if !request_matches_scope(&self.config, &request) { + return Err(IssueError::Denied); + } + let key = IssueKey { + owner: request.owner, + repo: request.repo, + number: request.number, + }; + let cached = self + .cache + .lock() + .map_err(|_| IssueError::Unavailable)? + .get(&key) + .cloned(); + let requested_etag = request.etag; + let reused_cached_entry = self.has_authoritative_prior + && requested_etag.is_none() + && cached.as_ref().is_some_and(|entry| entry.etag.is_some()); + let etag = conditional_etag( + self.has_authoritative_prior, + requested_etag, + cached.as_ref().and_then(|entry| entry.etag.clone()), + ); + let endpoint = format!( + "https://{API_HOST}/repos/{}/{}/issues/{}", + key.owner, key.repo, key.number + ); + let address = resolve_public_api_address()?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(self.config.connect_timeout) + .timeout(self.config.total_timeout) + .gzip(true) + .resolve(API_HOST, address) + .build() + .map_err(|_| IssueError::Unavailable)?; + let mut builder = client + .get(endpoint) + .header("accept", "application/vnd.github+json") + .header("x-github-api-version", "2022-11-28") + .header("user-agent", "st2-resource-provider"); + if let Some(etag) = etag.as_deref() { + builder = builder.header("if-none-match", etag); + } + let mut response = tokio::select! { + biased; + reason = wait_for_interruption(&self.control) => { + return Err(interruption_error(reason)); + } + response = builder.send() => response.map_err(map_transport_error)?, + }; + let status = response.status(); + if status.is_redirection() && status.as_u16() != 304 { + return Err(IssueError::Denied); + } + let header_bytes = response.headers().iter().try_fold(0_usize, |total, (name, value)| { + total + .checked_add(name.as_str().len()) + .and_then(|total| total.checked_add(value.as_bytes().len())) + .ok_or(IssueError::ResourceExhausted) + })?; + if header_bytes > MAX_HEADERS_BYTES { + return Err(IssueError::ResourceExhausted); + } + let response_etag = response + .headers() + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .filter(|value| valid_etag(value)) + .map(str::to_owned); + match status.as_u16() { + 304 => Ok(not_modified_response( + reused_cached_entry, + response_etag, + etag, + cached, + )), + 200 => { + let mut body = Vec::new(); + loop { + let chunk = tokio::select! { + biased; + reason = wait_for_interruption(&self.control) => { + return Err(interruption_error(reason)); + } + chunk = response.chunk() => chunk.map_err(map_transport_error)?, + }; + let Some(chunk) = chunk else { + break; + }; + if body.len().saturating_add(chunk.len()) > MAX_BODY_BYTES { + return Err(IssueError::ResourceExhausted); + } + body.extend_from_slice(&chunk); + } + self.cache + .lock() + .map_err(|_| IssueError::Unavailable)? + .insert( + key, + CachedIssue { + etag: response_etag.clone(), + body: body.clone(), + }, + ); + Ok(IssueResponse::Ok((response_etag, body))) + } + 401 | 403 | 404 => Err(IssueError::Denied), + _ => Err(IssueError::Unavailable), + } + } +} +async fn wait_for_interruption(control: &InvocationControl) -> InterruptionReason { + loop { + if let Some(reason) = control.interruption_reason() { + return reason; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +fn interruption_error(reason: InterruptionReason) -> IssueError { + match reason { + InterruptionReason::Cancelled => IssueError::Unavailable, + InterruptionReason::TimedOut => IssueError::DeadlineExceeded, + } +} + +fn request_matches_scope(config: &GitHubIssueConfig, request: &IssueRequest) -> bool { + request.owner == config.owner + && request.repo == config.repo + && request.number == config.number + && request.etag.as_ref().is_none_or(|etag| valid_etag(etag)) +} + + +fn conditional_etag( + has_authoritative_prior: bool, + requested: Option, + cached: Option, +) -> Option { + has_authoritative_prior + .then(|| requested.or(cached)) + .flatten() +} + +fn not_modified_response( + reused_cached_entry: bool, + response_etag: Option, + conditional_etag: Option, + cached: Option, +) -> IssueResponse { + let effective_etag = response_etag.or(conditional_etag); + if reused_cached_entry + && let Some(cached) = cached + && cached.etag == effective_etag + { + return IssueResponse::Ok((effective_etag, cached.body)); + } + IssueResponse::NotModified(effective_etag) +} + +fn valid_slug(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn valid_etag(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_ETAG_BYTES + && !value.bytes().any(|byte| byte == b'\r' || byte == b'\n' || byte == 0) +} + +fn map_transport_error(error: reqwest::Error) -> IssueError { + if error.is_timeout() { + IssueError::DeadlineExceeded + } else { + IssueError::Unavailable + } +} + +fn resolve_public_api_address() -> Result { + let addresses = (API_HOST, API_PORT) + .to_socket_addrs() + .map_err(|_| IssueError::Unavailable)? + .collect::>(); + if addresses.is_empty() || addresses.iter().any(|address| !is_public(address.ip())) { + return Err(IssueError::Denied); + } + addresses.into_iter().next().ok_or(IssueError::Unavailable) +} + +fn is_public(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + !(address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_broadcast() + || address.is_documentation() + || address.is_unspecified() + || address.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (octets[1] & 0xc0) == 64) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 198 && matches!(octets[1], 18 | 19)) + || octets[0] >= 240) + } + IpAddr::V6(address) => { + let segments = address.segments(); + let first = segments[0]; + let mapped_private = address + .to_ipv4_mapped() + .is_some_and(|mapped| !is_public(IpAddr::V4(mapped))); + !(address.is_loopback() + || address.is_unspecified() + || address.is_multicast() + || mapped_private + || (first & 0xfe00) == 0xfc00 + || (first & 0xffc0) == 0xfe80 + || (segments[0] == 0x2001 && segments[1] == 0x0db8)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + + fn live_config() -> GitHubIssueConfig { + GitHubIssueConfig { + owner: "rust-lang".into(), + repo: "rust".into(), + number: 1, + connect_timeout: Duration::from_secs(3), + total_timeout: Duration::from_secs(10), + } + } + + #[test] + fn exact_scope_and_header_policy_deny_before_transport() { + let module = GitHubIssueModule::new(live_config()).unwrap(); + for request in [ + IssueRequest { + owner: "other".into(), + repo: "rust".into(), + number: 1, + etag: None, + }, + IssueRequest { + owner: "rust-lang".into(), + repo: "other".into(), + number: 1, + etag: None, + }, + IssueRequest { + owner: "rust-lang".into(), + repo: "rust".into(), + number: 2, + etag: None, + }, + IssueRequest { + owner: "rust-lang".into(), + repo: "rust".into(), + number: 1, + etag: Some("\"ok\"\r\nx-injected: true".into()), + }, + ] { + assert!(!request_matches_scope(&module.config, &request)); + } + } + + #[test] + fn private_special_and_documentation_addresses_are_never_admitted() { + for address in [ + "0.0.0.0", + "10.0.0.1", + "127.0.0.1", + "169.254.1.1", + "192.0.2.1", + "224.0.0.1", + "::", + "::1", + "fc00::1", + "fe80::1", + "2001:db8::1", + "ff02::1", + ] { + assert!(!is_public(address.parse().unwrap()), "{address}"); + } + assert!(is_public("8.8.8.8".parse().unwrap())); + assert!(is_public("2606:4700:4700::1111".parse().unwrap())); + } + + #[test] + fn deadlines_are_bounded_and_ordered() { + let mut config = live_config(); + config.connect_timeout = Duration::from_secs(11); + assert!(config.validate().is_err()); + config.connect_timeout = Duration::from_secs(1); + config.total_timeout = Duration::from_secs(61); + assert!(config.validate().is_err()); + } + + #[test] + fn shared_runtime_only_reuses_an_etag_for_a_binding_with_prior_state() { + assert_eq!(conditional_etag(false, None, Some("\"cached\"".into())), None); + assert_eq!( + conditional_etag(true, None, Some("\"cached\"".into())), + Some("\"cached\"".into()) + ); + } + + #[test] + fn shared_runtime_304_replays_cached_body_to_an_older_binding() { + let newest_body = br#"{"title":"new"}"#.to_vec(); + let cached = CachedIssue { + etag: Some("\"new\"".into()), + body: newest_body.clone(), + }; + let response = not_modified_response( + true, + None, + Some("\"new\"".into()), + Some(cached), + ); + let IssueResponse::Ok((etag, body)) = response else { + panic!("shared cached revalidation must return the exact cached body"); + }; + assert_eq!(etag.as_deref(), Some("\"new\"")); + assert_eq!(body, newest_body); + assert_ne!( + st2_resource_protocol::SnapshotDigest::of(b"{\"title\":\"old\"}"), + st2_resource_protocol::SnapshotDigest::of(&body), + "the guest can compare and publish the newer body for the skewed binding" + ); + } + +} diff --git a/crates/st2-resource-providers/src/lib.rs b/crates/st2-resource-providers/src/lib.rs new file mode 100644 index 00000000..949c1241 --- /dev/null +++ b/crates/st2-resource-providers/src/lib.rs @@ -0,0 +1,7 @@ +//! Closed host capabilities for production resource-observer components. + +mod github_issue; +mod pty_stats; + +pub use github_issue::{GitHubIssueConfig, GitHubIssueModule}; +pub use pty_stats::{PtyStatsConfig, PtyStatsModule, PtyStatsScope}; diff --git a/crates/st2-resource-providers/src/pty_stats.rs b/crates/st2-resource-providers/src/pty_stats.rs new file mode 100644 index 00000000..782e06f7 --- /dev/null +++ b/crates/st2-resource-providers/src/pty_stats.rs @@ -0,0 +1,623 @@ +use std::io::Write as _; +use std::os::unix::fs::PermissionsExt as _; +use std::os::unix::process::{CommandExt as _, ExitStatusExt as _}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; + +use st2_resource_wasip2::{ + CapabilityContext, CapabilityModule, InterruptionReason, + InvocationControl as ExecutorInvocationControl, InvocationStore, +}; +use wasmtime::component::{HasSelf, Linker}; + +mod bindings { + wasmtime::component::bindgen!({ + path: "../../wit/pty-stats", + world: "pty-stats-provider", + }); +} + +use bindings::compoundingtech::st2_pty_stats::pty_stats::{ + ExitStatus, Host, Outcome, PtyStatsError, Scope, +}; + +const IMPORT_NAME: &str = "compoundingtech:st2-pty-stats/pty-stats@0.1.0"; +const MAX_STDOUT_BYTES: usize = 2 * 1024 * 1024; +const MAX_STDERR_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PtyStatsScope { + All, + Session(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PtyStatsConfig { + pub executable: PathBuf, + pub cwd: PathBuf, + pub scope: PtyStatsScope, + pub deadline: Duration, +} + +impl PtyStatsConfig { + pub fn resolve( + executable: impl AsRef, + cwd: impl Into, + scope: PtyStatsScope, + deadline: Duration, + ) -> Result { + if deadline.is_zero() || deadline > Duration::from_secs(60) { + return Err("PTY stats deadline is invalid"); + } + let executable = + resolve_executable(executable.as_ref()).ok_or("PTY executable is unavailable")?; + let cwd = cwd.into(); + if !cwd.is_absolute() { + return Err("PTY stats cwd must be absolute"); + } + if let PtyStatsScope::Session(session) = &scope + && (session.is_empty() || session.len() > 512 || session.contains('\0')) + { + return Err("PTY session scope is invalid"); + } + Ok(Self { + executable, + cwd, + scope, + deadline, + }) + } +} + +#[derive(Clone)] +pub struct PtyStatsModule { + config: PtyStatsConfig, +} + +impl PtyStatsModule { + pub fn new(config: PtyStatsConfig) -> Self { + Self { config } + } +} + + +pub struct PtyStatsInvocation { + config: PtyStatsConfig, + control: Arc, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +enum Termination { + None = 0, + Cancelled = 1, + TimedOut = 2, +} + +struct ProcessControl { + termination: AtomicU8, + child: Mutex, + #[cfg(not(test))] + invocation: ExecutorInvocationControl, + #[cfg(test)] + invocation: Option, +} + +enum ChildOwnership { + Pending, + Live(i32), + Reaping, + Reaped, +} + +impl ProcessControl { + fn new(invocation: ExecutorInvocationControl) -> Self { + Self { + termination: AtomicU8::new(Termination::None as u8), + child: Mutex::new(ChildOwnership::Pending), + #[cfg(not(test))] + invocation, + #[cfg(test)] + invocation: Some(invocation), + } + } + + #[cfg(test)] + fn detached() -> Self { + Self { + termination: AtomicU8::new(Termination::None as u8), + child: Mutex::new(ChildOwnership::Pending), + invocation: None, + } + } + + fn termination(&self) -> Termination { + match self.termination.load(Ordering::Acquire) { + 1 => Termination::Cancelled, + 2 => Termination::TimedOut, + _ => match self.executor_interruption() { + Some(InterruptionReason::Cancelled) => Termination::Cancelled, + Some(InterruptionReason::TimedOut) => Termination::TimedOut, + None => Termination::None, + }, + } + } + + fn executor_interruption(&self) -> Option { + #[cfg(not(test))] + { + self.invocation.interruption_reason() + } + #[cfg(test)] + { + self.invocation + .as_ref() + .and_then(ExecutorInvocationControl::interruption_reason) + } + } + + fn install(&self, process_group: i32) { + let mut ownership = self.child.lock(); + debug_assert!(matches!(*ownership, ChildOwnership::Pending)); + *ownership = ChildOwnership::Live(process_group); + if self.termination() != Termination::None { + let _ = kill_process_group(process_group); + } + } + + fn terminate(&self, reason: Termination) -> bool { + let changed = self + .termination + .compare_exchange( + Termination::None as u8, + reason as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + if changed { + if let ChildOwnership::Live(process_group) = *self.child.lock() { + let _ = kill_process_group(process_group); + } + } + changed + } + fn synchronize_interruption(&self) -> Termination { + let reason = self.termination(); + if reason != Termination::None { + self.terminate(reason); + } + reason + } + + + fn wait_and_reap( + &self, + child: &mut std::process::Child, + ) -> std::io::Result { + wait_without_reaping(child.id())?; + let mut ownership = self.child.lock(); + debug_assert!(matches!(*ownership, ChildOwnership::Live(_))); + *ownership = ChildOwnership::Reaping; + let status = child.wait(); + *ownership = ChildOwnership::Reaped; + status + } + + fn kill_and_reap(&self, child: &mut std::process::Child) { + let mut ownership = self.child.lock(); + if let ChildOwnership::Live(process_group) = *ownership { + let _ = kill_process_group(process_group); + } + *ownership = ChildOwnership::Reaping; + let _ = child.wait(); + *ownership = ChildOwnership::Reaped; + } +} + + +impl CapabilityModule for PtyStatsModule { + type Invocation = PtyStatsInvocation; + + fn import_names(&self) -> &'static [&'static str] { + &[IMPORT_NAME] + } + + fn add_to_linker( + &self, + linker: &mut Linker>, + ) -> Result<(), wasmtime::Error> { + bindings::PtyStatsProvider::add_to_linker::<_, HasSelf<_>>(linker, |state| state) + } + + fn begin(&self, context: CapabilityContext<'_>) -> Self::Invocation { + PtyStatsInvocation { + config: self.config.clone(), + control: Arc::new(ProcessControl::new(context.control().clone())), + } + } +} + +impl Host for InvocationStore { + fn get(&mut self, scope: Scope) -> Result { + self.capability_mut().get(scope) + } +} + +impl PtyStatsInvocation { + fn get(&mut self, scope: Scope) -> Result { + if !scope_matches(&self.config.scope, &scope) { + return Err(PtyStatsError::Denied); + } + if self.control.termination() == Termination::Cancelled { + return Err(PtyStatsError::Cancelled); + } + let mut command = Command::new(&self.config.executable); + command + .arg("stats") + .arg("--json") + .current_dir(&self.config.cwd) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let PtyStatsScope::Session(session) = &self.config.scope { + command.arg(session); + } + // SAFETY: this runs in the freshly-forked child before exec and calls only async-signal-safe + // setpgid. The dedicated process group is the cancellation/reaping boundary. + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + let mut child = command.spawn().map_err(|_| PtyStatsError::Unavailable)?; + self.control.install(child.id() as i32); + let Some(stdout) = child.stdout.take() else { + self.control.kill_and_reap(&mut child); + return Err(PtyStatsError::Unavailable); + }; + let Some(stderr) = child.stderr.take() else { + self.control.kill_and_reap(&mut child); + return Err(PtyStatsError::Unavailable); + }; + let stdout_reader = match thread::Builder::new() + .name("st2-pty-stats-stdout".into()) + .spawn(move || drain_bounded(stdout, MAX_STDOUT_BYTES)) + { + Ok(reader) => reader, + Err(_) => { + self.control.kill_and_reap(&mut child); + return Err(PtyStatsError::Unavailable); + } + }; + let stderr_reader = match thread::Builder::new() + .name("st2-pty-stats-stderr".into()) + .spawn(move || drain_bounded(stderr, MAX_STDERR_BYTES)) + { + Ok(reader) => reader, + Err(_) => { + self.control.kill_and_reap(&mut child); + let _ = stdout_reader.join(); + return Err(PtyStatsError::Unavailable); + } + }; + let (completed_tx, completed_rx) = mpsc::sync_channel(1); + let deadline = Instant::now() + self.config.deadline; + let deadline_control = Arc::clone(&self.control); + let timer = match thread::Builder::new() + .name("st2-pty-stats-deadline".into()) + .spawn(move || loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + deadline_control.terminate(Termination::TimedOut); + return; + } + match completed_rx.recv_timeout(remaining.min(Duration::from_millis(10))) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + if deadline_control.synchronize_interruption() != Termination::None { + return; + } + }) + { + Ok(timer) => timer, + Err(_) => { + self.control.kill_and_reap(&mut child); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(PtyStatsError::Unavailable); + } + }; + let status = match self.control.wait_and_reap(&mut child) { + Ok(status) => status, + Err(_) => { + self.control.kill_and_reap(&mut child); + let _ = completed_tx.send(()); + let _ = timer.join(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(PtyStatsError::Unavailable); + } + }; + let _ = completed_tx.send(()); + let _ = timer.join(); + let (stdout, stdout_truncated) = stdout_reader + .join() + .map_err(|_| PtyStatsError::Unavailable)??; + let (stderr, stderr_truncated) = stderr_reader + .join() + .map_err(|_| PtyStatsError::Unavailable)??; + match self.control.termination() { + Termination::Cancelled => return Err(PtyStatsError::Cancelled), + Termination::TimedOut => return Err(PtyStatsError::DeadlineExceeded), + Termination::None => {} + } + if stdout_truncated || stderr_truncated { + return Err(PtyStatsError::ResourceExhausted); + } + let exit = status.code().map_or_else( + || ExitStatus::Signal(status.signal().unwrap_or(0)), + ExitStatus::Code, + ); + Ok(Outcome { + stdout, + stderr, + stdout_truncated, + stderr_truncated, + exit, + }) + } +} + +fn scope_matches(configured: &PtyStatsScope, requested: &Scope) -> bool { + match (configured, requested) { + (PtyStatsScope::All, Scope::All) => true, + (PtyStatsScope::Session(configured), Scope::Session(requested)) => configured == requested, + _ => false, + } +} + +fn drain_bounded( + mut input: impl std::io::Read, + limit: usize, +) -> Result<(Vec, bool), PtyStatsError> { + let mut retained = Vec::with_capacity(limit.min(64 * 1024)); + let mut truncated = false; + let mut buffer = [0_u8; 16 * 1024]; + loop { + let read = input.read(&mut buffer).map_err(|_| PtyStatsError::Unavailable)?; + if read == 0 { + break; + } + let remaining = limit.saturating_sub(retained.len()); + retained + .write_all(&buffer[..read.min(remaining)]) + .map_err(|_| PtyStatsError::Unavailable)?; + truncated |= read > remaining; + } + Ok((retained, truncated)) +} + +fn kill_process_group(process_group: i32) -> bool { + // SAFETY: negative pid addresses the process group created by pre_exec; SIGKILL is required to + // make the deadline a hard bound even when the provider subprocess ignores graceful signals. + unsafe { libc::kill(-process_group, libc::SIGKILL) == 0 } +} + +fn wait_without_reaping(pid: u32) -> std::io::Result<()> { + loop { + // SAFETY: `info` is initialized for the kernel, and WNOWAIT deliberately keeps the child + // waitable so its process-group identity cannot be recycled before ownership is fenced. + let result = unsafe { + let mut info = std::mem::zeroed::(); + libc::waitid( + libc::P_PID, + pid, + &mut info, + libc::WEXITED | libc::WNOWAIT, + ) + }; + if result == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error); + } + } +} +fn resolve_executable(executable: &Path) -> Option { + if executable.is_absolute() { + return executable_is_runnable(executable).then(|| executable.to_path_buf()); + } + let validation_cwd = std::env::current_dir().ok()?; + let search_path = std::env::var_os("PATH"); + resolve_executable_at( + executable, + &validation_cwd, + search_path.as_deref(), + ) +} + +fn resolve_executable_at( + executable: &Path, + validation_cwd: &Path, + search_path: Option<&std::ffi::OsStr>, +) -> Option { + debug_assert!(validation_cwd.is_absolute()); + if executable.components().count() > 1 { + let candidate = validation_cwd.join(executable); + return executable_is_runnable(&candidate).then_some(candidate); + } + let search_path = search_path?; + std::env::split_paths(search_path) + .map(|directory| { + let directory = if directory.is_absolute() { + directory + } else { + validation_cwd.join(directory) + }; + directory.join(executable) + }) + .find(|candidate| executable_is_runnable(candidate)) +} + +fn executable_is_runnable(path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt as _; + + + use super::*; + + fn write_executable(path: &Path, contents: &str) { + std::fs::write(path, contents).unwrap(); + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(path, permissions).unwrap(); + } + + fn invoke(config: PtyStatsConfig) -> Outcome { + PtyStatsInvocation { + config, + control: Arc::new(ProcessControl::detached()), + } + .get(Scope::All) + .unwrap() + } + + #[test] + fn slash_relative_executable_stays_bound_to_validation_cwd() { + let validation_cwd = std::env::current_dir().unwrap(); + let temporary = tempfile::Builder::new() + .prefix("st2-pty-resolution-") + .tempdir_in(&validation_cwd) + .unwrap(); + let configured_cwd = temporary.path().join("configured"); + std::fs::create_dir_all(temporary.path().join("tools")).unwrap(); + let validated_executable = temporary.path().join("tools/pty-stats"); + write_executable( + &validated_executable, + "#!/bin/sh\nprintf 'validated\\n'\n", + ); + let relative_executable = validated_executable.strip_prefix(&validation_cwd).unwrap(); + let rebound_executable = configured_cwd.join(relative_executable); + std::fs::create_dir_all(rebound_executable.parent().unwrap()).unwrap(); + write_executable(&rebound_executable, "#!/bin/sh\nprintf 'rebound\\n'\n"); + + let config = PtyStatsConfig::resolve( + relative_executable, + configured_cwd, + PtyStatsScope::All, + Duration::from_secs(1), + ) + .unwrap(); + assert!(config.executable.is_absolute()); + assert_eq!(config.executable, validated_executable); + assert_eq!(invoke(config).stdout, b"validated\n"); + } + + #[test] + fn relative_path_entry_stays_bound_to_validation_cwd() { + let temporary = tempfile::tempdir().unwrap(); + let validation_cwd = temporary.path().join("validation"); + let configured_cwd = temporary.path().join("configured"); + std::fs::create_dir_all(validation_cwd.join("bin")).unwrap(); + std::fs::create_dir_all(configured_cwd.join("bin")).unwrap(); + write_executable( + &validation_cwd.join("bin/pty-stats"), + "#!/bin/sh\nprintf 'validated-path\\n'\n", + ); + write_executable( + &configured_cwd.join("bin/pty-stats"), + "#!/bin/sh\nprintf 'rebound-path\\n'\n", + ); + + let executable = resolve_executable_at( + Path::new("pty-stats"), + &validation_cwd, + Some(std::ffi::OsStr::new("bin")), + ) + .unwrap(); + assert!(executable.is_absolute()); + assert_eq!(executable, validation_cwd.join("bin/pty-stats")); + let outcome = invoke(PtyStatsConfig { + executable, + cwd: configured_cwd, + scope: PtyStatsScope::All, + deadline: Duration::from_secs(1), + }); + assert_eq!(outcome.stdout, b"validated-path\n"); + } + + #[test] + fn fixed_command_deadline_kills_and_reaps_the_process_group() { + let temporary = tempfile::tempdir().unwrap(); + let executable = temporary.path().join("blocked-pty"); + let fifo = temporary.path().join("block"); + let fifo_c = CString::new(fifo.as_os_str().as_bytes()).unwrap(); + // SAFETY: the pathname is a live NUL-terminated byte string owned for the call. + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + std::fs::write( + &executable, + "#!/bin/sh\nexec 3< \"$PWD/block\"\nread value <&3\n", + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).unwrap(); + let config = PtyStatsConfig::resolve( + &executable, + temporary.path(), + PtyStatsScope::All, + Duration::from_millis(100), + ) + .unwrap(); + let control = Arc::new(ProcessControl::detached()); + let mut invocation = PtyStatsInvocation { + config, + control: Arc::clone(&control), + }; + assert!(matches!( + invocation.get(Scope::All), + Err(PtyStatsError::DeadlineExceeded) + )); + assert!(matches!(*control.child.lock(), ChildOwnership::Reaped)); + } + + #[test] + #[ignore = "requires packaged pty: cargo test -p st2-resource-providers pty_stats_live_json -- --ignored"] + fn pty_stats_live_json() { + let config = PtyStatsConfig::resolve( + "pty", + "/", + PtyStatsScope::All, + Duration::from_secs(10), + ) + .unwrap(); + let mut invocation = PtyStatsInvocation { + config, + control: Arc::new(ProcessControl::detached()), + }; + let outcome = invocation.get(Scope::All).unwrap(); + assert!(matches!(outcome.exit, ExitStatus::Code(0))); + serde_json::from_slice::(&outcome.stdout).unwrap(); + } +} diff --git a/crates/st2-resource-wasip2/src/lib.rs b/crates/st2-resource-wasip2/src/lib.rs index f6857072..5a7dcfe5 100644 --- a/crates/st2-resource-wasip2/src/lib.rs +++ b/crates/st2-resource-wasip2/src/lib.rs @@ -5,6 +5,8 @@ use st2_resource_protocol::SnapshotDigest; #[derive(Debug, Clone, PartialEq)] pub struct ObservationRequest { + /// Host-only identity for cancellation ownership; it is never passed to the guest. + pub invocation_id: u64, pub uri: String, pub selector: Value, pub prior_digest: Option, @@ -53,7 +55,7 @@ mod runtime { use wasmtime::{Config, Engine, Store, Trap, UpdateDeadline}; use crate::bindings::Provider; - use crate::bindings::exports::provider_api as guest; + use crate::bindings::exports::st2::resource_provider::provider_api as guest; use crate::cache::{self, CacheDisposition, CacheIdentity, CacheLookup, PrivateArtifactCache}; use crate::limits::InvocationLimits; use crate::{ObservationRequest, ProviderDescriptor, SchedulingCapability}; @@ -432,7 +434,7 @@ mod runtime { classify_execution_error(&store, &control.state.reason, error, true).describe() })?; let result = bindings - .provider_api() + .st2_resource_provider_provider_api() .call_describe(&mut store) .map_err(|error| { classify_execution_error(&store, &control.state.reason, error, false).describe() @@ -499,7 +501,7 @@ mod runtime { classify_execution_error(&store, &control.state.reason, error, true).observe() })?; let result = bindings - .provider_api() + .st2_resource_provider_provider_api() .call_observe(&mut store, &guest_request) .map_err(|error| { classify_execution_error(&store, &control.state.reason, error, false).observe() diff --git a/crates/st2-resource-wasip2/tests/executor.rs b/crates/st2-resource-wasip2/tests/executor.rs index 33851a59..5f4bd353 100644 --- a/crates/st2-resource-wasip2/tests/executor.rs +++ b/crates/st2-resource-wasip2/tests/executor.rs @@ -1,4 +1,5 @@ use std::fs; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Barrier}; use serde_json::json; @@ -92,6 +93,29 @@ impl CapabilityModule for FixtureCapabilities { FixtureInvocation::default() } } +struct CountingCapabilities { + begins: Arc, +} + +impl CapabilityModule for CountingCapabilities { + type Invocation = (); + + fn import_names(&self) -> &'static [&'static str] { + &[] + } + + fn add_to_linker( + &self, + _linker: &mut Linker>, + ) -> Result<(), wasmtime::Error> { + Ok(()) + } + + fn begin(&self, _context: CapabilityContext<'_>) -> Self::Invocation { + self.begins.fetch_add(1, Ordering::Relaxed); + } +} + struct BlockingCapabilities { entered: Arc, } @@ -139,6 +163,7 @@ fn executor(config: RuntimeConfig, cache: Option) -> Execu fn request() -> ObservationRequest { ObservationRequest { + invocation_id: 1, uri: "fixture://resource".to_owned(), selector: json!({"region": "local"}), prior_digest: None, @@ -180,6 +205,31 @@ fn executes_the_repository_provider_world() { )) )); } +#[test] +fn request_validation_precedes_capability_begin_and_leaves_no_host_state() { + let begins = Arc::new(AtomicUsize::new(0)); + let executor = Executor::new( + RuntimeConfig::default(), + None, + CountingCapabilities { + begins: Arc::clone(&begins), + }, + ) + .unwrap(); + let loaded = executor.load(&component("(component)")).unwrap(); + let mut invalid = request(); + invalid.uri = "x".repeat(64 * 1024 + 1); + + for invocation_id in 1..=64 { + invalid.invocation_id = invocation_id; + assert!(matches!( + executor.observe(&loaded, &invalid, None), + Err(ObserveError::InvalidRequest("URI exceeds 64 KiB")) + )); + } + assert_eq!(begins.load(Ordering::Relaxed), 0); +} + #[test] fn host_rejects_a_semantically_invalid_guest_proposal() { diff --git a/crates/st2-resource-wasip2/tests/fixtures/no-effect-component.wat b/crates/st2-resource-wasip2/tests/fixtures/no-effect-component.wat index f7689c20..3cc30e06 100644 --- a/crates/st2-resource-wasip2/tests/fixtures/no-effect-component.wat +++ b/crates/st2-resource-wasip2/tests/fixtures/no-effect-component.wat @@ -141,5 +141,5 @@ (export "describe" (func $implemented-describe)) (export "observe" (func $implemented-observe)) ) - (export "provider-api" (instance $api)) + (export "st2:resource-provider/provider-api@0.1.0" (instance $api)) ) diff --git a/crates/st2-resource-wasip2/wit/provider.wit b/crates/st2-resource-wasip2/wit/provider.wit index 2c7bc520..ecf8fbe0 100644 --- a/crates/st2-resource-wasip2/wit/provider.wit +++ b/crates/st2-resource-wasip2/wit/provider.wit @@ -55,11 +55,3 @@ interface provider-api { describe: func() -> result; observe: func(request: observe-request) -> observation-result; } - -world provider { - export provider-api: provider-api; -} - -world fixture-host { - import provider-api; -} diff --git a/crates/st2-resource-wasip2/wit/worlds.wit b/crates/st2-resource-wasip2/wit/worlds.wit new file mode 100644 index 00000000..e24b4e37 --- /dev/null +++ b/crates/st2-resource-wasip2/wit/worlds.wit @@ -0,0 +1,9 @@ +package st2:resource-provider@0.1.0; + +world provider { + export provider-api; +} + +world fixture-host { + import provider-api; +} diff --git a/examples/README.md b/examples/README.md index 0aaed689..83693bc6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,4 +1,4 @@ -# Native st2 examples +# st2 examples [`native/`](native/) contains the maintained, hand-authored Codex and Claude agent declaration shapes. Render one outside the catalog as: @@ -23,3 +23,35 @@ st2 up --catalog --host --once Canonical KDL is the boundary; st2 does not compile intent. Inspect all KDL and workspace targets before publication. + +## WASIp2 resource-provider E2E + +This exact command builds both immutable guest components, creates temporary GitHub Issue and PTY +catalogs/bindings, runs both through the production supervisor, checks carrier publication, +unchanged replay, and a denied GitHub scope with no partial commit, then drops both supervisors. +It performs one anonymous, read-only request for `rust-lang/rust#1`; it writes no remote state and +uses no credentials. + +```sh +github_component="$( + nix build --no-link --print-out-paths .#st2-github-issue-component +)/share/st2/providers/st2_github_issue_component.component.wasm" +pty_component="$( + nix build --no-link --print-out-paths .#st2-pty-stats-component +)/share/st2/providers/st2_pty_stats_component.component.wasm" +ST2_GITHUB_ISSUE_COMPONENT="$github_component" \ +ST2_PTY_STATS_COMPONENT="$pty_component" \ +nix develop --command cargo test \ + --features wasip2-provider-runtime \ + --test resource_provider_e2e \ + both_real_components_publish_replay_unchanged_and_fail_without_commit \ + -- --ignored --exact +``` + +The production binary is opt-in for the same reason: + +```sh +nix build .#st2-provider-runtime +``` + +The default `.#st2` package and default Cargo members remain Wasmtime-free. diff --git a/flake.lock b/flake.lock index 82f37cad..67f6ecc6 100644 --- a/flake.lock +++ b/flake.lock @@ -21,6 +21,27 @@ "type": "github" } }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1788261902, + "narHash": "sha256-N7UHRZbJzgWaad+SOgl9QlWKiBulHan2/ezZhLfnjlo=", + "owner": "nix-community", + "repo": "fenix", + "rev": "af85de9d317b402448a8ebb06bc16c7fd82f0375", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -145,11 +166,29 @@ "root": { "inputs": { "effect-utils": "effect-utils", + "fenix": "fenix", "flake-utils": "flake-utils_2", "nixpkgs": "nixpkgs_3", "pty": "pty" } }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1788194240, + "narHash": "sha256-dehprdAEnE6veOVeKq1wbOKmwPPoNbVHwwBj3l8rOFY=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "ce43a12d9fe4a24e5d6a86d29a57234efcc902b7", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index f10e9044..52556394 100644 --- a/flake.nix +++ b/flake.nix @@ -4,6 +4,8 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + fenix.url = "github:nix-community/fenix"; + fenix.inputs.nixpkgs.follows = "nixpkgs"; # Packaged PTY dependency: the merged revision with atomic metadata patching and the # fleet-observation guarantees required by st2 reconciliation. pty.url = "github:compoundingtech/pty/504ac7332895fe1fa3767b530dcd99f091f56cda"; @@ -22,6 +24,7 @@ self, nixpkgs, flake-utils, + fenix, pty, effect-utils, }: @@ -29,6 +32,15 @@ system: let pkgs = import nixpkgs { inherit system; }; + providerRustToolchain = fenix.packages.${system}.combine [ + fenix.packages.${system}.stable.cargo + fenix.packages.${system}.stable.rustc + fenix.packages.${system}.targets.wasm32-unknown-unknown.stable.rust-std + ]; + providerRustPlatform = pkgs.makeRustPlatform { + cargo = providerRustToolchain; + rustc = providerRustToolchain; + }; # Cargo.toml is the single source of truth for the version, so a release # bump needs no matching edit here. @@ -127,6 +139,12 @@ # selects only `st2` and silently skips the `agent-spec` crate. cargoTestFlags = [ "--workspace" + "--exclude" + "st2-resource-providers" + "--exclude" + "st2-github-issue-component" + "--exclude" + "st2-pty-stats-component" "--lib" "--bins" "--test" @@ -169,14 +187,18 @@ pname = "st2-wasm-resolver-check"; cargoTestFlags = [ "--workspace" + "--exclude" + "st2-resource-providers" + "--exclude" + "st2-github-issue-component" + "--exclude" + "st2-pty-stats-component" "--test" "resync" "--test" "resync_notify_chain" "--test" "profile_wasm" - "--test" - "resource_profile_supervisor_e2e" ]; }); @@ -196,6 +218,75 @@ ]; }); + buildProviderComponent = + { + package, + wasmName, + }: + providerRustPlatform.buildRustPackage { + pname = package; + inherit version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + buildPhase = '' + runHook preBuild + cargo build --offline --release -p ${package} --target wasm32-unknown-unknown + runHook postBuild + ''; + doCheck = false; + nativeBuildInputs = [ + pkgs.lld + pkgs.wasm-tools + ]; + installPhase = '' + runHook preInstall + mkdir -p "$out/share/st2/providers" + wasm-tools component new \ + "target/wasm32-unknown-unknown/release/${wasmName}.wasm" \ + -o "$out/share/st2/providers/${wasmName}.component.wasm" + runHook postInstall + ''; + }; + + st2GitHubIssueComponent = buildProviderComponent { + package = "st2-github-issue-component"; + wasmName = "st2_github_issue_component"; + }; + + st2PtyStatsComponent = buildProviderComponent { + package = "st2-pty-stats-component"; + wasmName = "st2_pty_stats_component"; + }; + + st2ProviderRuntime = st2.overrideAttrs (old: { + pname = "st2-provider-runtime"; + cargoBuildFeatures = (old.cargoBuildFeatures or [ ]) ++ [ "wasip2-provider-runtime" ]; + cargoCheckFeatures = (old.cargoCheckFeatures or [ ]) ++ [ "wasip2-provider-runtime" ]; + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ + pkgs.lld + pty.packages.${system}.default + ]; + }); + + st2ProviderRuntimeCheck = st2ProviderRuntime.overrideAttrs (_: { + pname = "st2-provider-runtime-check"; + ST2_GITHUB_ISSUE_COMPONENT = "${st2GitHubIssueComponent}/share/st2/providers/st2_github_issue_component.component.wasm"; + ST2_PTY_STATS_COMPONENT = "${st2PtyStatsComponent}/share/st2/providers/st2_pty_stats_component.component.wasm"; + cargoTestFlags = [ + "-p" + "st2-resource-providers" + "--lib" + "-p" + "st2" + "--features" + "st2/wasip2-provider-runtime" + "--test" + "resource_profile_supervisor_e2e" + "--test" + "resource_provider_e2e" + ]; + }); + # Narrow sandbox-safe integration gate for the atomic snapshot boundary. The main package # deliberately omits the broad doctor suite because some doctor cases exercise facilities # unavailable in the Nix sandbox. A dedicated target containing exactly one test makes the @@ -295,6 +386,9 @@ { packages.st2 = st2; packages.st2-wasm-resolver = st2WasmResolver; + packages.st2-provider-runtime = st2ProviderRuntime; + packages.st2-github-issue-component = st2GitHubIssueComponent; + packages.st2-pty-stats-component = st2PtyStatsComponent; packages.default = st2; # `nix flake check` is the whole CI: it builds the package — which runs @@ -314,6 +408,9 @@ checks.otel-export = st2OtelExport; checks.wasm-resolver-feature = st2WasmResolverCheck; checks.wasip2-resource-executor = st2Wasip2ExecutorCheck; + checks.wasip2-resource-providers = st2ProviderRuntimeCheck; + checks.github-issue-component = st2GitHubIssueComponent; + checks.pty-stats-component = st2PtyStatsComponent; # Exercise the shipped binary, not a cargo-side surrogate: its version entrypoint runs and # the same artifact strictly admits a catalog carrying a real wasm profile module. checks.wasm-resolver-artifact = pkgs.runCommand "st2-wasm-resolver-artifact-${version}" { } '' diff --git a/src/catalog.rs b/src/catalog.rs index f356d90e..5c70d2ff 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -32,7 +32,7 @@ use kdl::KdlDocument; /// The catalog-level declaration, read from the catalog root. pub const CONFIG_FILE: &str = "catalog.kdl"; -/// One declared resource profile with a closed direct runtime and optional `demand` capability. +/// One declared resource profile with a closed component runtime and optional demand observation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeclaredProfile { /// The URI scheme this profile resolves. @@ -44,18 +44,41 @@ pub struct DeclaredProfile { /// Whether a binding through this profile also subscribes to its ancestors' same-scheme /// carriers; defaults to off. pub notify_chain: bool, - /// Trusted direct process invocation for an observable profile. + /// Immutable provider component and its one typed host capability. pub runtime: Option, } -/// The closed host-runtime declaration. `argv[0]` is executed directly; no shell is involved. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeclaredProfileRuntime { - pub argv: Vec, + pub component: String, + pub capability: DeclaredProviderCapability, /// Opt in to demand-driven observation through one atomic observation result. pub demand: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeclaredProviderCapability { + GitHubIssue { + owner: String, + repo: String, + number: u64, + connect_timeout_ms: u64, + total_timeout_ms: u64, + }, + PtyStats { + executable: String, + cwd: String, + scope: DeclaredPtyStatsScope, + deadline_ms: u64, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeclaredPtyStatsScope { + All, + Session(String), +} + /// What `/catalog.kdl` declares. An absent file leaves every field empty. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CatalogConfig { @@ -183,71 +206,82 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result { anyhow::bail!("profile '{scheme}': runtime takes no values or properties"); } let runtime_children = child.children().ok_or_else(|| { - anyhow::anyhow!("profile '{scheme}': runtime needs exactly one argv child") + anyhow::anyhow!( + "profile '{scheme}': runtime needs a component and one typed capability" + ) })?; - let mut argv = None; + let mut component = None; + let mut capability = None; let mut demand = false; + let mut seen_demand = false; for runtime_child in runtime_children.nodes() { match runtime_child.name().value() { - "argv" => { - if argv.is_some() - || runtime_child.children().is_some() - || runtime_child.entries().is_empty() - { - anyhow::bail!( - "profile '{scheme}': runtime needs exactly one argv child with one or more arguments" - ); - } - argv = Some( - runtime_child - .entries() - .iter() - .map(|entry| { - (entry.name().is_none()) - .then(|| entry.value().as_string()) - .flatten() - .filter(|value| !value.is_empty()) - .map(str::to_owned) - }) - .collect::>>() - .ok_or_else(|| { - anyhow::anyhow!( - "profile '{scheme}': runtime argv accepts only non-empty quoted arguments" - ) - })?, + "component" => { + anyhow::ensure!( + component.is_none() + && runtime_child.children().is_none() + && runtime_child.entries().len() == 1, + "profile '{scheme}': runtime needs exactly one component path" + ); + component = runtime_child + .get(0) + .and_then(|value| value.as_string()) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + anyhow::ensure!( + component.is_some(), + "profile '{scheme}': component path must be a non-empty quoted string" + ); + } + "demand" => { + anyhow::ensure!( + !seen_demand, + "profile '{scheme}': demand is declared more than once" + ); + anyhow::ensure!( + runtime_child.children().is_none() + && runtime_child.entries().len() == 1, + "profile '{scheme}': demand takes one boolean value" ); + demand = runtime_child + .get(0) + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + anyhow::anyhow!( + "profile '{scheme}': demand takes one boolean value" + ) + })?; + seen_demand = true; } - "capability" => { - let entries = runtime_child.entries(); - let capability = (runtime_child.children().is_none() - && entries.len() == 1 - && entries[0].name().is_none()) - .then(|| entries[0].value().as_string()) - .flatten() - .ok_or_else(|| { - anyhow::anyhow!( - "profile '{scheme}': runtime capability needs exactly one quoted value" - ) - })?; + "github-issue" => { anyhow::ensure!( - capability == "demand", - "profile '{scheme}': unknown runtime capability '{capability}'" + capability.is_none() && runtime_child.children().is_none(), + "profile '{scheme}': runtime declares more than one capability" ); + capability = Some(parse_github_issue_capability(scheme, runtime_child)?); + } + "pty-stats" => { anyhow::ensure!( - !demand, - "profile '{scheme}': runtime capability 'demand' is declared more than once" + capability.is_none() && runtime_child.children().is_none(), + "profile '{scheme}': runtime declares more than one capability" ); - demand = true; + capability = Some(parse_pty_stats_capability(scheme, runtime_child)?); } other => anyhow::bail!( - "profile '{scheme}': runtime field '{other}' is unknown (expected argv or capability)" + "profile '{scheme}': runtime field '{other}' is unknown \ + (expected component, demand, github-issue, or pty-stats)" ), } } - let argv = argv.ok_or_else(|| { - anyhow::anyhow!("profile '{scheme}': runtime needs exactly one argv child") - })?; - runtime = Some(DeclaredProfileRuntime { argv, demand }); + runtime = Some(DeclaredProfileRuntime { + component: component.ok_or_else(|| { + anyhow::anyhow!("profile '{scheme}': runtime needs exactly one component path") + })?, + capability: capability.ok_or_else(|| { + anyhow::anyhow!("profile '{scheme}': runtime needs exactly one typed capability") + })?, + demand, + }); continue; } if child.children().is_some() { @@ -321,6 +355,102 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result { }) } +fn parse_github_issue_capability( + scheme: &str, + node: &kdl::KdlNode, +) -> anyhow::Result { + anyhow::ensure!( + node.entries().len() == 5 && node.entries().iter().all(|entry| entry.name().is_some()), + "profile '{scheme}': github-issue requires owner, repo, number, \ + connect-timeout-ms, and total-timeout-ms properties" + ); + let owner = required_string_property(scheme, node, "owner")?; + let repo = required_string_property(scheme, node, "repo")?; + let number = required_u64_property(scheme, node, "number")?; + let connect_timeout_ms = required_u64_property(scheme, node, "connect-timeout-ms")?; + let total_timeout_ms = required_u64_property(scheme, node, "total-timeout-ms")?; + anyhow::ensure!(number > 0, "profile '{scheme}': GitHub issue number must be positive"); + anyhow::ensure!( + connect_timeout_ms > 0 + && connect_timeout_ms <= total_timeout_ms + && total_timeout_ms <= 60_000, + "profile '{scheme}': GitHub deadlines must be positive, ordered, and at most 60000ms" + ); + Ok(DeclaredProviderCapability::GitHubIssue { + owner, + repo, + number, + connect_timeout_ms, + total_timeout_ms, + }) +} + +fn parse_pty_stats_capability( + scheme: &str, + node: &kdl::KdlNode, +) -> anyhow::Result { + anyhow::ensure!( + node.entries().len() == 4 && node.entries().iter().all(|entry| entry.name().is_some()), + "profile '{scheme}': pty-stats requires executable, cwd, scope, and deadline-ms properties" + ); + let executable = required_string_property(scheme, node, "executable")?; + let cwd = required_string_property(scheme, node, "cwd")?; + let deadline_ms = required_u64_property(scheme, node, "deadline-ms")?; + anyhow::ensure!( + deadline_ms > 0 && deadline_ms <= 60_000, + "profile '{scheme}': PTY deadline must be between 1ms and 60000ms" + ); + let scope = required_string_property(scheme, node, "scope")?; + let scope = if scope == "all" { + DeclaredPtyStatsScope::All + } else if let Some(session) = scope.strip_prefix("session:").filter(|value| !value.is_empty()) { + DeclaredPtyStatsScope::Session(session.to_owned()) + } else { + anyhow::bail!( + "profile '{scheme}': PTY scope must be 'all' or 'session:'" + ); + }; + Ok(DeclaredProviderCapability::PtyStats { + executable, + cwd, + scope, + deadline_ms, + }) +} + +fn required_string_property( + scheme: &str, + node: &kdl::KdlNode, + name: &str, +) -> anyhow::Result { + node.get(name) + .and_then(|value| value.as_string()) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + anyhow::anyhow!( + "profile '{scheme}': '{}' property '{name}' must be a non-empty string", + node.name().value() + ) + }) +} + +fn required_u64_property( + scheme: &str, + node: &kdl::KdlNode, + name: &str, +) -> anyhow::Result { + node.get(name) + .and_then(|value| value.as_integer()) + .and_then(|value| u64::try_from(value).ok()) + .ok_or_else(|| { + anyhow::anyhow!( + "profile '{scheme}': '{}' property '{name}' must be a non-negative integer", + node.name().value() + ) + }) +} + /// Read `/catalog.kdl`. A missing file is the default declaration, not an error. pub fn load(catalog_root: &Path) -> anyhow::Result { match std::fs::read_to_string(config_path(catalog_root)) { @@ -360,6 +490,22 @@ pub(crate) fn resolve_profile_module( )) } +/// Resolve a provider component as a catalog-owned transactional artifact. +/// +/// Unlike legacy resolver modules, provider components may not be external: the component bytes +/// and `catalog.kdl` capability declaration must cross apply/generation boundaries together. +pub(crate) fn resolve_provider_component( + catalog_root: &Path, + declared: &str, +) -> anyhow::Result { + match resolve_profile_module(catalog_root, declared)? { + ResolvedProfileModule::CatalogRelative(relative) => Ok(relative), + ResolvedProfileModule::External(_) => { + anyhow::bail!("provider component must be catalog-relative: {declared}") + } + } +} + pub(crate) fn validate_catalog_relative_profile_module_path(relative: &Path) -> anyhow::Result<()> { let components = relative .components() @@ -562,6 +708,18 @@ fn validate_runtime_contracts( } #[cfg(feature = "wasm-resolver")] { + #[cfg(not(feature = "wasip2-provider-runtime"))] + if let Some(profile) = config + .profiles + .iter() + .find(|profile| profile.runtime.is_some()) + { + anyhow::bail!( + "profile '{}': component provider runtime unavailable because st2 was built \ + without the `wasip2-provider-runtime` feature", + profile.scheme + ); + } let refresh = registry.begin_refresh(); for profile in &config.profiles { let descriptor = match refresh.try_descriptor(&profile.scheme) { @@ -718,14 +876,15 @@ mod tests { } #[test] - fn runtime_grammar_is_closed_and_argv_is_direct() { + fn runtime_grammar_has_one_immutable_component_and_one_typed_capability() { let config = parse( r#" profile "dev.example.observe" { wasm "observe.wasm" runtime { - argv "github-resource-runtime" "pr" - capability "demand" + component "components/github-issue.wasm" + demand #true + github-issue owner="rust-lang" repo="rust" number=1 connect-timeout-ms=3000 total-timeout-ms=10000 } } "#, @@ -734,27 +893,31 @@ mod tests { assert_eq!( config.profiles[0].runtime, Some(DeclaredProfileRuntime { - argv: vec!["github-resource-runtime".into(), "pr".into()], + component: "components/github-issue.wasm".into(), + capability: DeclaredProviderCapability::GitHubIssue { + owner: "rust-lang".into(), + repo: "rust".into(), + number: 1, + connect_timeout_ms: 3000, + total_timeout_ms: 10000, + }, demand: true, }) ); for malformed in [ r#"profile "dev.x" { wasm "x.wasm"; runtime }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime "shell" { argv "x" } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime "shell" { component "x.wasm" } }"#, r#"profile "dev.x" { wasm "x.wasm"; runtime { } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { argv } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "" } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "x"; argv "y" } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { command "x" } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "x"; capability "unknown" } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "x"; capability "demand"; capability "demand" } }"#, - r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "x" }; runtime { argv "y" } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x.wasm" } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component ""; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; component "y"; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "x" } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000; github-issue owner="o" repo="r" number=1 connect-timeout-ms=1 total-timeout-ms=2 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; demand #true; demand #true; pty-stats executable="pty" cwd="/" scope="all" deadline-ms=1000 } }"#, + r#"profile "dev.x" { wasm "x.wasm"; runtime { component "x"; pty-stats executable="pty" cwd="/" scope="shell" deadline-ms=1000 } }"#, ] { assert!(parse(malformed).is_err(), "expected error for: {malformed}"); } - let without_demand = - parse(r#"profile "dev.x" { wasm "x.wasm"; runtime { argv "x" } }"#).unwrap(); - assert!(!without_demand.profiles[0].runtime.as_ref().unwrap().demand); } #[test] diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index 23f88025..874525b6 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -2247,19 +2247,30 @@ fn collect_profile_modules( let config = crate::catalog::load(root).context("parse catalog profile modules")?; let mut modules = BTreeSet::new(); for profile in config.profiles { - let crate::catalog::ResolvedProfileModule::CatalogRelative(relative) = + if let crate::catalog::ResolvedProfileModule::CatalogRelative(relative) = crate::catalog::resolve_profile_module(logical_catalog, &profile.wasm)? - else { - continue; - }; - let normalized = add_profile_module(root, &relative, files).with_context(|| { - format!( - "admit catalog-relative profile module '{}' for scheme '{}'", - relative.display(), - profile.scheme - ) - })?; - modules.insert(normalized); + { + let normalized = add_profile_module(root, &relative, files).with_context(|| { + format!( + "admit catalog-relative profile module '{}' for scheme '{}'", + relative.display(), + profile.scheme + ) + })?; + modules.insert(normalized); + } + if let Some(runtime) = &profile.runtime { + let relative = + crate::catalog::resolve_provider_component(logical_catalog, &runtime.component)?; + let normalized = add_profile_module(root, &relative, files).with_context(|| { + format!( + "admit provider component '{}' for scheme '{}'", + relative.display(), + profile.scheme + ) + })?; + modules.insert(normalized); + } } Ok(modules) } @@ -2313,6 +2324,15 @@ fn add_profile_module( Ok(normalized) } +pub(crate) fn read_provider_component(root: &Path, relative: &Path) -> Result> { + let mut files = BTreeMap::new(); + let normalized = add_profile_module(root, relative, &mut files)?; + files + .remove(&normalized) + .map(|file| file.bytes) + .context("admitted provider component disappeared") +} + fn add_regular( root: &Path, diff --git a/src/resource_profile_supervisor.rs b/src/resource_profile_supervisor.rs index d452cd4d..dd9ede94 100644 --- a/src/resource_profile_supervisor.rs +++ b/src/resource_profile_supervisor.rs @@ -1,15 +1,14 @@ -//! Resident process supervision for observable Resource Profiles. +//! Resident Component Model supervision for observable Resource Profiles. //! -//! The worker is the sole owner of runtime processes and their protocol state. Reconcile callers -//! submit complete desired binding sets; removals are acknowledged only after registrations have -//! been fenced and processes no longer owned by the desired generation have been stopped. +//! The worker owns component instances and their atomic proposal fences. Reconcile callers submit +//! complete desired binding sets; removals are acknowledged only after registrations have been +//! fenced and provider invocations no longer belong to the desired generation. use std::collections::{BTreeMap, BTreeSet}; -use std::io::{self, BufRead, BufReader, Write as _}; use std::path::{Component, Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::sync::mpsc::{self, Receiver, SyncSender}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, SystemTime}; @@ -22,6 +21,18 @@ use notify::RecommendedWatcher; use serde::Serialize; use serde_json::Value; use sha2::{Digest as _, Sha256}; +#[cfg(feature = "wasip2-provider-runtime")] +use st2_resource_protocol::ProposalFence; +#[cfg(feature = "wasip2-provider-runtime")] +use st2_resource_providers::{ + GitHubIssueConfig, GitHubIssueModule, PtyStatsConfig, PtyStatsModule, PtyStatsScope, +}; +#[cfg(feature = "wasip2-provider-runtime")] +use st2_resource_wasip2::{ + Executor as Wasip2Executor, LoadedComponent, ObservationRequest as Wasip2ObservationRequest, + ProviderDescriptor as Wasip2ProviderDescriptor, RuntimeConfig as Wasip2RuntimeConfig, + SchedulingCapability as Wasip2SchedulingCapability, +}; use crate::catalog::CatalogConfig; use crate::resource_observe::{ @@ -31,14 +42,12 @@ use crate::resource_observe::{ }; use crate::resource_profile::{ AcceptedObservation, AcceptedOutput, AcceptedPublication, BindingId, BindingRegistration, - CatchUp, HostMessage, MAX_PROTOCOL_LINE_BYTES, OwnerClaim, PublicationContract, - PublicationOutcome, RegistrationToken, ResourceFact, RuntimeHealthState, RuntimeIncarnation, - RuntimeLifecycle, RuntimeMessage, RuntimeOwner, SnapshotDigest, SnapshotTarget, TopicSelection, - decode_runtime_line, encode_host_line, + CatchUp, OwnerClaim, PublicationContract, PublicationOutcome, RegistrationToken, ResourceFact, + RuntimeHealthState, RuntimeIncarnation, RuntimeLifecycle, RuntimeMessage, RuntimeOwner, + SnapshotDigest, SnapshotTarget, TopicSelection, }; const MAILBOX_CAPACITY: usize = 64; -const WRITER_CAPACITY: usize = 64; static ID_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Debug, Clone, PartialEq, Eq)] @@ -60,6 +69,7 @@ pub struct ResourceProfileSupervisor { catalog_root: PathBuf, this_host: String, observe_watcher: Option, + component_cache: Mutex, observe_bridge: Option>, } @@ -79,18 +89,18 @@ impl ResourceProfileSupervisor { ); } let (tx, rx) = mpsc::sync_channel(MAILBOX_CAPACITY); - let worker_tx = tx.clone(); let worker_root = catalog_root.clone(); let worker_host = this_host.clone(); + let completion_tx = tx.clone(); let worker = thread::Builder::new() .name("st2-resource-profile".to_owned()) .spawn(move || { Worker::new( worker_root, worker_host, - worker_tx, request_dir, receipt_dir, + completion_tx, ) .run(rx); }) @@ -109,6 +119,7 @@ impl ResourceProfileSupervisor { let supervisor = Self { tx, worker: Some(worker), + component_cache: Mutex::new(ComponentSnapshotCache::default()), catalog_root, this_host, observe_watcher, @@ -137,6 +148,7 @@ impl ResourceProfileSupervisor { profiles, generation, live_specs, + &self.component_cache, ); let (reply_tx, reply_rx) = mpsc::sync_channel(1); if self @@ -212,29 +224,119 @@ enum Msg { Health { reply: SyncSender>, }, - RuntimeOutput { - key: RuntimeKey, - owner: RuntimeOwner, - output: Result, - }, - RuntimeEof { - key: RuntimeKey, - owner: RuntimeOwner, - }, - WriterDrained { - key: RuntimeKey, - owner: RuntimeOwner, - }, - WriterFailed { - key: RuntimeKey, - owner: RuntimeOwner, - error: String, - }, ObserveRequests, + #[cfg(feature = "wasip2-provider-runtime")] + ObservationCompleted(ObservationCompletion), Shutdown { reply: SyncSender<()>, }, } +#[cfg(feature = "wasip2-provider-runtime")] +#[derive(Debug)] +struct ObservationCompletion { + job_id: u64, + runtime_key: RuntimeKey, + stable_key: String, + authority: ObservationAuthority, + watermark: u64, + fence: ProposalFence, + result: Result, +} + +#[cfg(feature = "wasip2-provider-runtime")] +struct ObservationJob { + runtime_key: RuntimeKey, + stable_key: String, + cancellation: ObservationCancellation, + join: JoinHandle<()>, +} + +#[cfg(feature = "wasip2-provider-runtime")] +struct ObservationLaunch { + job_id: u64, + runtime_key: RuntimeKey, + stable_key: String, + authority: ObservationAuthority, + watermark: u64, + fence: ProposalFence, + request: Wasip2ObservationRequest, + provider: Arc, + cancellation: ObservationCancellation, +} + +#[cfg(feature = "wasip2-provider-runtime")] +#[derive(Clone)] +struct ObservationCancellation { + interruption: st2_resource_wasip2::InterruptionHandle, +} + +#[cfg(feature = "wasip2-provider-runtime")] +impl ObservationCancellation { + fn cancel(&self) { + self.interruption.cancel(); + } +} + +#[cfg(feature = "wasip2-provider-runtime")] +fn catch_observation( + observe: impl FnOnce( + ) -> Result, +) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(observe)) + .map_err(|_| "provider observation panicked".to_owned())? + .map_err(|error| error.to_string()) +} + +#[derive(Debug)] +struct ProviderComponentSnapshot { + relative: PathBuf, + identity: String, + bytes: Arc<[u8]>, +} + +impl PartialEq for ProviderComponentSnapshot { + fn eq(&self, other: &Self) -> bool { + self.relative == other.relative && self.identity == other.identity + } +} + +#[derive(Default)] +struct ComponentSnapshotCache { + catalog_generation: Option, + snapshots: BTreeMap>, +} + +impl ComponentSnapshotCache { + fn begin_refresh(&mut self, catalog_generation: Option) { + if catalog_generation.is_none() || self.catalog_generation != catalog_generation { + self.snapshots.clear(); + self.catalog_generation = catalog_generation; + } + } + + fn load( + &mut self, + catalog_root: &Path, + component: &str, + ) -> anyhow::Result> { + if let Some(snapshot) = self.snapshots.get(component) { + return Ok(Arc::clone(snapshot)); + } + let relative = crate::catalog::resolve_provider_component(catalog_root, component)?; + let bytes = crate::catalog_transaction::read_provider_component(catalog_root, &relative) + .with_context(|| format!("read provider component '{}'", relative.display()))?; + let snapshot = Arc::new(ProviderComponentSnapshot { + relative, + identity: format!("{:x}", Sha256::digest(&bytes)), + bytes: Arc::from(bytes), + }); + self.snapshots + .insert(component.to_owned(), Arc::clone(&snapshot)); + Ok(snapshot) + } +} + + #[derive(Debug, Clone, PartialEq)] struct DesiredBinding { @@ -246,8 +348,8 @@ struct DesiredBinding { scheme: String, generation: u64, topology: RuntimeTopology, - argv: Vec, - demand: bool, + runtime: crate::catalog::DeclaredProfileRuntime, + component: Arc, uri: String, selector: Value, descriptor: ProfileDescriptor, @@ -290,12 +392,18 @@ fn desired_bindings( profiles: &ResourceProfileRegistry, generation: Option, live_specs: &[AgentSpec], + component_cache: &Mutex, ) -> (BTreeMap, Vec) { let catalog_generation = generation.unwrap_or(0); let refresh = profiles.begin_refresh(); + let mut component_cache = component_cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + component_cache.begin_refresh(generation); let mut descriptors = BTreeMap::new(); let mut runtimes = BTreeMap::new(); let mut generations = BTreeMap::new(); + let mut components = BTreeMap::new(); let mut warnings = Vec::new(); for declared in &config.profiles { let Some(runtime) = declared.runtime.as_ref() else { @@ -307,12 +415,29 @@ fn desired_bindings( .capabilities .contains(&ProfileCapability::Observe) => { + let component = match component_cache.load(catalog_root, &runtime.component) { + Ok(component) => component, + Err(error) => { + warnings.push(format!( + "Resource Profile '{}': provider component unavailable: {error:#}", + declared.scheme + )); + continue; + } + }; generations.insert( declared.scheme.clone(), - profile_generation(catalog_generation, declared, &descriptor, profiles), + profile_generation( + catalog_generation, + declared, + &descriptor, + profiles, + &component.identity, + ), ); descriptors.insert(declared.scheme.clone(), descriptor); runtimes.insert(declared.scheme.clone(), runtime.clone()); + components.insert(declared.scheme.clone(), component); } Ok(_) => warnings.push(format!( "Resource Profile '{}': runtime has no observable descriptor", @@ -336,9 +461,10 @@ fn desired_bindings( let Some((scheme, _)) = resource.uri().split_once(':') else { continue; }; - let (Some(descriptor), Some(runtime), Some(generation)) = ( + let (Some(descriptor), Some(runtime), Some(component), Some(generation)) = ( descriptors.get(scheme), runtimes.get(scheme), + components.get(scheme), generations.get(scheme), ) else { continue; @@ -402,8 +528,8 @@ fn desired_bindings( scheme: scheme.to_owned(), generation: *generation, topology: descriptor.runtime.topology, - argv: runtime.argv.clone(), - demand: runtime.demand, + runtime: runtime.clone(), + component: Arc::clone(component), uri: resource.uri().to_owned(), selector, descriptor: descriptor.clone(), @@ -418,33 +544,39 @@ fn desired_bindings( struct Worker { catalog_root: PathBuf, this_host: String, - tx: SyncSender, request_dir: PathBuf, receipt_dir: PathBuf, initialized: bool, catalog_generation: Option, desired: BTreeMap, runtimes: BTreeMap, + #[cfg(feature = "wasip2-provider-runtime")] + completion_tx: SyncSender, + #[cfg(feature = "wasip2-provider-runtime")] + jobs: BTreeMap, } impl Worker { fn new( catalog_root: PathBuf, this_host: String, - tx: SyncSender, request_dir: PathBuf, receipt_dir: PathBuf, + completion_tx: SyncSender, ) -> Self { Self { catalog_root, this_host, - tx, request_dir, receipt_dir, initialized: false, catalog_generation: None, desired: BTreeMap::new(), runtimes: BTreeMap::new(), + #[cfg(feature = "wasip2-provider-runtime")] + completion_tx, + #[cfg(feature = "wasip2-provider-runtime")] + jobs: BTreeMap::new(), } } @@ -479,30 +611,15 @@ impl Worker { .collect(); let _ = reply.send(health); } - Msg::RuntimeOutput { key, owner, output } => { - self.runtime_output(&key, &owner, output); - scan_requests_now = true; - } - Msg::RuntimeEof { key, owner } => { - self.runtime_failed(&key, &owner, "runtime protocol reached EOF"); - scan_requests_now = true; - } - Msg::WriterDrained { key, owner } => { - if !owner_matches( - self.runtimes.get(&key).map(|runtime| &runtime.owner), - &owner, - ) { - continue; - } - } - Msg::WriterFailed { key, owner, error } => { - self.runtime_failed(&key, &owner, &error); - scan_requests_now = true; - } Msg::ObserveRequests => { scan_requests_now = true; } + #[cfg(feature = "wasip2-provider-runtime")] + Msg::ObservationCompleted(completion) => { + self.complete_observation(completion); + } Msg::Shutdown { reply } => { + self.shutdown_jobs(&rx); self.stop_all(); let _ = reply.send(()); break; @@ -518,6 +635,8 @@ impl Worker { } fn reconcile(&mut self, desired: BTreeMap) -> Vec { + #[cfg(feature = "wasip2-provider-runtime")] + self.cancel_replaced_jobs(&desired); let mut warnings = Vec::new(); let desired_runtime_keys = desired .values() @@ -552,7 +671,6 @@ impl Worker { match RuntimeProcess::spawn( key.clone(), &bindings[0], - self.tx.clone(), &self.catalog_root, &self.this_host, ) { @@ -588,7 +706,35 @@ impl Worker { warnings } + #[cfg(feature = "wasip2-provider-runtime")] + fn cancel_replaced_jobs(&self, desired: &BTreeMap) { + for job in self.jobs.values() { + let remains_current = desired.get(&job.stable_key).is_some_and(|next| { + next.runtime_key() == job.runtime_key + && self + .runtimes + .get(&job.runtime_key) + .and_then(|runtime| runtime.bindings.get(&job.stable_key)) + .is_some_and(|active| active.desired == *next) + }); + if !remains_current { + job.cancellation.cancel(); + } + } + } + fn deactivate_recipient(&mut self, recipient: &str) { + #[cfg(feature = "wasip2-provider-runtime")] + for job in self.jobs.values() { + let belongs_to_recipient = self + .runtimes + .get(&job.runtime_key) + .and_then(|runtime| runtime.bindings.get(&job.stable_key)) + .is_some_and(|active| active.desired.recipient == recipient); + if belongs_to_recipient { + job.cancellation.cancel(); + } + } for runtime in self.runtimes.values_mut() { runtime.deactivate_recipient(recipient); } @@ -604,6 +750,27 @@ impl Worker { } } fn retry_observe_dispatches(&mut self) { + #[cfg(feature = "wasip2-provider-runtime")] + { + let keys = self.runtimes.keys().cloned().collect::>(); + for key in keys { + let (launches, errors, scheme) = { + let runtime = self + .runtimes + .get_mut(&key) + .expect("runtime selected from the same map"); + let (launches, errors) = runtime.prepare_observe_dispatches(&key); + (launches, errors, runtime.scheme.clone()) + }; + for error in errors { + eprintln!("st2: Resource Profile '{scheme}': {error}"); + } + for launch in launches { + self.spawn_observation(launch); + } + } + } + #[cfg(not(feature = "wasip2-provider-runtime"))] for runtime in self.runtimes.values_mut() { for error in runtime.retry_observe_dispatches() { eprintln!("st2: Resource Profile '{}': {error}", runtime.scheme); @@ -611,6 +778,213 @@ impl Worker { } } + #[cfg(feature = "wasip2-provider-runtime")] + fn spawn_observation(&mut self, launch: ObservationLaunch) { + let ObservationLaunch { + job_id, + runtime_key, + stable_key, + authority, + watermark, + fence, + request, + provider, + cancellation, + } = launch; + let completion_tx = self.completion_tx.clone(); + let thread_runtime_key = runtime_key.clone(); + let thread_stable_key = stable_key.clone(); + let thread_authority = authority.clone(); + let thread_cancellation = cancellation.clone(); + let thread_fence = fence; + let spawn = thread::Builder::new() + .name(format!("st2-resource-observe-{job_id}")) + .spawn(move || { + let result = + catch_observation(|| provider.observe(&request, &thread_cancellation)); + let _ = completion_tx.send(Msg::ObservationCompleted(ObservationCompletion { + job_id, + runtime_key: thread_runtime_key, + stable_key: thread_stable_key, + authority: thread_authority, + watermark, + fence: thread_fence, + result, + })); + }); + match spawn { + Ok(join) => { + self.jobs.insert( + job_id, + ObservationJob { + runtime_key, + stable_key, + cancellation, + join, + }, + ); + } + Err(error) => { + cancellation.cancel(); + if let Some(runtime) = self.runtimes.get_mut(&runtime_key) + && let Some(active) = runtime.bindings.get_mut(&stable_key) + { + active.health.state = RuntimeHealthState::Degraded; + active.health.detail = + Some(format!("observation worker could not start: {error}")); + let _ = settle_active_demand( + &runtime.request_dir, + &runtime.receipt_dir, + &runtime.owner, + active, + watermark, + ObserveReceiptStatus::SettledFailed, + None, + Some("provider observation worker could not start".to_owned()), + ); + runtime.refresh_process_health(); + } + } + } + } + + #[cfg(feature = "wasip2-provider-runtime")] + fn complete_observation(&mut self, completion: ObservationCompletion) { + let Some(job) = self.jobs.remove(&completion.job_id) else { + return; + }; + let _ = job.join.join(); + if job.runtime_key != completion.runtime_key || job.stable_key != completion.stable_key { + return; + } + let Some(runtime) = self.runtimes.get_mut(&completion.runtime_key) else { + return; + }; + let Some(active) = runtime.bindings.get(&completion.stable_key) else { + return; + }; + let fence_is_current = active.binding_id == completion.authority.binding_id + && active.registration == completion.authority.registration + && active.desired.generation == completion.fence.generation() + && active.revision == completion.fence.revision() + && active.catch_up.state().current_snapshot_digest() + == completion.fence.prior_digest() + && active + .demand + .in_flight + .as_ref() + .is_some_and(|batch| batch.watermark == completion.watermark); + if !fence_is_current { + return; + } + match completion.result { + Ok(result) => { + let failure_detail = match &result { + st2_resource_protocol::ObservationResult::Failed { diagnostic } => { + Some(diagnostic.clone().unwrap_or_else(|| { + "provider returned a failed observation".to_owned() + })) + } + _ => None, + }; + let message = RuntimeMessage::ObservationResult { + owner: completion.authority.owner, + binding_id: completion.authority.binding_id, + registration: completion.authority.registration, + demand_watermark: completion.watermark, + result, + }; + let catalog_root = active.desired.catalog_root.clone(); + let this_host = active.desired.this_host.clone(); + if let Err(error) = runtime.accept(message, &catalog_root, &this_host) { + if let Some(active) = runtime.bindings.get_mut(&completion.stable_key) { + active.health.state = RuntimeHealthState::Degraded; + active.health.detail = Some(format!("provider proposal rejected: {error:#}")); + let _ = settle_active_demand( + &runtime.request_dir, + &runtime.receipt_dir, + &runtime.owner, + active, + completion.watermark, + ObserveReceiptStatus::SettledFailed, + None, + Some("provider proposal was rejected".to_owned()), + ); + } + } else if let Some(active) = runtime.bindings.get_mut(&completion.stable_key) { + if let Some(detail) = failure_detail { + active.health.state = RuntimeHealthState::Degraded; + active.health.detail = Some(detail); + } else { + active.health.state = RuntimeHealthState::Ready; + active.health.detail = None; + } + } + } + Err(error) => { + tracing::warn!( + scheme = %runtime.scheme, + error = %error, + "resource provider observation failed" + ); + if let Some(active) = runtime.bindings.get_mut(&completion.stable_key) { + active.health.state = RuntimeHealthState::Degraded; + active.health.detail = Some(error); + let _ = settle_active_demand( + &runtime.request_dir, + &runtime.receipt_dir, + &runtime.owner, + active, + completion.watermark, + ObserveReceiptStatus::SettledFailed, + None, + Some("provider observation failed".to_owned()), + ); + } + } + } + runtime.refresh_process_health(); + } + + #[cfg(feature = "wasip2-provider-runtime")] + fn shutdown_jobs(&mut self, rx: &Receiver) { + for job in self.jobs.values() { + job.cancellation.cancel(); + } + for runtime in self.runtimes.values_mut() { + runtime.finalize_all_demand( + ObserveReceiptStatus::ProviderUnavailable, + Some("Resource Profile supervisor shut down".to_owned()), + ); + } + while !self.jobs.is_empty() { + match rx.recv() { + Ok(Msg::ObservationCompleted(completion)) => { + self.complete_observation(completion); + } + Ok(Msg::Refresh { reply, .. }) => { + let _ = reply.send(vec![ + "Resource Profile supervisor is shutting down".to_owned(), + ]); + } + Ok(Msg::Deactivate { reply, .. }) | Ok(Msg::Shutdown { reply }) => { + let _ = reply.send(()); + } + Ok(Msg::Health { reply }) => { + let _ = reply.send(Vec::new()); + } + Ok(Msg::ObserveRequests) => {} + Err(_) => break, + } + } + for (_, job) in std::mem::take(&mut self.jobs) { + let _ = job.join.join(); + } + } + + #[cfg(not(feature = "wasip2-provider-runtime"))] + fn shutdown_jobs(&mut self, _rx: &Receiver) {} + fn consume_observe_requests(&mut self) { if !self.initialized { return; @@ -671,7 +1045,7 @@ impl Worker { }); let Some(runtime_key) = runtime_key else { let (status, detail) = match self.desired.get(&stable_key) { - Some(desired) if desired.demand => ( + Some(desired) if desired.runtime.demand => ( ObserveReceiptStatus::ProviderUnavailable, "the declared provider runtime is not available", ), @@ -694,7 +1068,7 @@ impl Worker { else { continue; }; - if !active.desired.demand { + if !active.desired.runtime.demand { self.finish_request_without_dispatch( &record, ObserveReceiptStatus::AbsentBinding, @@ -770,43 +1144,6 @@ impl Worker { } } - fn runtime_output( - &mut self, - key: &RuntimeKey, - owner: &RuntimeOwner, - output: Result, - ) { - let Some(runtime) = self.runtimes.get_mut(key) else { - return; - }; - if !owner_matches(Some(&runtime.owner), owner) { - return; - } - let failure = match output { - Ok(message) => runtime - .accept(message, &self.catalog_root, &self.this_host) - .err() - .map(|error| format!("runtime output rejected: {error:#}")), - Err(error) => Some(format!("runtime protocol error: {error}")), - }; - if let Some(detail) = failure { - self.runtime_failed(key, owner, &detail); - } - } - - fn runtime_failed(&mut self, key: &RuntimeKey, owner: &RuntimeOwner, detail: &str) { - if !owner_matches(self.runtimes.get(key).map(|runtime| &runtime.owner), owner) { - return; - } - if let Some(mut runtime) = self.runtimes.remove(key) { - eprintln!("st2: Resource Profile '{}': {detail}", runtime.scheme); - runtime.finalize_all_demand( - ObserveReceiptStatus::ProviderUnavailable, - Some(detail.to_owned()), - ); - runtime.stop(); - } - } fn stop_all(&mut self) { for (_, mut runtime) in std::mem::take(&mut self.runtimes) { @@ -819,16 +1156,93 @@ struct RuntimeProcess { scheme: String, owner: RuntimeOwner, lifecycle: RuntimeLifecycle, - child: Child, - writer: Option>>, - writer_thread: Option>, - reader_thread: Option>, + #[cfg(feature = "wasip2-provider-runtime")] + provider: Arc, bindings: BTreeMap, request_dir: PathBuf, receipt_dir: PathBuf, process_health: ResourceProfileHealth, } +#[cfg(feature = "wasip2-provider-runtime")] +enum ProviderRuntime { + GitHubIssue { + executor: Wasip2Executor, + component: LoadedComponent, + }, + PtyStats { + executor: Wasip2Executor, + component: LoadedComponent, + }, +} +#[cfg(feature = "wasip2-provider-runtime")] +fn validate_provider_descriptor( + provider: &Wasip2ProviderDescriptor, + profile: &ProfileDescriptor, +) -> anyhow::Result<()> { + anyhow::ensure!( + provider.capabilities == [Wasip2SchedulingCapability::Demand], + "provider descriptor must declare exactly the demand scheduling capability" + ); + anyhow::ensure!( + provider.snapshot_media_type == profile.snapshot.media_type, + "provider snapshot media type '{}' does not match profile contract '{}'", + provider.snapshot_media_type, + profile.snapshot.media_type + ); + anyhow::ensure!( + provider.snapshot_schema_id == profile.snapshot.schema_id, + "provider snapshot schema '{}' does not match profile contract '{}'", + provider.snapshot_schema_id, + profile.snapshot.schema_id + ); + let profile_topics = profile + .topics + .iter() + .map(|topic| topic.name.as_str()) + .collect::>(); + anyhow::ensure!( + provider + .topics + .iter() + .all(|topic| profile_topics.contains(topic.as_str())), + "provider descriptor declares a topic outside the profile contract" + ); + Ok(()) +} + + + +#[cfg(feature = "wasip2-provider-runtime")] +impl ProviderRuntime { + fn cancellation(&self) -> ObservationCancellation { + let interruption = match self { + Self::GitHubIssue { executor, .. } => executor.interruption_handle(), + Self::PtyStats { executor, .. } => executor.interruption_handle(), + }; + ObservationCancellation { interruption } + } + + fn observe( + &self, + request: &Wasip2ObservationRequest, + cancellation: &ObservationCancellation, + ) -> Result { + match self { + Self::GitHubIssue { + executor, + component, + .. + } => executor.observe(component, request, Some(&cancellation.interruption)), + Self::PtyStats { + executor, + component, + .. + } => executor.observe(component, request, Some(&cancellation.interruption)), + } + } +} + struct ActiveBinding { desired: DesiredBinding, binding_id: BindingId, @@ -836,6 +1250,7 @@ struct ActiveBinding { catch_up: CatchUp, health: ResourceProfileHealth, demand: DemandState, + revision: u64, } #[derive(Debug, Default)] @@ -913,81 +1328,116 @@ impl DemandState { impl RuntimeProcess { fn spawn( - key: RuntimeKey, + _key: RuntimeKey, sample: &DesiredBinding, - supervisor_tx: SyncSender, catalog_root: &Path, this_host: &str, ) -> anyhow::Result { - let executable = sample - .argv - .first() - .context("runtime argv is unexpectedly empty")?; - let mut command = Command::new(executable); - command - .args(&sample.argv[1..]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - let mut child = command - .spawn() - .with_context(|| format!("spawn {executable:?}"))?; - let stdin = child.stdin.take().context("capture runtime stdin")?; - let stdout = child.stdout.take().context("capture runtime stdout")?; - let sequence = ID_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let incarnation = RuntimeIncarnation::new(format!("{}-{sequence}", sample.generation))?; - let claim = OwnerClaim::new(hash_text(&format!( - "{}\0{}\0{}\0{sequence}", - catalog_root.display(), - this_host, - sample.scheme - )))?; - let owner = RuntimeOwner::new(incarnation, claim); - let mut lifecycle = RuntimeLifecycle::new(); - lifecycle.claim(owner.clone()); - - let (writer_tx, writer_rx) = mpsc::sync_channel::>(WRITER_CAPACITY); - let writer_key = key.clone(); - let writer_owner = owner.clone(); - let writer_supervisor = supervisor_tx.clone(); - let writer_thread = thread::Builder::new() - .name("st2-resource-profile-stdin".to_owned()) - .spawn(move || { - runtime_writer( - stdin, - writer_rx, - writer_key, - writer_owner, - writer_supervisor, - ) - })?; - let reader_key = key.clone(); - let reader_owner = owner.clone(); - let reader_thread = thread::Builder::new() - .name("st2-resource-profile-stdout".to_owned()) - .spawn(move || runtime_reader(stdout, reader_key, reader_owner, supervisor_tx))?; - let scope = crate::park::SupervisorScope::current(catalog_root, this_host)?; - let request_dir = scope.observe_request_dir(); - let receipt_dir = scope.observe_receipt_dir(); - - Ok(Self { - scheme: sample.scheme.clone(), - owner, - lifecycle, - child, - writer: Some(writer_tx), - writer_thread: Some(writer_thread), - reader_thread: Some(reader_thread), - bindings: BTreeMap::new(), - request_dir, - receipt_dir, - process_health: ResourceProfileHealth { + #[cfg(not(feature = "wasip2-provider-runtime"))] + { + let _ = (sample, catalog_root, this_host); + anyhow::bail!( + "component provider runtime unavailable because st2 was built without \ + `wasip2-provider-runtime`" + ); + } + #[cfg(feature = "wasip2-provider-runtime")] + { + let provider = match &sample.runtime.capability { + crate::catalog::DeclaredProviderCapability::GitHubIssue { + owner, + repo, + number, + connect_timeout_ms, + total_timeout_ms, + } => { + let module = GitHubIssueModule::new(GitHubIssueConfig { + owner: owner.clone(), + repo: repo.clone(), + number: *number, + connect_timeout: Duration::from_millis(*connect_timeout_ms), + total_timeout: Duration::from_millis(*total_timeout_ms), + }) + .map_err(anyhow::Error::msg)?; + let executor = Wasip2Executor::new( + Wasip2RuntimeConfig::default(), + None, + module, + )?; + let component = executor.load(&sample.component.bytes)?; + let descriptor = executor.describe(&component, None)?; + validate_provider_descriptor(&descriptor, &sample.descriptor)?; + ProviderRuntime::GitHubIssue { + executor, + component, + } + } + crate::catalog::DeclaredProviderCapability::PtyStats { + executable, + cwd, + scope, + deadline_ms, + } => { + let executable = + crate::expand::expand_catalog(executable, catalog_root); + let cwd = crate::expand::expand_catalog(cwd, catalog_root); + let scope = match scope { + crate::catalog::DeclaredPtyStatsScope::All => PtyStatsScope::All, + crate::catalog::DeclaredPtyStatsScope::Session(session) => { + PtyStatsScope::Session(session.clone()) + } + }; + let config = PtyStatsConfig::resolve( + executable, + PathBuf::from(cwd), + scope, + Duration::from_millis(*deadline_ms), + ) + .map_err(anyhow::Error::msg)?; + let module = PtyStatsModule::new(config); + let executor = Wasip2Executor::new( + Wasip2RuntimeConfig::default(), + None, + module, + )?; + let component = executor.load(&sample.component.bytes)?; + let descriptor = executor.describe(&component, None)?; + validate_provider_descriptor(&descriptor, &sample.descriptor)?; + ProviderRuntime::PtyStats { + executor, + component, + } + } + }; + let sequence = ID_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let incarnation = + RuntimeIncarnation::new(format!("{}-{sequence}", sample.generation))?; + let claim = OwnerClaim::new(hash_text(&format!( + "{}\0{}\0{}\0{sequence}", + catalog_root.display(), + this_host, + sample.scheme + )))?; + let owner = RuntimeOwner::new(incarnation, claim); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(owner.clone()); + let scope = crate::park::SupervisorScope::current(catalog_root, this_host)?; + Ok(Self { scheme: sample.scheme.clone(), - binding: None, - state: RuntimeHealthState::Starting, - detail: None, - }, - }) + owner, + lifecycle, + provider: Arc::new(provider), + bindings: BTreeMap::new(), + request_dir: scope.observe_request_dir(), + receipt_dir: scope.observe_receipt_dir(), + process_health: ResourceProfileHealth { + scheme: sample.scheme.clone(), + binding: None, + state: RuntimeHealthState::Ready, + detail: None, + }, + }) + } } fn reconcile_bindings(&mut self, desired: Vec) -> anyhow::Result<()> { @@ -1056,22 +1506,11 @@ impl RuntimeProcess { .with_context(|| format!("create {}", state_directory.display()))?; let mut catch_up = CatchUp::open_for_snapshot(&state_directory, &desired.target)?; let pending = catch_up.set_deliverable(true)?; - let previous_digest = catch_up.state().current_snapshot_digest(); - let message = HostMessage::Register { - owner: self.owner.clone(), - binding_id: binding_id.clone(), - registration: registration.clone(), - uri: desired.uri.clone(), - selector: desired.selector.clone(), - carrier_path: desired.target.path(), - previous_digest, - }; - self.send(message)?; let mut active = ActiveBinding { health: ResourceProfileHealth { scheme: desired.scheme.clone(), binding: Some(desired.binding_name.clone()), - state: RuntimeHealthState::Starting, + state: RuntimeHealthState::Ready, detail: None, }, desired, @@ -1079,12 +1518,14 @@ impl RuntimeProcess { registration, catch_up, demand: DemandState::default(), + revision: 0, }; if pending.is_some() { let _ = emit_pending_for(&mut active); } self.bindings .insert(active.desired.stable_key.clone(), active); + self.refresh_process_health(); Ok(()) } @@ -1101,15 +1542,10 @@ impl RuntimeProcess { Some("binding registration was removed or replaced".to_owned()), ); let _ = active.catch_up.set_deliverable(false); - let message = HostMessage::Unregister { - owner: self.owner.clone(), - binding_id: active.binding_id.clone(), - registration: active.registration.clone(), - }; - let _ = self.send(message); let _ = self .lifecycle .unregister(&self.owner, &active.binding_id, &active.registration); + self.refresh_process_health(); } fn deactivate_recipient(&mut self, recipient: &str) { @@ -1152,6 +1588,97 @@ impl RuntimeProcess { .any(|active| active.demand.contains_request(request_id)) } + #[cfg(feature = "wasip2-provider-runtime")] + fn prepare_observe_dispatches( + &mut self, + runtime_key: &RuntimeKey, + ) -> (Vec, Vec) { + let mut launches = Vec::new(); + let mut errors = Vec::new(); + for active in self.bindings.values_mut() { + let authority = ObservationAuthority { + owner: self.owner.clone(), + binding_id: active.binding_id.clone(), + registration: active.registration.clone(), + }; + errors.extend(retry_settled_demand( + &self.request_dir, + &self.receipt_dir, + &authority, + active, + )); + } + let keys = self + .bindings + .iter() + .filter(|(_, active)| { + active.demand.in_flight.is_none() && active.demand.trailing.is_some() + }) + .map(|(key, _)| key.clone()) + .collect::>(); + for stable_key in keys { + let active = self + .bindings + .get_mut(&stable_key) + .expect("binding selected from the same map"); + let authority = ObservationAuthority { + owner: self.owner.clone(), + binding_id: active.binding_id.clone(), + registration: active.registration.clone(), + }; + let mut batch = active + .demand + .trailing + .take() + .expect("binding was selected with trailing demand"); + batch.dispatched_at = Some(Instant::now()); + errors.extend(write_batch_status( + &self.request_dir, + &self.receipt_dir, + &authority, + &mut batch, + ObserveReceiptStatus::Accepted, + None, + None, + )); + for pending in &batch.requests { + crate::metrics::record_resource_observe_dispatch( + SystemTime::now() + .duration_since(pending.queued_at) + .unwrap_or(Duration::ZERO), + ); + } + let watermark = batch.watermark; + active.demand.in_flight = Some(batch); + let fence = ProposalFence::new( + active.desired.generation, + active.revision, + active.catch_up.state().current_snapshot_digest(), + ); + let job_id = ID_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let cancellation = self.provider.cancellation(); + launches.push(ObservationLaunch { + job_id, + runtime_key: runtime_key.clone(), + stable_key, + authority, + watermark, + fence, + request: Wasip2ObservationRequest { + invocation_id: job_id, + uri: active.desired.uri.clone(), + selector: active.desired.selector.clone(), + prior_digest: fence.prior_digest(), + demand_watermark: Some(watermark), + }, + provider: Arc::clone(&self.provider), + cancellation, + }); + } + (launches, errors) + } + + #[cfg(not(feature = "wasip2-provider-runtime"))] fn retry_observe_dispatches(&mut self) -> Vec { let mut errors = Vec::new(); for active in self.bindings.values_mut() { @@ -1176,76 +1703,43 @@ impl RuntimeProcess { .map(|(key, _)| key.clone()) .collect::>(); for key in keys { - let Some(active) = self.bindings.get(&key) else { - continue; - }; + let active = self + .bindings + .get_mut(&key) + .expect("binding selected from the same map"); let authority = ObservationAuthority { owner: self.owner.clone(), binding_id: active.binding_id.clone(), registration: active.registration.clone(), }; - let demand_watermark = active + let mut batch = active .demand .trailing - .as_ref() - .expect("binding was selected with trailing demand") - .watermark; - let message = HostMessage::Observe { - owner: authority.owner.clone(), - binding_id: authority.binding_id.clone(), - registration: authority.registration.clone(), - demand_watermark, - }; - match self.send(message) { - Ok(()) => { - let active = self - .bindings - .get_mut(&key) - .expect("binding selected from the same map"); - let mut batch = active - .demand - .trailing - .take() - .expect("binding was selected with trailing demand"); - batch.dispatched_at = Some(Instant::now()); - errors.extend(write_batch_status( - &self.request_dir, - &self.receipt_dir, - &authority, - &mut batch, - ObserveReceiptStatus::Accepted, - None, - None, - )); - for pending in &batch.requests { - crate::metrics::record_resource_observe_dispatch( - SystemTime::now() - .duration_since(pending.queued_at) - .unwrap_or(Duration::ZERO), - ); - } - active.demand.in_flight = Some(batch); - } - Err(error) => { - let active = self - .bindings - .get_mut(&key) - .expect("binding selected from the same map"); - let batch = active - .demand - .trailing - .as_mut() - .expect("binding was selected with trailing demand"); - errors.extend(write_batch_status( - &self.request_dir, - &self.receipt_dir, - &authority, - batch, - ObserveReceiptStatus::Backpressured, - None, - Some(error.to_string()), - )); - } + .take() + .expect("binding was selected with trailing demand"); + batch.dispatched_at = Some(Instant::now()); + errors.extend(write_batch_status( + &self.request_dir, + &self.receipt_dir, + &authority, + &mut batch, + ObserveReceiptStatus::Accepted, + None, + None, + )); + let watermark = batch.watermark; + active.demand.in_flight = Some(batch); + if let Err(error) = settle_active_demand( + &self.request_dir, + &self.receipt_dir, + &self.owner, + active, + watermark, + ObserveReceiptStatus::ProviderUnavailable, + None, + Some("component provider runtime is unavailable".to_owned()), + ) { + errors.push(error.to_string()); } } errors @@ -1351,16 +1845,25 @@ impl RuntimeProcess { } } } + self.refresh_process_health(); Ok(()) } - fn send(&self, message: HostMessage) -> anyhow::Result<()> { - let line = encode_host_line(&message)?; - let writer = self.writer.as_ref().context("runtime stdin is closed")?; - match writer.try_send(line) { - Ok(()) => Ok(()), - Err(TrySendError::Full(_)) => anyhow::bail!("runtime stdin queue is full"), - Err(TrySendError::Disconnected(_)) => anyhow::bail!("runtime stdin is disconnected"), + + fn refresh_process_health(&mut self) { + let degraded = self + .bindings + .values() + .filter(|active| active.health.state == RuntimeHealthState::Degraded) + .map(|active| active.desired.binding_name.as_str()) + .collect::>(); + if degraded.is_empty() { + self.process_health.state = RuntimeHealthState::Ready; + self.process_health.detail = None; + } else { + self.process_health.state = RuntimeHealthState::Degraded; + self.process_health.detail = + Some(format!("degraded bindings: {}", degraded.join(", "))); } } @@ -1370,17 +1873,7 @@ impl RuntimeProcess { .collect() } - fn stop(&mut self) { - self.writer.take(); - let _ = self.child.kill(); - let _ = self.child.wait(); - if let Some(thread) = self.writer_thread.take() { - let _ = thread.join(); - } - if let Some(thread) = self.reader_thread.take() { - let _ = thread.join(); - } - } + fn stop(&mut self) {} } fn process_publication( @@ -1394,7 +1887,12 @@ fn process_publication( .values_mut() .find(|active| active.binding_id == binding_id) .context("accepted publication has no active binding")?; + let next_revision = active + .revision + .checked_add(1) + .context("provider publication revision exhausted")?; let (outcome, pending) = active.catch_up.publish(publication)?; + active.revision = next_revision; let delivery_error = if pending.is_some() { emit_pending(catalog_root, this_host, active).err() } else { @@ -1572,104 +2070,6 @@ fn finalize_active_demand( } } -fn runtime_writer( - mut stdin: std::process::ChildStdin, - rx: Receiver>, - key: RuntimeKey, - owner: RuntimeOwner, - supervisor: SyncSender, -) { - while let Ok(line) = rx.recv() { - if let Err(error) = stdin.write_all(&line).and_then(|_| stdin.flush()) { - let _ = supervisor.send(Msg::WriterFailed { - key, - owner, - error: format!("runtime stdin failed: {error}"), - }); - return; - } - match supervisor.try_send(Msg::WriterDrained { - key: key.clone(), - owner: owner.clone(), - }) { - Ok(()) | Err(TrySendError::Full(_)) => {} - Err(TrySendError::Disconnected(_)) => return, - } - } -} - -fn runtime_reader( - stdout: std::process::ChildStdout, - key: RuntimeKey, - owner: RuntimeOwner, - supervisor: SyncSender, -) { - let mut reader = BufReader::new(stdout); - loop { - match read_bounded_line(&mut reader) { - Ok(Some(line)) => { - let output = decode_runtime_line(&line).map_err(|error| error.to_string()); - if supervisor - .send(Msg::RuntimeOutput { - key: key.clone(), - owner: owner.clone(), - output, - }) - .is_err() - { - return; - } - } - Ok(None) => { - let _ = supervisor.send(Msg::RuntimeEof { key, owner }); - return; - } - Err(error) => { - let _ = supervisor.send(Msg::RuntimeOutput { - key, - owner, - output: Err(error.to_string()), - }); - return; - } - } - } -} - -fn read_bounded_line(reader: &mut impl BufRead) -> io::Result>> { - let mut line = Vec::new(); - loop { - let available = reader.fill_buf()?; - if available.is_empty() { - return if line.is_empty() { - Ok(None) - } else { - Ok(Some(line)) - }; - } - if let Some(newline) = available.iter().position(|byte| *byte == b'\n') { - let consumed = newline + 1; - if line.len() + consumed > MAX_PROTOCOL_LINE_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "runtime protocol line exceeds 2 MiB", - )); - } - line.extend_from_slice(&available[..consumed]); - reader.consume(consumed); - return Ok(Some(line)); - } - if line.len() + available.len() >= MAX_PROTOCOL_LINE_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "runtime protocol line exceeds 2 MiB", - )); - } - let consumed = available.len(); - line.extend_from_slice(available); - reader.consume(consumed); - } -} fn selector_topics(selector: &Value) -> anyhow::Result { let topics = selector @@ -1697,6 +2097,7 @@ fn profile_generation( declared: &crate::catalog::DeclaredProfile, descriptor: &ProfileDescriptor, profiles: &ResourceProfileRegistry, + component_identity: &str, ) -> u64 { let module_identity = profiles .get(&declared.scheme) @@ -1712,7 +2113,7 @@ fn profile_generation( }) .unwrap_or_default(); let input = format!( - "{catalog_generation}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{module_identity}", + "{catalog_generation}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{module_identity}\0{component_identity}", declared.scheme, declared.wasm, declared.class, @@ -1899,9 +2300,6 @@ fn hash_path(path: &Path) -> String { hash_text(&path.to_string_lossy()) } -fn owner_matches(current: Option<&RuntimeOwner>, message: &RuntimeOwner) -> bool { - current == Some(message) -} fn hash_text(value: &str) -> String { format!("{:x}", Sha256::digest(value.as_bytes())) @@ -1912,21 +2310,6 @@ mod tests { use super::*; use std::fs; - #[test] - fn bounded_reader_accepts_one_maximal_line_and_rejects_overflow() { - let mut maximal = vec![b'x'; MAX_PROTOCOL_LINE_BYTES - 1]; - maximal.push(b'\n'); - assert_eq!( - read_bounded_line(&mut maximal.as_slice()) - .unwrap() - .unwrap() - .len(), - MAX_PROTOCOL_LINE_BYTES - ); - let mut overflow = vec![b'x'; MAX_PROTOCOL_LINE_BYTES]; - overflow.push(b'\n'); - assert!(read_bounded_line(&mut overflow.as_slice()).is_err()); - } #[test] fn runtime_keys_enforce_shared_and_per_binding_topology() { @@ -2076,69 +2459,6 @@ mod tests { ); } - #[test] - fn full_writer_queue_is_reported_without_consuming_the_frame() { - let owner = RuntimeOwner::new( - RuntimeIncarnation::new("incarnation").unwrap(), - OwnerClaim::new("claim").unwrap(), - ); - let mut lifecycle = RuntimeLifecycle::new(); - lifecycle.claim(owner.clone()); - let child = Command::new(std::env::current_exe().unwrap()) - .arg("--help") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let (writer, queued) = mpsc::sync_channel(1); - writer.try_send(vec![1]).unwrap(); - let receipt_dir = tempfile::tempdir().unwrap(); - let mut runtime = RuntimeProcess { - scheme: "dev.x".into(), - owner: owner.clone(), - lifecycle, - child, - writer: Some(writer), - writer_thread: None, - reader_thread: None, - bindings: BTreeMap::new(), - request_dir: receipt_dir.path().join("requests"), - receipt_dir: receipt_dir.path().to_path_buf(), - process_health: ResourceProfileHealth { - scheme: "dev.x".into(), - binding: None, - state: RuntimeHealthState::Starting, - detail: None, - }, - }; - let error = runtime - .send(HostMessage::Observe { - owner, - binding_id: BindingId::new("binding").unwrap(), - registration: RegistrationToken::new("registration").unwrap(), - demand_watermark: 1, - }) - .unwrap_err(); - assert!(error.to_string().contains("queue is full")); - assert_eq!(queued.try_recv().unwrap(), vec![1]); - runtime.stop(); - } - - #[test] - fn stale_owner_envelopes_cannot_target_a_replacement_with_the_same_runtime_key() { - let old = RuntimeOwner::new( - RuntimeIncarnation::new("incarnation-1").unwrap(), - OwnerClaim::new("claim-1").unwrap(), - ); - let replacement = RuntimeOwner::new( - RuntimeIncarnation::new("incarnation-2").unwrap(), - OwnerClaim::new("claim-2").unwrap(), - ); - assert!(owner_matches(Some(&replacement), &replacement)); - assert!(!owner_matches(Some(&replacement), &old)); - assert!(!owner_matches(None, &old)); - } #[test] fn publication_subject_renders_ordered_facts_and_reserves_topics() { @@ -2183,62 +2503,12 @@ mod tests { ); } - #[test] - fn stale_protocol_failure_does_not_remove_the_replacement_process() { - let old = RuntimeOwner::new( - RuntimeIncarnation::new("incarnation-1").unwrap(), - OwnerClaim::new("claim-1").unwrap(), - ); - let replacement = RuntimeOwner::new( - RuntimeIncarnation::new("incarnation-2").unwrap(), - OwnerClaim::new("claim-2").unwrap(), - ); - let mut lifecycle = RuntimeLifecycle::new(); - lifecycle.claim(replacement.clone()); - let child = Command::new(std::env::current_exe().unwrap()) - .arg("--help") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let key = RuntimeKey::Shared { - scheme: "dev.x".into(), - generation: 1, - }; - let (tx, _rx) = mpsc::sync_channel(1); - let temp = tempfile::tempdir().unwrap(); - let mut worker = Worker::new( - temp.path().to_path_buf(), - "host".into(), - tx, - temp.path().join("requests"), - temp.path().join("receipts"), - ); - worker.runtimes.insert( - key.clone(), - RuntimeProcess { - scheme: "dev.x".into(), - owner: replacement, - lifecycle, - child, - writer: None, - writer_thread: None, - reader_thread: None, - bindings: BTreeMap::new(), - request_dir: temp.path().join("requests"), - receipt_dir: temp.path().join("receipts"), - process_health: ResourceProfileHealth { - scheme: "dev.x".into(), - binding: None, - state: RuntimeHealthState::Starting, - detail: None, - }, - }, - ); - worker.runtime_output(&key, &old, Err("stale malformed output".into())); - assert!(worker.runtimes.contains_key(&key)); - worker.stop_all(); + #[cfg(feature = "wasip2-provider-runtime")] + #[test] + fn observation_panic_becomes_a_typed_failed_completion() { + let result = catch_observation(|| panic!("synthetic provider panic")); + assert_eq!(result.unwrap_err(), "provider observation panicked"); } + } diff --git a/tests/resource_profile_supervisor_e2e.rs b/tests/resource_profile_supervisor_e2e.rs index 9cbca3cd..f54ab9eb 100755 --- a/tests/resource_profile_supervisor_e2e.rs +++ b/tests/resource_profile_supervisor_e2e.rs @@ -1,705 +1,360 @@ -#![cfg(all(unix, feature = "wasm-resolver"))] +#![cfg(all(unix, feature = "wasip2-provider-runtime"))] use std::ffi::CString; -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::os::unix::ffi::OsStrExt as _; use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Barrier}; use std::time::{Duration, Instant}; +use parking_lot::Mutex; -use st2::resource_observe::{ - MAX_PENDING_OBSERVE_REQUESTS, ObserveAdmissionBackpressure, ObserveReceiptStatus, - ObserveRequest, -}; -use st2::resource_profile::{ - BindingId, HostMessage, ObservationResult, Publication, RegistrationToken, ResourceFact, - RuntimeHealthState, RuntimeMessage, RuntimeOwner, SnapshotBytes, SnapshotDigest, - decode_host_line, encode_runtime_line, -}; +use st2::resource_observe::{ObserveReceipt, ObserveReceiptStatus, ObserveRequest, submit_request}; use st2::resource_profile_supervisor::ResourceProfileSupervisor; -const SCHEME: &str = "dev.example.observable"; -const SCHEMA_ID: &str = "dev.example.observable.snapshot.v1"; -static TEST_ENV_LOCK: Mutex<()> = Mutex::new(()); -const MEDIA_TYPE: &str = "application/json"; - -#[derive(Clone)] -struct Registration { - owner: RuntimeOwner, - binding_id: BindingId, - registration: RegistrationToken, -} - -#[derive(Clone)] -struct Observation { - registration: Registration, - demand_watermark: u64, -} - -struct RuntimeControl { - register_path: PathBuf, - observe_path: PathBuf, - output: File, -} - -impl RuntimeControl { - fn registration(&self) -> Registration { - wait_until("runtime register message", || { - let line = fs::read(&self.register_path).ok()?; - let message = decode_host_line(&line).ok()?; - let HostMessage::Register { - owner, - binding_id, - registration, - .. - } = message - else { - return None; - }; - Some(Registration { - owner, - binding_id, - registration, - }) - }) - } - - fn observations(&self) -> Vec { - fs::read_to_string(&self.observe_path) - .unwrap_or_default() - .lines() - .filter_map(|line| { - let message = decode_host_line(format!("{line}\n").as_bytes()).ok()?; - let HostMessage::Observe { - owner, - binding_id, - registration, - demand_watermark, - } = message - else { - return None; - }; - Some(Observation { - registration: Registration { - owner, - binding_id, - registration, - }, - demand_watermark, - }) - }) - .collect() - } - - fn wait_for_observation(&self, count: usize) -> Observation { - wait_until("runtime observe message", || { - self.observations().get(count.saturating_sub(1)).cloned() - }) - } - - fn unchanged(&self, observation: &Observation) { - self.result(observation, ObservationResult::Unchanged); - } - - fn failed(&self, observation: &Observation, diagnostic: &str) { - self.result( - observation, - ObservationResult::Failed { - diagnostic: Some(diagnostic.to_owned()), - }, - ); - } - - fn publish_observation( - &self, - observation: &Observation, - bytes: &[u8], - topics: &[&str], - fact: &str, - ) { - self.result( - observation, - ObservationResult::Published { - publication: publication(bytes, topics, fact), - }, - ); - } - - fn result(&self, observation: &Observation, result: ObservationResult) { - let registration = &observation.registration; - let message = RuntimeMessage::ObservationResult { - owner: registration.owner.clone(), - binding_id: registration.binding_id.clone(), - registration: registration.registration.clone(), - demand_watermark: observation.demand_watermark, - result, - }; - let mut output = &self.output; - output - .write_all(&encode_runtime_line(&message).unwrap()) - .unwrap(); - output.flush().unwrap(); - } - - fn publish( - &self, - registration: &Registration, - bytes: &[u8], - topics: &[&str], - health_marker: &str, - ) { - let publication = RuntimeMessage::Publish { - owner: registration.owner.clone(), - binding_id: registration.binding_id.clone(), - registration: registration.registration.clone(), - publication: publication(bytes, topics, health_marker), - }; - let health = RuntimeMessage::Health { - owner: registration.owner.clone(), - binding_id: Some(registration.binding_id.clone()), - registration: Some(registration.registration.clone()), - state: RuntimeHealthState::Ready, - detail: Some(health_marker.to_owned()), - }; - let mut output = &self.output; - output - .write_all(&encode_runtime_line(&publication).unwrap()) - .unwrap(); - output - .write_all(&encode_runtime_line(&health).unwrap()) - .unwrap(); - output.flush().unwrap(); - } -} - -fn publication(bytes: &[u8], topics: &[&str], fact: &str) -> Publication { - Publication { - schema_id: SCHEMA_ID.to_owned(), - media_type: MEDIA_TYPE.to_owned(), - bytes: SnapshotBytes::new(bytes.to_vec()).unwrap(), - topics: topics.iter().map(|topic| (*topic).to_owned()).collect(), - facts: Some(vec![ResourceFact::current("revision", fact).unwrap()]), - } -} - -struct CatalogFixture { - root: PathBuf, - host: String, - agent_dir: PathBuf, - runtime: RuntimeControl, -} - -impl CatalogFixture { - fn new(root: PathBuf, host: &str) -> Self { - fs::create_dir_all(&root).unwrap(); - let agent_dir = root.join("agents").join(host).join("worker"); - fs::create_dir_all(&agent_dir).unwrap(); - fs::create_dir_all(agent_dir.join("resources")).unwrap(); - fs::write( - agent_dir.join("agent.kdl"), - format!( - r##"agent "worker" {{ - host "{host}" - command "true" - resource "observed" uri="{SCHEME}://subject" reason="Observed state." selector=#"{{"topics":["selected"]}}"# -}} -"##, - ), - ) - .unwrap(); - - let resolver = root.join("observable-resolver.wasm"); - fs::write(&resolver, observable_resolver_wasm()).unwrap(); - let control_dir = root.join("runtime-control"); - fs::create_dir_all(&control_dir).unwrap(); - let register_path = control_dir.join("register.ndjson"); - let observe_path = control_dir.join("observe.ndjson"); - let fifo_path = control_dir.join("runtime-output.fifo"); - let fifo = CString::new(fifo_path.as_os_str().as_bytes()).unwrap(); - assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); - let output = OpenOptions::new() - .read(true) - .write(true) - .open(&fifo_path) - .unwrap(); - - let runtime = root.join("fake-observable-runtime"); - fs::write( - &runtime, - "#!/bin/sh\nset -eu\nexec 3<&0\n(\n while IFS= read -r frame; do\n case \"$frame\" in\n *'\"type\":\"register\"'*) printf '%s\\n' \"$frame\" > \"$1/register.ndjson\" ;;\n *'\"type\":\"observe\"'*) printf '%s\\n' \"$frame\" >> \"$1/observe.ndjson\" ;;\n esac\n done\n) <&3 &\nexec cat \"$1/runtime-output.fifo\"\n", - ) - .unwrap(); - fs::set_permissions(&runtime, fs::Permissions::from_mode(0o755)).unwrap(); - fs::write( - st2::catalog::config_path(&root), - format!( - r#"profile "{SCHEME}" {{ - wasm "observable-resolver.wasm" - class "immediate" - runtime {{ - argv "{}" "{}" - capability "demand" - }} -}} -"#, - runtime.display(), - control_dir.display() - ), - ) - .unwrap(); - - Self { - root, - host: host.to_owned(), - agent_dir, - runtime: RuntimeControl { - register_path, - observe_path, - output, - }, - } - } - - fn supervisor(&self) -> ResourceProfileSupervisor { - let supervisor = - ResourceProfileSupervisor::new(self.root.clone(), self.host.clone()).unwrap(); - self.refresh(&supervisor); - supervisor - } - - fn refresh(&self, supervisor: &ResourceProfileSupervisor) { - self.refresh_generation(supervisor, 1); - } - - fn refresh_generation(&self, supervisor: &ResourceProfileSupervisor, generation: u64) { - let (config, profiles) = st2::catalog::declared_profile_catalog(&self.root).unwrap(); - let discovery = st2::discover_strict(&self.root); - assert!( - discovery.errors.is_empty(), - "fixture catalog must be valid: {:?}", - discovery.errors - ); - let report = supervisor.refresh(&config, &profiles, Some(generation), &discovery.specs); - assert!( - report.warnings.is_empty(), - "Resource Profile refresh warnings: {:?}", - report.warnings - ); - } - - fn snapshot_path(&self) -> PathBuf { - self.agent_dir.join("resources/snapshot.json") - } - - fn owner_binding_path(&self) -> PathBuf { - let park_dir = st2::park::SupervisorScope::current(&self.root, &self.host) - .unwrap() - .park_dir(); - park_dir.parent().unwrap().join("stream-owner.json") - } - - fn observe_request_dir(&self) -> PathBuf { - st2::park::SupervisorScope::current(&self.root, &self.host) - .unwrap() - .park_dir() - .parent() - .unwrap() - .join("observe-requests") - } - - fn observe_receipt_dir(&self) -> PathBuf { - st2::park::SupervisorScope::current(&self.root, &self.host) - .unwrap() - .park_dir() - .parent() - .unwrap() - .join("observe-receipts") - } - - fn request(&self) -> ObserveRequest { - ObserveRequest::new( - format!("{}.worker", self.host), - "observed".to_owned(), - Some(1), - None, - ) - .unwrap() - } -} +static STATE_ENV: Mutex<()> = Mutex::new(()); +const WAIT: Duration = Duration::from_secs(30); #[test] -fn observable_publication_reaches_builtin_resync_with_filter_catch_up_and_scope_isolation() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); +fn supervisor_compatibility_contract_uses_the_production_pty_component() { + // The component executor replaces native protocol frames. Executor import admission and + // resource limits live in st2-resource-wasip2 tests; this test maps the applicable supervisor + // contract to real component changed/unchanged/failed proposals, health, and restart recovery. + let _guard = STATE_ENV.lock(); let temporary = tempfile::tempdir().unwrap(); - let state = temporary.path().join("state"); - unsafe { std::env::set_var("XDG_STATE_HOME", &state) }; - - let primary = CatalogFixture::new(temporary.path().join("catalog-primary"), "alpha"); - st2::event::publish_owner_binding_for_test(&primary.root, &primary.host).unwrap(); - let primary_supervisor = primary.supervisor(); - let primary_registration = primary.runtime.registration(); - - let first = br#"{"revision":1}"#; - primary - .runtime - .publish(&primary_registration, first, &["selected"], "primary-first"); - wait_for_health(&primary_supervisor, "primary-first"); - assert_eq!(fs::read(primary.snapshot_path()).unwrap(), first); - let first_inbox = resync_inbox(&primary.agent_dir); + unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; + let executable = temporary.path().join("fixture-pty"); + write_executable( + &executable, + "#!/bin/sh\nprintf '%s\\n' '{\"sessions\":1}'\n", + ); + let pty = ProviderFixture::new( + temporary.path().join("pty"), + "dev.st2.pty-stats", + component("ST2_PTY_STATS_COMPONENT"), + r#"{"topics":["stats"]}"#, + &format!( + "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + executable, + temporary.path() + ), + "st2.resource.pty-stats.v1", + "stats", + ); + let first = pty.observe(None); + assert_eq!(first.status, ObserveReceiptStatus::SettledChanged, "{first:?}"); + let first_bytes = fs::read(pty.snapshot()).unwrap(); + let replay = pty.observe(first.digest); + assert_eq!(replay.status, ObserveReceiptStatus::SettledUnchanged); + assert_eq!(fs::read(pty.snapshot()).unwrap(), first_bytes); + + write_executable(&executable, "#!/bin/sh\nexit 7\n"); + let failed = pty.observe(first.digest); + assert_eq!(failed.status, ObserveReceiptStatus::SettledFailed); + assert_eq!(fs::read(pty.snapshot()).unwrap(), first_bytes); + assert!(pty + .supervisor + .health() + .iter() + .any(|health| health.binding.as_deref() == Some("observed") + && health.state == st2::resource_profile::RuntimeHealthState::Degraded)); + + write_executable( + &executable, + "#!/bin/sh\nprintf '%s\\n' '{\"sessions\":2}'\n", + ); + let recovered = pty.observe(first.digest); + assert_eq!(recovered.status, ObserveReceiptStatus::SettledChanged); + assert!(pty + .supervisor + .health() + .iter() + .any(|health| health.binding.as_deref() == Some("observed") + && health.state == st2::resource_profile::RuntimeHealthState::Ready)); + let recovered_bytes = fs::read(pty.snapshot()).unwrap(); + drop(pty); + + let restarted = ProviderFixture::new( + temporary.path().join("pty"), + "dev.st2.pty-stats", + component("ST2_PTY_STATS_COMPONENT"), + r#"{"topics":["stats"]}"#, + &format!( + "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + executable, + temporary.path() + ), + "st2.resource.pty-stats.v1", + "stats", + ); + let unchanged_after_restart = restarted.observe(recovered.digest); assert_eq!( - first_inbox.len(), - 1, - "the first selected publication must create one built-in resync record" - ); - assert!( - first_inbox[0].contains("subject: observed · revision=primary-first [selected]"), - "{}", - first_inbox[0] - ); - assert!( - first_inbox[0].contains(r#""facts":[{"key":"revision","after":"primary-first"}]"#), - "{}", - first_inbox[0] + unchanged_after_restart.status, + ObserveReceiptStatus::SettledUnchanged ); + assert_eq!(fs::read(restarted.snapshot()).unwrap(), recovered_bytes); +} - primary - .runtime - .publish(&primary_registration, first, &["selected"], "primary-equal"); - wait_for_health(&primary_supervisor, "primary-equal"); - assert_eq!(fs::read(primary.snapshot_path()).unwrap(), first); - assert_eq!( - resync_inbox(&primary.agent_dir), - first_inbox, - "an equal publication must not invalidate the inbox" - ); - let filtered = br#"{"revision":2}"#; - primary.runtime.publish( - &primary_registration, - filtered, - &["ignored"], - "primary-filtered", - ); - wait_for_health(&primary_supervisor, "primary-filtered"); - assert_eq!(fs::read(primary.snapshot_path()).unwrap(), filtered); - assert_eq!( - resync_inbox(&primary.agent_dir), - first_inbox, - "an unselected topic must update the canonical snapshot without invalidation" - ); +#[test] +fn production_component_preserves_resync_filter_catch_up_and_scope_isolation() { + let _guard = STATE_ENV.lock(); + let temporary = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; - fs::remove_file(primary.owner_binding_path()).unwrap(); - let caught_up = br#"{"revision":3}"#; - primary.runtime.publish( - &primary_registration, - caught_up, - &["selected"], - "delivery-unavailable", - ); - wait_until("failed runtime after unavailable delivery", || { - (fs::read(primary.snapshot_path()).ok().as_deref() == Some(caught_up) - && primary_supervisor.health().is_empty()) - .then_some(()) + let primary_control = temporary.path().join("primary-control"); + fs::create_dir_all(&primary_control).unwrap(); + let primary_payload = primary_control.join("payload.json"); + fs::write(&primary_payload, r#"{"sessions":1}"#).unwrap(); + let primary_executable = primary_control.join("fixture-pty"); + write_executable( + &primary_executable, + "#!/bin/sh\nread payload < payload.json\nprintf '%s\\n' \"$payload\"\n", + ); + let primary = ProviderFixture::new( + temporary.path().join("primary"), + "dev.st2.pty-stats", + component("ST2_PTY_STATS_COMPONENT"), + r#"{"topics":["stats"]}"#, + &format!( + "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + primary_executable, primary_control + ), + "st2.resource.pty-stats.v1", + "stats", + ); + let first = primary.observe(None); + assert_eq!(first.status, ObserveReceiptStatus::SettledChanged, "{first:?}"); + let first_snapshot = fs::read(primary.snapshot()).unwrap(); + let first_inbox = wait_until("first resync record", || { + let inbox = resync_inbox(&primary.agent); + (!inbox.is_empty()).then_some(inbox) }); + assert_eq!(first_inbox.len(), 1); + assert!(first_inbox[0].contains("subject: observed · scope=all [stats]")); + assert!(first_inbox[0].contains(r#""facts":[{"key":"scope","after":"all"}]"#)); + + let equal = primary.observe(first.digest); + assert_eq!(equal.status, ObserveReceiptStatus::SettledUnchanged); + assert_eq!(fs::read(primary.snapshot()).unwrap(), first_snapshot); + assert_eq!(resync_inbox(&primary.agent), first_inbox); + + fs::write(&primary_payload, r#"{"sessions":2}"#).unwrap(); + primary.rewrite_selector(r#"{"topics":["ignored"]}"#); + primary.refresh(); + let filtered = primary.observe(first.digest); + assert_eq!(filtered.status, ObserveReceiptStatus::SettledChanged); + assert_ne!(fs::read(primary.snapshot()).unwrap(), first_snapshot); assert_eq!( - resync_inbox(&primary.agent_dir), + resync_inbox(&primary.agent), first_inbox, - "failed delivery must remain pending rather than forging a local inbox write" + "an unselected topic updates the snapshot without invalidation" + ); + + primary.rewrite_selector(r#"{"topics":["stats"]}"#); + primary.refresh(); + fs::write(&primary_payload, r#"{"sessions":3}"#).unwrap(); + let catch_up_fifo = primary_control.join("catch-up.fifo"); + let fifo_c = CString::new(catch_up_fifo.as_os_str().as_bytes()).unwrap(); + // SAFETY: `fifo_c` is a live NUL-terminated pathname for this call. + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + write_executable( + &primary_executable, + "#!/bin/sh\nread release < catch-up.fifo\nread payload < payload.json\nprintf '%s\\n' \"$payload\"\n", + ); + let pending_request = primary.request(1, filtered.digest); + let pending_client = + submit_request(&primary.root, &primary.host, &pending_request).unwrap(); + wait_receipt_status( + &primary, + &pending_request.request_id, + ObserveReceiptStatus::Accepted, ); - + fs::remove_file(primary.owner_binding_path()).unwrap(); + release_fifo(&catch_up_fifo); + let pending = pending_client.wait_for_terminal(WAIT).unwrap().receipt.unwrap(); + assert_eq!(pending.status, ObserveReceiptStatus::SettledChanged); + assert_eq!(resync_inbox(&primary.agent), first_inbox); st2::event::publish_owner_binding_for_test(&primary.root, &primary.host).unwrap(); - primary.refresh(&primary_supervisor); - let caught_up_inbox = resync_inbox(&primary.agent_dir); - assert_eq!( - caught_up_inbox.len(), - 1, - "supersession keeps one unread head" - ); - assert_ne!( - caught_up_inbox, first_inbox, - "restoring delivery must replace the old head with the pending digest" - ); - assert!( - caught_up_inbox[0].contains("revision=delivery-unavailable"), - "{}", - caught_up_inbox[0] - ); - let caught_up_projection = file_tree(&primary.agent_dir.join("resources")); - primary.refresh(&primary_supervisor); - assert_eq!( - file_tree(&primary.agent_dir.join("resources")), - caught_up_projection, - "an acknowledged catch-up must not replay on a later equal refresh" - ); - - let isolated = CatalogFixture::new(temporary.path().join("catalog-isolated"), "beta"); - st2::event::publish_owner_binding_for_test(&isolated.root, &isolated.host).unwrap(); - let isolated_supervisor = isolated.supervisor(); - let isolated_registration = isolated.runtime.registration(); - let isolated_first = br#"{"catalog":"isolated","revision":1}"#; - isolated.runtime.publish( - &isolated_registration, - isolated_first, - &["selected"], - "isolated-first", - ); - wait_for_health(&isolated_supervisor, "isolated-first"); - assert_eq!(fs::read(isolated.snapshot_path()).unwrap(), isolated_first); - assert_eq!( - resync_inbox(&isolated.agent_dir).len(), - 1, - "the isolated scope must own a real snapshot and inbox head before teardown" - ); - let isolated_before_drop = file_tree(&isolated.agent_dir.join("resources")); - - drop(primary_supervisor); + primary.refresh(); + let caught_up = wait_until("pending resync replay", || { + let inbox = resync_inbox(&primary.agent); + (inbox != first_inbox).then_some(inbox) + }); + assert_eq!(caught_up.len(), 1); + let caught_up_tree = file_tree(&primary.agent.join("resources")); + primary.refresh(); assert_eq!( - file_tree(&isolated.agent_dir.join("resources")), - isolated_before_drop, - "tearing down one catalog+host supervisor must not mutate another scope" - ); - - let isolated_second = br#"{"catalog":"isolated","revision":2}"#; - isolated.runtime.publish( - &isolated_registration, - isolated_second, - &["selected"], - "isolated-after-primary-drop", - ); - wait_for_health(&isolated_supervisor, "isolated-after-primary-drop"); + file_tree(&primary.agent.join("resources")), + caught_up_tree, + "acknowledged catch-up does not replay" + ); + + let isolated_control = temporary.path().join("isolated-control"); + fs::create_dir_all(&isolated_control).unwrap(); + let isolated_payload = isolated_control.join("payload.json"); + fs::write(&isolated_payload, r#"{"sessions":10}"#).unwrap(); + let isolated_executable = isolated_control.join("fixture-pty"); + write_executable( + &isolated_executable, + "#!/bin/sh\nread payload < payload.json\nprintf '%s\\n' \"$payload\"\n", + ); + let isolated = ProviderFixture::new( + temporary.path().join("isolated"), + "dev.st2.pty-stats", + component("ST2_PTY_STATS_COMPONENT"), + r#"{"topics":["stats"]}"#, + &format!( + "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + isolated_executable, isolated_control + ), + "st2.resource.pty-stats.v1", + "stats", + ); + let isolated_first = isolated.observe(None); + assert_eq!(isolated_first.status, ObserveReceiptStatus::SettledChanged); + let isolated_before = file_tree(&isolated.agent.join("resources")); + drop(primary); assert_eq!( - fs::read(isolated.snapshot_path()).unwrap(), - isolated_second, - "the isolated supervisor must remain live after the other scope tears down" + file_tree(&isolated.agent.join("resources")), + isolated_before, + "dropping one catalog scope must not mutate another" ); + fs::write(&isolated_payload, r#"{"sessions":11}"#).unwrap(); + let isolated_second = isolated.observe(isolated_first.digest); + assert_eq!(isolated_second.status, ObserveReceiptStatus::SettledChanged); assert_ne!( - file_tree(&isolated.agent_dir.join("resources")), - isolated_before_drop, - "the surviving scope must still publish and invalidate" - ); -} -#[test] -fn demand_observation_settlement_matrix_is_atomic_and_preserves_facts() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let temporary = tempfile::tempdir().unwrap(); - unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; - - let fixture = CatalogFixture::new(temporary.path().join("catalog"), "alpha"); - st2::event::publish_owner_binding_for_test(&fixture.root, &fixture.host).unwrap(); - let supervisor = fixture.supervisor(); - fixture.runtime.registration(); - - let unchanged = fixture.request(); - let unchanged_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &unchanged).unwrap(); - let unchanged_observation = fixture.runtime.wait_for_observation(1); - assert_eq!(unchanged_observation.demand_watermark, 1); - fixture.runtime.unchanged(&unchanged_observation); - let unchanged_receipt = unchanged_client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap(); - assert_eq!( - unchanged_receipt.status, - ObserveReceiptStatus::SettledUnchanged - ); - assert_eq!(unchanged_receipt.demand_watermark, Some(1)); - assert_eq!(unchanged_receipt.digest, None); - - let changed = fixture.request(); - let changed_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &changed).unwrap(); - let changed_observation = fixture.runtime.wait_for_observation(2); - let changed_bytes = br#"{"demand":"changed"}"#; - assert_eq!(changed_observation.demand_watermark, 2); - fixture.runtime.publish_observation( - &changed_observation, - changed_bytes, - &["selected"], - "demand-changed", - ); - let changed_receipt = changed_client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap(); - assert_eq!(changed_receipt.status, ObserveReceiptStatus::SettledChanged); - assert_eq!( - changed_receipt.digest, - Some(SnapshotDigest::of(changed_bytes)) + file_tree(&isolated.agent.join("resources")), + isolated_before, + "the surviving catalog remains live" ); - assert_eq!(fs::read(fixture.snapshot_path()).unwrap(), changed_bytes); - let changed_inbox = resync_inbox(&fixture.agent_dir); - assert_eq!(changed_inbox.len(), 1); - assert!( - changed_inbox[0].contains(r#""facts":[{"key":"revision","after":"demand-changed"}]"#), - "{}", - changed_inbox[0] - ); - - let failed = fixture.request(); - let failed_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &failed).unwrap(); - let failed_observation = fixture.runtime.wait_for_observation(3); - assert_eq!(failed_observation.demand_watermark, 3); - fixture - .runtime - .failed(&failed_observation, "provider refused"); - let failed_receipt = failed_client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap(); - assert_eq!(failed_receipt.status, ObserveReceiptStatus::SettledFailed); - assert_eq!( - failed_receipt.diagnostic.as_deref(), - Some("provider refused") - ); - drop(supervisor); } #[test] -fn demand_observation_coalesces_and_fences_watermarks() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); +fn production_demand_jobs_coalesce_queue_disconnect_and_fence_generation() { + let _guard = STATE_ENV.lock(); let temporary = tempfile::tempdir().unwrap(); unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; - - let fixture = CatalogFixture::new(temporary.path().join("catalog"), "alpha"); - st2::event::publish_owner_binding_for_test(&fixture.root, &fixture.host).unwrap(); - let supervisor = fixture.supervisor(); - fixture.runtime.registration(); - - let leading = fixture.request(); - let leading_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &leading).unwrap(); - let leading_observation = fixture.runtime.wait_for_observation(1); - let trailing_a = fixture.request(); + let control = temporary.path().join("control"); + fs::create_dir_all(&control).unwrap(); + fs::write(control.join("payload.json"), r#"{"sessions":1}"#).unwrap(); + let fifo = control.join("release.fifo"); + let fifo_c = CString::new(fifo.as_os_str().as_bytes()).unwrap(); + // SAFETY: `fifo_c` is a live NUL-terminated pathname for this call. + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + let executable = control.join("fixture-pty"); + write_executable( + &executable, + "#!/bin/sh\nread release < release.fifo\nread payload < payload.json\nprintf '%s\\n' \"$payload\"\n", + ); + let fixture = ProviderFixture::new( + temporary.path().join("catalog"), + "dev.st2.pty-stats", + component("ST2_PTY_STATS_COMPONENT"), + r#"{"topics":["stats"]}"#, + &format!( + "pty-stats executable={:?} cwd={:?} scope=\"all\" deadline-ms=10000", + executable, control + ), + "st2.resource.pty-stats.v1", + "stats", + ); + + let leading = fixture.request(1, None); + let leading_client = submit_request(&fixture.root, &fixture.host, &leading).unwrap(); + let leading_accepted = wait_receipt_status( + &fixture, + &leading.request_id, + ObserveReceiptStatus::Accepted, + ); + assert_eq!(leading_accepted.demand_watermark, Some(1)); + let trailing_a = fixture.request(1, None); + let trailing_b = fixture.request(1, None); let trailing_a_path = fixture .observe_request_dir() .join(format!("{}.json", trailing_a.request_id)); - let trailing_a_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &trailing_a).unwrap(); - let trailing_b = fixture.request(); let trailing_b_path = fixture .observe_request_dir() .join(format!("{}.json", trailing_b.request_id)); - let trailing_b_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &trailing_b).unwrap(); - wait_until("trailing request retention", || { - (trailing_a_path.exists() && trailing_b_path.exists()).then_some(()) + let trailing_a_client = submit_request(&fixture.root, &fixture.host, &trailing_a).unwrap(); + let trailing_b_client = submit_request(&fixture.root, &fixture.host, &trailing_b).unwrap(); + wait_until("coalesced trailing requests", || { + (trailing_a_path.is_file() && trailing_b_path.is_file()).then_some(()) }); - assert_eq!( - fixture.runtime.observations().len(), - 1, - "one in-flight demand permits only one coalesced trailing batch" - ); - assert_eq!(leading_observation.demand_watermark, 1); - fixture.runtime.unchanged(&leading_observation); - let trailing_observation = fixture.runtime.wait_for_observation(2); - assert_eq!(trailing_observation.demand_watermark, 2); - fixture.runtime.unchanged(&trailing_observation); + release_fifo(&fifo); assert_eq!( leading_client - .wait_for_terminal(Duration::from_secs(2)) + .wait_for_terminal(WAIT) .unwrap() .receipt .unwrap() .demand_watermark, Some(1) ); + for request in [&trailing_a, &trailing_b] { + let accepted = + wait_receipt_status(&fixture, &request.request_id, ObserveReceiptStatus::Accepted); + assert_eq!(accepted.demand_watermark, Some(2)); + } + release_fifo(&fifo); for client in [trailing_a_client, trailing_b_client] { - assert_eq!( - client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap() - .demand_watermark, - Some(2) - ); + let receipt = client.wait_for_terminal(WAIT).unwrap().receipt.unwrap(); + assert_eq!(receipt.demand_watermark, Some(2)); } - wait_until("durable request cleanup after terminal receipts", || { + wait_until("durable coalesced request cleanup", || { (!trailing_a_path.exists() && !trailing_b_path.exists()).then_some(()) }); - let mismatched = fixture.request(); - let mismatched_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &mismatched).unwrap(); - let mut mismatched_observation = fixture.runtime.wait_for_observation(3); - assert_eq!(mismatched_observation.demand_watermark, 3); - mismatched_observation.demand_watermark = 2; - fixture.runtime.unchanged(&mismatched_observation); - let mismatched_receipt = mismatched_client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap(); - assert_eq!( - mismatched_receipt.status, - ObserveReceiptStatus::ProviderUnavailable, - "a stale watermark must fail the runtime rather than settle the current demand" + let disconnected = fixture.request(1, None); + let disconnected_id = disconnected.request_id.clone(); + let disconnected_client = + submit_request(&fixture.root, &fixture.host, &disconnected).unwrap(); + wait_receipt_status( + &fixture, + &disconnected_id, + ObserveReceiptStatus::Accepted, + ); + drop(disconnected_client); + release_fifo(&fifo); + let disconnected_receipt = wait_until("receipt after client disconnect", || { + st2::resource_observe::read_receipt( + &fixture.observe_receipt_dir(), + &disconnected_id, + ) + .ok() + .flatten() + .filter(|receipt| receipt.status.is_terminal()) + }); + assert!( + matches!( + disconnected_receipt.status, + ObserveReceiptStatus::SettledChanged | ObserveReceiptStatus::SettledUnchanged + ), + "{disconnected_receipt:?}" ); - fixture.refresh(&supervisor); - - let stale = fixture.request(); - let stale_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &stale).unwrap(); - fixture.runtime.wait_for_observation(4); - fixture.refresh_generation(&supervisor, 2); - let stale_receipt = stale_client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap(); - assert_eq!(stale_receipt.status, ObserveReceiptStatus::StaleGeneration); -} - -#[test] -fn demand_observation_survives_restart_disconnect_and_denies_missing_capability() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let temporary = tempfile::tempdir().unwrap(); - unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; - - let fixture = CatalogFixture::new(temporary.path().join("catalog"), "alpha"); - st2::event::publish_owner_binding_for_test(&fixture.root, &fixture.host).unwrap(); - let initial = fixture.supervisor(); - fixture.runtime.registration(); - let restart_request = fixture.request(); - let restart_path = fixture + let future = fixture.request(2, disconnected_receipt.digest); + let future_path = fixture .observe_request_dir() - .join(format!("{}.json", restart_request.request_id)); - let restart_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &restart_request) - .unwrap(); - let interrupted_observation = fixture.runtime.wait_for_observation(1); + .join(format!("{}.json", future.request_id)); + let future_client = submit_request(&fixture.root, &fixture.host, &future).unwrap(); + fixture.refresh_generation(1); + assert!(future_path.is_file()); assert!( - restart_path.is_file(), - "enqueue must not remove the restart-recoverable durable request" - ); - drop(initial); - let restarted = fixture.supervisor(); - let restart_observation = fixture.runtime.wait_for_observation(2); - assert_eq!(restart_observation.demand_watermark, 1); - assert_ne!( - restart_observation.registration.owner, interrupted_observation.registration.owner, - "restart recovery must redispatch through the new runtime owner" + st2::resource_observe::read_receipt( + &fixture.observe_receipt_dir(), + &future.request_id, + ) + .unwrap() + .is_none() ); - fixture.runtime.unchanged(&restart_observation); + fixture.refresh_generation(2); + wait_receipt_status(&fixture, &future.request_id, ObserveReceiptStatus::Accepted); + release_fifo(&fifo); assert_eq!( - restart_client - .wait_for_terminal(Duration::from_secs(2)) + future_client + .wait_for_terminal(WAIT) .unwrap() .receipt .unwrap() @@ -707,86 +362,66 @@ fn demand_observation_survives_restart_disconnect_and_denies_missing_capability( ObserveReceiptStatus::SettledUnchanged ); - let disconnected = fixture.request(); - let disconnected_id = disconnected.request_id.clone(); - let disconnected_client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &disconnected).unwrap(); - let disconnected_observation = fixture.runtime.wait_for_observation(3); - drop(disconnected_client); - assert_eq!(disconnected_observation.demand_watermark, 2); - fixture.runtime.unchanged(&disconnected_observation); - let disconnected_receipt = wait_until("receipt after client disconnect", || { - st2::resource_observe::read_receipt(&fixture.observe_receipt_dir(), &disconnected_id) - .ok() - .flatten() - .filter(|receipt| receipt.status.is_terminal()) - }); + let stale = fixture.request(2, None); + let stale_client = submit_request(&fixture.root, &fixture.host, &stale).unwrap(); + wait_receipt_status(&fixture, &stale.request_id, ObserveReceiptStatus::Accepted); + fixture.refresh_generation(3); assert_eq!( - disconnected_receipt.status, - ObserveReceiptStatus::SettledUnchanged + stale_client + .wait_for_terminal(WAIT) + .unwrap() + .receipt + .unwrap() + .status, + ObserveReceiptStatus::StaleGeneration ); let config_path = st2::catalog::config_path(&fixture.root); let without_demand = fs::read_to_string(&config_path) .unwrap() - .replace(" capability \"demand\"\n", ""); - fs::write(&config_path, without_demand).unwrap(); - fixture.refresh_generation(&restarted, 2); - let gated = ObserveRequest::new( - format!("{}.worker", fixture.host), - "observed".to_owned(), - None, - None, - ) - .unwrap(); - let before_gate = fixture.runtime.observations().len(); - let gated_receipt = st2::resource_observe::submit_request(&fixture.root, &fixture.host, &gated) + .replace(" demand #true\n", " demand #false\n"); + fs::write(config_path, without_demand).unwrap(); + fixture.refresh_generation(4); + let absent = fixture.request(4, None); + let absent_receipt = submit_request(&fixture.root, &fixture.host, &absent) .unwrap() - .wait_for_terminal(Duration::from_secs(2)) + .wait_for_terminal(WAIT) .unwrap() .receipt .unwrap(); - assert_eq!(gated_receipt.status, ObserveReceiptStatus::AbsentBinding); - assert_eq!( - fixture.runtime.observations().len(), - before_gate, - "a runtime without declared demand capability received an observe frame" - ); + assert_eq!(absent_receipt.status, ObserveReceiptStatus::AbsentBinding); } #[test] -fn durable_observe_admission_rejects_the_concurrent_257th_request() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); +fn durable_admission_retains_the_256_request_boundary_without_a_runtime() { + use st2::resource_observe::{MAX_PENDING_OBSERVE_REQUESTS, ObserveAdmissionBackpressure}; + let _guard = STATE_ENV.lock(); + let temporary = tempfile::tempdir().unwrap(); unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; - - let fixture = CatalogFixture::new(temporary.path().join("catalog"), "alpha"); - st2::event::publish_owner_binding_for_test(&fixture.root, &fixture.host).unwrap(); - let supervisor = fixture.supervisor(); - fixture.runtime.registration(); - drop(supervisor); - + let root = temporary.path(); + let host = "capacity"; + st2::event::publish_owner_binding_for_test(root, host).unwrap(); + let barrier = Arc::new(Barrier::new(MAX_PENDING_OBSERVE_REQUESTS + 1)); let submissions = (0..=MAX_PENDING_OBSERVE_REQUESTS) - .map(|_| { - let root = fixture.root.clone(); - let host = fixture.host.clone(); + .map(|index| { + let root = root.to_path_buf(); + let host = host.to_owned(); + let barrier = Arc::clone(&barrier); std::thread::spawn(move || { let request = ObserveRequest::new( - format!("{host}.worker"), - "observed".to_owned(), + format!("{host}.worker-{index}"), + "observed".into(), Some(1), None, ) .unwrap(); - st2::resource_observe::submit_request(&root, &host, &request) - .map(|_| true) - .map_err(|error| { - error - .downcast_ref::() - .is_some() - }) + barrier.wait(); + submit_request(&root, &host, &request).map_err(|error| { + error + .downcast_ref::() + .is_some_and(|pressure| pressure.limit() == MAX_PENDING_OBSERVE_REQUESTS) + }) }) }) .collect::>(); @@ -800,122 +435,41 @@ fn durable_observe_admission_rejects_the_concurrent_257th_request() { .iter() .filter(|result| matches!(result, Err(true))) .count(), - 1, - "the only failed submission must expose structured durable backpressure" - ); - let durable_count = fs::read_dir(fixture.observe_request_dir()) - .unwrap() - .flatten() - .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) - .count(); - assert_eq!(durable_count, MAX_PENDING_OBSERVE_REQUESTS); -} - -#[test] -fn newer_client_generation_stays_queued_until_the_supervisor_refreshes() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let temporary = tempfile::tempdir().unwrap(); - unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; - - let fixture = CatalogFixture::new(temporary.path().join("catalog"), "alpha"); - st2::event::publish_owner_binding_for_test(&fixture.root, &fixture.host).unwrap(); - let supervisor = fixture.supervisor(); - fixture.runtime.registration(); - - let request = ObserveRequest::new( - format!("{}.worker", fixture.host), - "observed".to_owned(), - Some(2), - None, - ) - .unwrap(); - let client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &request).unwrap(); - fixture.refresh_generation(&supervisor, 1); - let _ = supervisor.health(); - assert!( - fixture.runtime.observations().is_empty(), - "a client-ahead generation was dispatched against an older resident catalog" - ); - assert!( - st2::resource_observe::read_receipt(&fixture.observe_receipt_dir(), &request.request_id) - .unwrap() - .is_none(), - "a client-ahead generation was incorrectly terminalized as stale" + 1 ); - - fixture.refresh_generation(&supervisor, 2); - let observation = fixture.runtime.wait_for_observation(1); - fixture.runtime.unchanged(&observation); - let receipt = client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap(); - assert_eq!(receipt.status, ObserveReceiptStatus::SettledUnchanged); } -#[test] -fn demanded_publication_settles_changed_before_resync_delivery_failure() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let temporary = tempfile::tempdir().unwrap(); - unsafe { std::env::set_var("XDG_STATE_HOME", temporary.path().join("state")) }; - - let fixture = CatalogFixture::new(temporary.path().join("catalog"), "alpha"); - st2::event::publish_owner_binding_for_test(&fixture.root, &fixture.host).unwrap(); - let _supervisor = fixture.supervisor(); - fixture.runtime.registration(); - - let request = fixture.request(); - let client = - st2::resource_observe::submit_request(&fixture.root, &fixture.host, &request).unwrap(); - let observation = fixture.runtime.wait_for_observation(1); - fs::remove_file(fixture.owner_binding_path()).unwrap(); - let published = br#"{"demand":"accepted-before-delivery"}"#; - fixture - .runtime - .publish_observation(&observation, published, &["selected"], "delivery-failed"); - let receipt = client - .wait_for_terminal(Duration::from_secs(2)) - .unwrap() - .receipt - .unwrap(); - assert_eq!(receipt.status, ObserveReceiptStatus::SettledChanged); - assert_eq!(receipt.digest, Some(SnapshotDigest::of(published))); - assert_eq!(fs::read(fixture.snapshot_path()).unwrap(), published); +fn release_fifo(path: &Path) { + let mut writer = OpenOptions::new().write(true).open(path).unwrap(); + writer.write_all(b"go\n").unwrap(); } -fn wait_for_health(supervisor: &ResourceProfileSupervisor, marker: &str) { - wait_until(marker, || { - supervisor - .health() - .iter() - .any(|health| health.detail.as_deref() == Some(marker)) - .then_some(()) - }); +fn wait_receipt_status( + fixture: &ProviderFixture, + request_id: &str, + status: ObserveReceiptStatus, +) -> ObserveReceipt { + wait_until(status.wire_str(), || { + st2::resource_observe::read_receipt(&fixture.observe_receipt_dir(), request_id) + .ok() + .flatten() + .filter(|receipt| receipt.status == status) + }) } fn wait_until(description: &str, mut probe: impl FnMut() -> Option) -> T { - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + WAIT; loop { if let Some(value) = probe() { return value; } - assert!( - Instant::now() < deadline, - "timed out waiting for {description}" - ); + assert!(Instant::now() < deadline, "timed out waiting for {description}"); std::thread::yield_now(); } } -fn resync_inbox(agent_dir: &Path) -> Vec { - let inbox = agent_dir.join("resources/inbox"); - let mut records = fs::read_dir(inbox) +fn resync_inbox(agent: &Path) -> Vec { + let mut records = fs::read_dir(agent.join("resources/inbox")) .into_iter() .flatten() .flatten() @@ -946,51 +500,211 @@ fn file_tree(root: &Path) -> Vec<(PathBuf, Vec)> { } } } - let mut files = Vec::new(); visit(root, root, &mut files); files.sort_by(|left, right| left.0.cmp(&right.0)); files } -fn observable_resolver_wasm() -> Vec { - const DESCRIPTOR: &[u8] = br#"{"abiVersion":3,"capabilities":["resolve","read","observe"],"selectorSchema":{"type":"object","properties":{"topics":{"type":"array","items":{"type":"string"},"uniqueItems":true}},"required":["topics"],"additionalProperties":false},"defaultSelector":{"topics":["selected"]},"topics":[{"name":"selected"},{"name":"ignored"}],"runtime":{"topology":"shared"},"snapshot":{"mediaType":"application/json","schemaId":"dev.example.observable.snapshot.v1"}}"#; +fn component(variable: &str) -> PathBuf { + PathBuf::from(std::env::var_os(variable).unwrap_or_else(|| panic!("{variable} is not set"))) +} + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); +} + +struct ProviderFixture { + root: PathBuf, + agent: PathBuf, + host: String, + scheme: String, + selector: Mutex, + supervisor: ResourceProfileSupervisor, +} + +impl ProviderFixture { + #[allow(clippy::too_many_arguments)] + fn new( + root: PathBuf, + scheme: &str, + component: PathBuf, + selector: &str, + capability: &str, + schema_id: &str, + topic: &str, + ) -> Self { + let host = "e2e".to_owned(); + let agent = root.join("agents/e2e/worker"); + fs::create_dir_all(agent.join("resources")).unwrap(); + fs::create_dir_all(root.join("providers")).unwrap(); + let installed_component = root.join("providers/provider.component.wasm"); + if installed_component.exists() { + fs::remove_file(&installed_component).unwrap(); + } + fs::copy(component, installed_component).unwrap(); + fs::write( + root.join("resolver.wasm"), + observable_resolver_wasm(schema_id, topic, selector), + ) + .unwrap(); + fs::write( + st2::catalog::config_path(&root), + format!( + "profile {scheme:?} {{\n wasm \"resolver.wasm\"\n class \"immediate\"\n runtime {{\n component \"providers/provider.component.wasm\"\n demand #true\n {capability}\n }}\n}}\n" + ), + ) + .unwrap(); + write_agent(&agent, &host, scheme, selector); + st2::event::publish_owner_binding_for_test(&root, &host).unwrap(); + let supervisor = ResourceProfileSupervisor::new(root.clone(), host.clone()).unwrap(); + let fixture = Self { + root, + agent, + host, + scheme: scheme.to_owned(), + selector: Mutex::new(selector.to_owned()), + supervisor, + }; + fixture.refresh(); + fixture + } + + fn refresh(&self) { + self.refresh_generation(1); + } + + fn refresh_generation(&self, generation: u64) { + let (config, profiles) = st2::catalog::declared_profile_catalog(&self.root).unwrap(); + let discovery = st2::discover_strict(&self.root); + assert!(discovery.errors.is_empty(), "{:?}", discovery.errors); + let report = self + .supervisor + .refresh(&config, &profiles, Some(generation), &discovery.specs); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + } + + fn rewrite_selector(&self, selector: &str) { + *self.selector.lock() = selector.to_owned(); + write_agent(&self.agent, &self.host, &self.scheme, selector); + } + + fn observe(&self, prior: Option) -> ObserveReceipt { + self.observe_generation(1, prior) + } + + fn observe_generation( + &self, + generation: u64, + prior: Option, + ) -> ObserveReceipt { + let request = ObserveRequest::new( + format!("{}.worker", self.host), + "observed".into(), + Some(generation), + prior, + ) + .unwrap(); + let wait = submit_request(&self.root, &self.host, &request) + .unwrap() + .wait_for_terminal(WAIT) + .unwrap(); + assert!(!wait.timed_out); + wait.receipt.unwrap() + } + + fn request( + &self, + generation: u64, + prior: Option, + ) -> ObserveRequest { + ObserveRequest::new( + format!("{}.worker", self.host), + "observed".into(), + Some(generation), + prior, + ) + .unwrap() + } + + fn snapshot(&self) -> PathBuf { + self.agent.join("resources/snapshot.json") + } + + fn observe_request_dir(&self) -> PathBuf { + st2::park::SupervisorScope::current(&self.root, &self.host) + .unwrap() + .park_dir() + .parent() + .unwrap() + .join("observe-requests") + } + + fn observe_receipt_dir(&self) -> PathBuf { + st2::park::SupervisorScope::current(&self.root, &self.host) + .unwrap() + .park_dir() + .parent() + .unwrap() + .join("observe-receipts") + } + + fn owner_binding_path(&self) -> PathBuf { + st2::park::SupervisorScope::current(&self.root, &self.host) + .unwrap() + .park_dir() + .parent() + .unwrap() + .join("stream-owner.json") + } +} + +fn write_agent(agent: &Path, host: &str, scheme: &str, selector: &str) { + fs::write( + agent.join("agent.kdl"), + format!( + "agent \"worker\" {{\n host {host:?}\n command \"true\"\n resource \"observed\" uri=\"{scheme}://subject\" reason=\"Observed state.\" selector=#\"{selector}\"#\n}}\n" + ), + ) + .unwrap(); +} + +fn observable_resolver_wasm(schema_id: &str, topic: &str, selector: &str) -> Vec { + let selector_value: serde_json::Value = serde_json::from_str(selector).unwrap(); + let descriptor = serde_json::to_vec(&serde_json::json!({ + "abiVersion": 3, + "capabilities": ["resolve", "read", "observe"], + "selectorSchema": { "type": "object", "additionalProperties": true }, + "defaultSelector": selector_value, + "topics": [{"name": topic}, {"name": "ignored"}], + "runtime": {"topology": "shared"}, + "snapshot": {"mediaType": "application/json", "schemaId": schema_id} + })) + .unwrap(); const RESOLUTION: &[u8] = br#"{"path":"resources/snapshot.json","class":"observable"}"#; const DESCRIPTOR_PTR: i64 = 1024; - const RESOLUTION_PTR: i64 = 4096; - + const RESOLUTION_PTR: i64 = 8192; let mut module = b"\0asm\x01\0\0\0".to_vec(); - let mut types = vec![3, 0x60, 1, 0x7f, 1, 0x7f, 0x60, 4]; types.extend([0x7f, 0x7f, 0x7f, 0x7f, 1, 0x7e]); types.extend([0x60, 0, 1, 0x7e]); push_section(&mut module, 1, &types); push_section(&mut module, 3, &[3, 0, 1, 2]); push_section(&mut module, 5, &[1, 0, 1]); - let mut exports = vec![4]; push_export(&mut exports, "memory", 0x02, 0); push_export(&mut exports, "alloc", 0x00, 0); push_export(&mut exports, "resolve", 0x00, 1); push_export(&mut exports, "describe", 0x00, 2); push_section(&mut module, 7, &exports); - let mut code = vec![3]; - push_body(&mut code, 0x41, 8192); - push_body( - &mut code, - 0x42, - (RESOLUTION_PTR << 32) | RESOLUTION.len() as i64, - ); - push_body( - &mut code, - 0x42, - (DESCRIPTOR_PTR << 32) | DESCRIPTOR.len() as i64, - ); + push_body(&mut code, 0x41, 16384); + push_body(&mut code, 0x42, (RESOLUTION_PTR << 32) | RESOLUTION.len() as i64); + push_body(&mut code, 0x42, (DESCRIPTOR_PTR << 32) | descriptor.len() as i64); push_section(&mut module, 10, &code); - let mut data = vec![2]; - push_data(&mut data, DESCRIPTOR_PTR, DESCRIPTOR); + push_data(&mut data, DESCRIPTOR_PTR, &descriptor); push_data(&mut data, RESOLUTION_PTR, RESOLUTION); push_section(&mut module, 11, &data); module @@ -1018,8 +732,7 @@ fn push_body(section: &mut Vec, constant_opcode: u8, value: i64) { } fn push_data(section: &mut Vec, offset: i64, bytes: &[u8]) { - section.push(0); - section.push(0x41); + section.extend([0, 0x41]); push_i64(section, offset); section.push(0x0b); push_u32(section, bytes.len() as u32); @@ -1030,13 +743,9 @@ fn push_u32(bytes: &mut Vec, mut value: u32) { loop { let mut byte = (value & 0x7f) as u8; value >>= 7; - if value != 0 { - byte |= 0x80; - } + if value != 0 { byte |= 0x80; } bytes.push(byte); - if value == 0 { - return; - } + if value == 0 { return; } } } @@ -1046,8 +755,6 @@ fn push_i64(bytes: &mut Vec, mut value: i64) { value >>= 7; let done = (value == 0 && byte & 0x40 == 0) || (value == -1 && byte & 0x40 != 0); bytes.push(if done { byte } else { byte | 0x80 }); - if done { - return; - } + if done { return; } } } diff --git a/tests/resource_provider_e2e.rs b/tests/resource_provider_e2e.rs new file mode 100755 index 00000000..354aa2e3 --- /dev/null +++ b/tests/resource_provider_e2e.rs @@ -0,0 +1,161 @@ +#![cfg(all(unix, feature = "wasip2-provider-runtime"))] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::json; +use st2_resource_protocol::{ObservationResult, SnapshotDigest}; +use st2_resource_providers::{ + GitHubIssueConfig, GitHubIssueModule, PtyStatsConfig, PtyStatsModule, PtyStatsScope, +}; +use st2_resource_wasip2::{Executor, ObservationRequest, RuntimeConfig}; + +#[test] +fn pty_component_observes_replays_and_enforces_capability_scope() { + let temporary = tempfile::tempdir().unwrap(); + let executable = temporary.path().join("pty-stats"); + write_executable( + &executable, + "#!/bin/sh\nprintf '%s\\n' '{\"sessions\":2,\"bytes\":64}'\n", + ); + + let module = PtyStatsModule::new( + PtyStatsConfig::resolve( + &executable, + temporary.path().to_path_buf(), + PtyStatsScope::All, + Duration::from_secs(5), + ) + .unwrap(), + ); + let executor = Executor::new(RuntimeConfig::default(), None, module).unwrap(); + let component_bytes = fs::read(component("ST2_PTY_STATS_COMPONENT")).unwrap(); + let loaded = executor.load(&component_bytes).unwrap(); + let descriptor = executor.describe(&loaded, None).unwrap(); + assert_eq!(descriptor.topics, ["stats"]); + assert_eq!(descriptor.snapshot_schema_id, "st2.resource.pty-stats.v1"); + assert_eq!(descriptor.snapshot_media_type, "application/json"); + + + let first = executor + .observe( + &loaded, + &ObservationRequest { + invocation_id: 1, + uri: "dev.st2.pty-stats://all".into(), + selector: json!({ "topics": ["stats"] }), + prior_digest: None, + demand_watermark: Some(1), + }, + None, + ) + .unwrap(); + let publication = match first { + ObservationResult::Published { publication } => publication, + other => panic!("first observation must publish, got {other:?}"), + }; + assert_eq!(publication.schema_id, "st2.resource.pty-stats.v1"); + assert_eq!(publication.topics, ["stats"]); + assert_eq!( + publication + .facts + .as_ref() + .unwrap() + .iter() + .map(|fact| fact.key()) + .collect::>(), + ["scope"] + ); + let prior = SnapshotDigest::of(publication.bytes.as_slice()); + + let replay = executor + .observe( + &loaded, + &ObservationRequest { + invocation_id: 2, + uri: "dev.st2.pty-stats://all".into(), + selector: json!({ "topics": ["stats"] }), + prior_digest: Some(prior), + demand_watermark: Some(2), + }, + None, + ) + .unwrap(); + assert_eq!(replay, ObservationResult::Unchanged); + + let denied = executor + .observe( + &loaded, + &ObservationRequest { + invocation_id: 3, + uri: "dev.st2.pty-stats://session/other".into(), + selector: json!({ "session": "other", "topics": ["stats"] }), + prior_digest: None, + demand_watermark: Some(3), + }, + None, + ) + .unwrap(); + assert!(matches!( + &denied, + ObservationResult::Failed { + diagnostic: Some(diagnostic) + } if diagnostic.contains("PTY stats scope denied") + )); +} + +#[test] +#[ignore = "explicit read-only public GitHub smoke; requires network and ST2_GITHUB_ISSUE_COMPONENT"] +fn github_component_public_read_only_smoke() { + let module = GitHubIssueModule::new(GitHubIssueConfig { + owner: "rust-lang".into(), + repo: "rust".into(), + number: 1, + connect_timeout: Duration::from_secs(3), + total_timeout: Duration::from_secs(10), + }) + .unwrap(); + let executor = Executor::new(RuntimeConfig::default(), None, module).unwrap(); + let component_bytes = fs::read(component("ST2_GITHUB_ISSUE_COMPONENT")).unwrap(); + let loaded = executor.load(&component_bytes).unwrap(); + let descriptor = executor.describe(&loaded, None).unwrap(); + assert_eq!(descriptor.topics, ["issue"]); + assert_eq!( + descriptor.snapshot_schema_id, + "st2.resource.github-issue.v1" + ); + assert_eq!(descriptor.snapshot_media_type, "application/json"); + + let observed = executor + .observe( + &loaded, + &ObservationRequest { + invocation_id: 1, + uri: "dev.st2.github-issue://rust-lang/rust/1".into(), + selector: json!({ + "owner": "rust-lang", + "repo": "rust", + "number": 1, + "topics": ["issue"] + }), + prior_digest: None, + demand_watermark: Some(1), + }, + None, + ) + .unwrap(); + assert!(matches!(observed, ObservationResult::Published { .. })); +} + +fn component(variable: &str) -> PathBuf { + PathBuf::from(std::env::var_os(variable).unwrap_or_else(|| panic!("{variable} is not set"))) +} + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} diff --git a/wit/github-issue/deps/st2-github-issue/github-issue.wit b/wit/github-issue/deps/st2-github-issue/github-issue.wit new file mode 100644 index 00000000..859280c5 --- /dev/null +++ b/wit/github-issue/deps/st2-github-issue/github-issue.wit @@ -0,0 +1,24 @@ +package compoundingtech:st2-github-issue@0.1.0; + +interface github-issue { + record issue-request { + owner: string, + repo: string, + number: u64, + etag: option, + } + + variant issue-response { + ok(tuple, list>), + not-modified(option), + } + + variant issue-error { + denied, + unavailable, + resource-exhausted, + deadline-exceeded, + } + + get: func(request: issue-request) -> result; +} diff --git a/wit/github-issue/provider-api.wit b/wit/github-issue/provider-api.wit new file mode 120000 index 00000000..d2b86ec4 --- /dev/null +++ b/wit/github-issue/provider-api.wit @@ -0,0 +1 @@ +../../crates/st2-resource-wasip2/wit/provider.wit \ No newline at end of file diff --git a/wit/github-issue/provider.wit b/wit/github-issue/provider.wit new file mode 100644 index 00000000..da2e4ea6 --- /dev/null +++ b/wit/github-issue/provider.wit @@ -0,0 +1,6 @@ +package st2:resource-provider@0.1.0; + +world github-issue-provider { + import compoundingtech:st2-github-issue/github-issue@0.1.0; + export provider-api; +} diff --git a/wit/pty-stats/deps/st2-pty-stats/pty-stats.wit b/wit/pty-stats/deps/st2-pty-stats/pty-stats.wit new file mode 100644 index 00000000..95f43864 --- /dev/null +++ b/wit/pty-stats/deps/st2-pty-stats/pty-stats.wit @@ -0,0 +1,31 @@ +package compoundingtech:st2-pty-stats@0.1.0; + +interface pty-stats { + variant scope { + all, + session(string), + } + + variant exit-status { + code(s32), + signal(s32), + } + + record outcome { + stdout: list, + stderr: list, + stdout-truncated: bool, + stderr-truncated: bool, + exit: exit-status, + } + + variant pty-stats-error { + denied, + unavailable, + resource-exhausted, + deadline-exceeded, + cancelled, + } + + get: func(scope: scope) -> result; +} diff --git a/wit/pty-stats/provider-api.wit b/wit/pty-stats/provider-api.wit new file mode 120000 index 00000000..d2b86ec4 --- /dev/null +++ b/wit/pty-stats/provider-api.wit @@ -0,0 +1 @@ +../../crates/st2-resource-wasip2/wit/provider.wit \ No newline at end of file diff --git a/wit/pty-stats/provider.wit b/wit/pty-stats/provider.wit new file mode 100644 index 00000000..a37a360b --- /dev/null +++ b/wit/pty-stats/provider.wit @@ -0,0 +1,6 @@ +package st2:resource-provider@0.1.0; + +world pty-stats-provider { + import compoundingtech:st2-pty-stats/pty-stats@0.1.0; + export provider-api; +}