refactor!: extract execution engine into standalone workshop-engine
crate BREAKING CHANGE: Moved engine/ module (cgroups, executor, pm, upm, types) and shared types (command, repology, time) from workshop-baker to new workshop-engine crate. All internal error types consolidated into workshop-engine::error. Import paths changed throughout codebase.
This commit is contained in:
Generated
+885
@@ -0,0 +1,885 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "duration-str"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "027cd1402a609c71a9ac333c7e3d90ee042e7a131da1a83da8c60df323f12f61"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"time",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image",
|
||||
"objc2-core-location",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"objc2-user-notifications",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"serde",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "workshop-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"duration-str",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"os_info",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "workshop-engine"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "workshop_engine"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
thiserror = "2.0.17"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.148"
|
||||
serde_yaml = "0.9.34"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
async-trait = "0.1.87"
|
||||
lazy_static = "1.5.0"
|
||||
log = "0.4.28"
|
||||
os_info = "3.14.0"
|
||||
duration-str = "0.21.0"
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Linux cgroup resource manager for ws-executor.
|
||||
///
|
||||
/// Manages cgroup v2 hierarchy under `/sys/fs/cgroup/ws-executor/` for
|
||||
/// CPU, memory, and process isolation.
|
||||
pub struct CgroupManager {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl CgroupManager {
|
||||
/// Creates a new cgroup for the given build ID.
|
||||
pub fn new(build_id: &str) -> io::Result<Self> {
|
||||
let path = PathBuf::from(format!("/sys/fs/cgroup/ws-executor/{}", build_id));
|
||||
|
||||
fs::create_dir_all(&path)?;
|
||||
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
/// Sets memory limit in bytes. Use 0 for unlimited.
|
||||
pub fn set_memory_limit(&self, limit_bytes: u64) -> io::Result<()> {
|
||||
let mem_max_path = self.path.join("memory.max");
|
||||
let content = if limit_bytes == 0 {
|
||||
"max".to_string()
|
||||
} else {
|
||||
limit_bytes.to_string()
|
||||
};
|
||||
fs::write(&mem_max_path, content)?;
|
||||
|
||||
let mem_swap_path = self.path.join("memory.swap.max");
|
||||
fs::write(&mem_swap_path, "max")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets CPU weight (1-10000, higher = more CPU time).
|
||||
pub fn set_cpu_weight(&self, weight: u64) -> io::Result<()> {
|
||||
let cpu_weight_path = self.path.join("cpu.weight");
|
||||
let weight = weight.clamp(1, 10000);
|
||||
fs::write(&cpu_weight_path, weight.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets CPU max burst in microseconds with optional period.
|
||||
pub fn set_cpu_max(&self, max_us: u64, period_us: u64) -> io::Result<()> {
|
||||
let cpu_max_path = self.path.join("cpu.max");
|
||||
let content = if max_us == 0 {
|
||||
"max".to_string()
|
||||
} else {
|
||||
format!("{} {}", max_us, period_us)
|
||||
};
|
||||
fs::write(&cpu_max_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a process to this cgroup.
|
||||
pub fn add_process(&self, pid: u32) -> io::Result<()> {
|
||||
let cgroup_procs_path = self.path.join("cgroup.procs");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&cgroup_procs_path)?;
|
||||
|
||||
writeln!(file, "{}", pid)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destroys the cgroup directory.
|
||||
pub fn destroy(&self) -> io::Result<()> {
|
||||
if self.path.exists() {
|
||||
fs::remove_dir(&self.path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the cgroup path.
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_cgroup_manager_new() {
|
||||
let build_id = "test-build-123";
|
||||
let manager = CgroupManager::new(build_id).unwrap();
|
||||
assert!(manager.path().exists());
|
||||
manager.destroy().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_set_memory_limit() {
|
||||
let build_id = "test-mem-123";
|
||||
let manager = CgroupManager::new(build_id).unwrap();
|
||||
manager.set_memory_limit(1024 * 1024 * 1024).unwrap();
|
||||
manager.destroy().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_set_cpu_weight() {
|
||||
let build_id = "test-cpu-123";
|
||||
let manager = CgroupManager::new(build_id).unwrap();
|
||||
manager.set_cpu_weight(1024).unwrap();
|
||||
manager.destroy().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::time::deserialize_duration_ms;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A customizable command with environment, working directory, and timeout settings.
|
||||
///
|
||||
/// # Example
|
||||
/// ```yaml
|
||||
/// name: build-script
|
||||
/// command: ./build.sh
|
||||
/// timeout: 60000
|
||||
/// ```
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CustomCommand {
|
||||
/// Command identifier. Defaults to `"(unnamed)"`.
|
||||
#[serde(default = "default_name")]
|
||||
pub name: String,
|
||||
|
||||
/// The shell command to execute.
|
||||
pub command: String,
|
||||
|
||||
/// Environment variables as key-value pairs. A `None` value means the variable
|
||||
/// should be unset.
|
||||
#[serde(default)]
|
||||
pub environment: Option<HashMap<String, Option<String>>>,
|
||||
|
||||
/// Working directory for command execution. Defaults to current directory.
|
||||
#[serde(default)]
|
||||
pub working_dir: Option<String>,
|
||||
|
||||
/// Execution timeout in milliseconds. Defaults to `300_000` (5 minutes).
|
||||
/// Accepts human-readable duration strings (e.g., `"30s"`, `"5m"`).
|
||||
#[serde(
|
||||
default = "default_timeout_ms",
|
||||
deserialize_with = "deserialize_duration_ms",
|
||||
rename = "timeout"
|
||||
)]
|
||||
pub timeout_ms: u64,
|
||||
|
||||
/// Additional YAML value for flexible argument passing.
|
||||
#[serde(default)]
|
||||
pub argument: serde_yaml::Value,
|
||||
}
|
||||
|
||||
fn default_timeout_ms() -> u64 {
|
||||
300_000
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
"(unnamed)".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_values() {
|
||||
let yaml = "command: echo hello";
|
||||
let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cmd.name, "(unnamed)");
|
||||
assert_eq!(cmd.timeout_ms, 300_000);
|
||||
assert!(cmd.environment.is_none());
|
||||
assert!(cmd.working_dir.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_serde_roundtrip() {
|
||||
let cmd = CustomCommand {
|
||||
name: "test-cmd".to_string(),
|
||||
command: "./build.sh".to_string(),
|
||||
environment: Some(HashMap::from([
|
||||
("FOO".to_string(), Some("bar".to_string())),
|
||||
("BAZ".to_string(), None),
|
||||
])),
|
||||
working_dir: Some("/tmp/build".to_string()),
|
||||
timeout_ms: 60000,
|
||||
argument: serde_yaml::Value::Null,
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&cmd).unwrap();
|
||||
let deserialized: CustomCommand = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized.name, "test-cmd");
|
||||
assert_eq!(deserialized.command, "./build.sh");
|
||||
assert_eq!(deserialized.timeout_ms, 60_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimal_yaml() {
|
||||
let yaml = "command: ./build.sh";
|
||||
let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cmd.command, "./build.sh");
|
||||
assert_eq!(cmd.name, "(unnamed)");
|
||||
assert_eq!(cmd.timeout_ms, 300_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::fmt::Debug;
|
||||
use std::fmt::Display;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
pub trait HasExitCode: Display + Debug + Send + Sync + Sized + 'static {
|
||||
fn exit_code(&self) -> i32;
|
||||
}
|
||||
|
||||
pub const EXITCODE_OK: i32 = 0;
|
||||
pub const EXITCODE_GENERAL_ERROR: i32 = 1;
|
||||
pub const EXITCODE_INVALID_ARGS: i32 = 2;
|
||||
pub const EXITCODE_IO_ERROR: i32 = 3;
|
||||
pub const EXITCODE_PARSE_ERROR: i32 = 4;
|
||||
pub const EXITCODE_EXECUTION_ERROR: i32 = 5;
|
||||
pub const EXITCODE_TIMEOUT: i32 = 124;
|
||||
pub const EXITCODE_PRIV_DROP_FAILED: i32 = 201;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ExecutionError {
|
||||
#[error("Invalid command: {0}")]
|
||||
InvalidCommand(String),
|
||||
|
||||
#[error("Execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
|
||||
#[error("Permission denied: uid={euid}, gid={egid}: {reason}")]
|
||||
PermissionDenied {
|
||||
euid: u32,
|
||||
egid: u32,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("Timeout")]
|
||||
Timeout,
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(String),
|
||||
|
||||
#[error("Script not found: {0}")]
|
||||
ScriptNotFound(PathBuf),
|
||||
|
||||
#[error("Package manager not found")]
|
||||
PackageManagerNotFound,
|
||||
}
|
||||
|
||||
impl HasExitCode for ExecutionError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
ExecutionError::InvalidCommand(_) => EXITCODE_EXECUTION_ERROR,
|
||||
ExecutionError::ExecutionFailed(_) => EXITCODE_EXECUTION_ERROR,
|
||||
ExecutionError::PermissionDenied { .. } => EXITCODE_GENERAL_ERROR,
|
||||
ExecutionError::Timeout => EXITCODE_TIMEOUT,
|
||||
ExecutionError::IoError(_) => EXITCODE_IO_ERROR,
|
||||
ExecutionError::ScriptNotFound(_) => EXITCODE_INVALID_ARGS,
|
||||
ExecutionError::PackageManagerNotFound => EXITCODE_GENERAL_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DependencyError {
|
||||
#[error("No supported package manager found for {0}")]
|
||||
UnsupportedPlatform(os_info::Info),
|
||||
|
||||
#[error("Unknown package manager: {0}")]
|
||||
UnknownManager(String),
|
||||
|
||||
#[error("Failed to parse config")]
|
||||
ParseError(),
|
||||
|
||||
#[error("Stage {stage} failed: {source}")]
|
||||
StageFailed {
|
||||
stage: &'static str,
|
||||
#[source]
|
||||
source: ExecutionError,
|
||||
},
|
||||
|
||||
#[error("Custom command execution failed: {0}")]
|
||||
CustomFailed(#[from] ExecutionError),
|
||||
}
|
||||
|
||||
impl HasExitCode for DependencyError {
|
||||
fn exit_code(&self) -> i32 {
|
||||
// DependencyError doesn't need custom exit codes; use generic
|
||||
1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
use chrono::Utc;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::pm::PackageManager;
|
||||
use crate::types::{
|
||||
CgroupManager, DeltaExecutionContext, EventSender, ExecutionContext, ExecutionError,
|
||||
ExecutionEvent, ExecutionResult, ResourceLimits, StreamType,
|
||||
};
|
||||
|
||||
impl crate::types::Engine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cgroup_manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cgroup(limits: ResourceLimits, build_id: &str) -> Result<Self, String> {
|
||||
let cgroup_manager =
|
||||
CgroupManager::new(build_id).map_err(|e| format!("Failed to create cgroup: {}", e))?;
|
||||
|
||||
if let Some(mem) = limits.memory_bytes {
|
||||
cgroup_manager
|
||||
.set_memory_limit(mem)
|
||||
.map_err(|e| format!("Failed to set memory limit: {}", e))?;
|
||||
}
|
||||
|
||||
if let Some(weight) = limits.cpu_weight {
|
||||
cgroup_manager
|
||||
.set_cpu_weight(weight)
|
||||
.map_err(|e| format!("Failed to set cpu weight: {}", e))?;
|
||||
}
|
||||
|
||||
if let Some(max) = limits.cpu_max_us {
|
||||
cgroup_manager
|
||||
.set_cpu_max(max, limits.cpu_period_us)
|
||||
.map_err(|e| format!("Failed to set cpu max: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
cgroup_manager: Some(cgroup_manager),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_cgroup(&self, pid: u32) -> Result<(), ExecutionError> {
|
||||
if let Some(ref cgroup) = self.cgroup_manager {
|
||||
cgroup.add_process(pid).map_err(|e| {
|
||||
ExecutionError::IoError(format!("Failed to add process to cgroup: {}", e))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_script(
|
||||
&self,
|
||||
script_path: &PathBuf,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<ExecutionResult, ExecutionError> {
|
||||
let abs_path = if script_path.is_absolute() {
|
||||
script_path.clone()
|
||||
} else {
|
||||
ctx.working_dir.join(script_path)
|
||||
};
|
||||
|
||||
if !abs_path.exists() {
|
||||
return Err(ExecutionError::ScriptNotFound(abs_path));
|
||||
}
|
||||
|
||||
let mut command = Command::new(ctx.shell.as_str());
|
||||
command.arg("-c");
|
||||
command.arg(abs_path);
|
||||
self.execute(command, ctx, event_tx).await
|
||||
}
|
||||
|
||||
pub async fn execute_command(
|
||||
&self,
|
||||
cmd: &str,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<ExecutionResult, ExecutionError> {
|
||||
let mut command = Command::new(ctx.shell.as_str());
|
||||
command.arg("-c");
|
||||
command.arg(cmd);
|
||||
self.execute(command, ctx, event_tx).await
|
||||
}
|
||||
|
||||
pub async fn execute_command_with_delta(
|
||||
&self,
|
||||
cmd: &str,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
delta_ctx: &DeltaExecutionContext,
|
||||
) -> Result<ExecutionResult, ExecutionError> {
|
||||
let mut local_ctx = ctx.clone();
|
||||
if let Some(working_dir) = &delta_ctx.working_dir {
|
||||
log::debug!("Updating working directory to {}", working_dir.display());
|
||||
local_ctx.working_dir = working_dir.clone();
|
||||
}
|
||||
|
||||
if let Some(timeout) = &delta_ctx.timeout {
|
||||
log::debug!("Updating timeout to {}s", timeout.as_secs_f64());
|
||||
local_ctx.timeout = Some(*timeout);
|
||||
}
|
||||
|
||||
for (key, value) in &delta_ctx.env_vars {
|
||||
log::debug!("Updating envvar {} with {:?}", key, value);
|
||||
match value {
|
||||
Some(v) => local_ctx.env_vars.insert(key.clone(), v.clone()),
|
||||
None => local_ctx.env_vars.remove(key),
|
||||
};
|
||||
}
|
||||
self.execute_command(cmd, &local_ctx, event_tx).await
|
||||
}
|
||||
|
||||
fn construct_command(&self, command: &mut Command, ctx: &ExecutionContext) {
|
||||
command
|
||||
.current_dir(&ctx.working_dir)
|
||||
.envs(&ctx.env_vars)
|
||||
.env("HBW_TASKID", &ctx.task_id)
|
||||
.env("HBW_PIPELINE", &ctx.pipeline_name)
|
||||
.env("HBW_USERNAME", &ctx.username)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
command: Command,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<ExecutionResult, ExecutionError> {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
if let Some(tx) = event_tx {
|
||||
let _ = tx
|
||||
.send(ExecutionEvent::TaskStarted {
|
||||
task_id: ctx.task_id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let mut command = command;
|
||||
|
||||
self.construct_command(&mut command, ctx);
|
||||
|
||||
if ctx.dry_run {
|
||||
log::info!("[DRY_RUN] {:?}", command);
|
||||
return Ok(ExecutionResult {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let mut child = command.spawn().map_err(|e| {
|
||||
ExecutionError::InvalidCommand(format!("Failed to spawn command: {}", e))
|
||||
})?;
|
||||
|
||||
let pid = child
|
||||
.id()
|
||||
.ok_or_else(|| ExecutionError::IoError("Failed to get PID".to_string()))?;
|
||||
self.apply_cgroup(pid)?;
|
||||
|
||||
let task_id = ctx.task_id.clone();
|
||||
let (stdout_tx, mut stdout_rx) = mpsc::channel::<String>(1024);
|
||||
let (stderr_tx, mut stderr_rx) = mpsc::channel::<String>(1024);
|
||||
let mut stdout_buf = String::new();
|
||||
let mut stderr_buf = String::new();
|
||||
|
||||
let mut child_stdout = child.stdout.take();
|
||||
let mut child_stderr = child.stderr.take();
|
||||
|
||||
let tx_clone = stdout_tx.clone();
|
||||
let _task_id_clone = task_id.clone();
|
||||
let stdout_handle = tokio::spawn(async move {
|
||||
if let Some(mut stdout) = child_stdout.take() {
|
||||
let mut reader = tokio::io::BufReader::new(&mut stdout);
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let data = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
if tx_clone.send(data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tx_clone = stderr_tx.clone();
|
||||
let stderr_handle = tokio::spawn(async move {
|
||||
if let Some(mut stderr) = child_stderr.take() {
|
||||
let mut reader = tokio::io::BufReader::new(&mut stderr);
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let data = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
if tx_clone.send(data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let status = if let Some(timeout_duration) = ctx.timeout {
|
||||
match timeout(timeout_duration, child.wait()).await {
|
||||
Ok(Ok(status)) => status,
|
||||
Ok(Err(e)) => {
|
||||
return Err(ExecutionError::IoError(format!(
|
||||
"Failed to wait for process: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"Task {} timed out after {:?}, killing process",
|
||||
ctx.task_id,
|
||||
timeout_duration
|
||||
);
|
||||
if let Some(tx) = event_tx {
|
||||
let _ = tx
|
||||
.send(ExecutionEvent::TaskFailed {
|
||||
task_id: ctx.task_id.clone(),
|
||||
error: "Timeout".to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let _ = child.kill().await;
|
||||
drop(stdout_tx);
|
||||
drop(stderr_tx);
|
||||
let _ = stdout_handle.await;
|
||||
let _ = stderr_handle.await;
|
||||
while let Some(data) = stdout_rx.recv().await {
|
||||
stdout_buf.push_str(&data);
|
||||
}
|
||||
while let Some(data) = stderr_rx.recv().await {
|
||||
stderr_buf.push_str(&data);
|
||||
}
|
||||
return Err(ExecutionError::Timeout);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
child.wait().await.map_err(|e| {
|
||||
ExecutionError::IoError(format!("Failed to wait for process: {}", e))
|
||||
})?
|
||||
};
|
||||
|
||||
drop(stdout_tx);
|
||||
drop(stderr_tx);
|
||||
|
||||
let _ = stdout_handle.await;
|
||||
let _ = stderr_handle.await;
|
||||
|
||||
while let Some(data) = stdout_rx.recv().await {
|
||||
stdout_buf.push_str(&data);
|
||||
if let Some(tx) = event_tx {
|
||||
let _ = tx
|
||||
.send(ExecutionEvent::OutputChunk {
|
||||
task_id: task_id.clone(),
|
||||
stream: StreamType::Stdout,
|
||||
data: data.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
while let Some(data) = stderr_rx.recv().await {
|
||||
stderr_buf.push_str(&data);
|
||||
if let Some(tx) = event_tx {
|
||||
let _ = tx
|
||||
.send(ExecutionEvent::OutputChunk {
|
||||
task_id: task_id.clone(),
|
||||
stream: StreamType::Stderr,
|
||||
data: data.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let exit_code = status.code().unwrap_or(-1);
|
||||
let duration = start_time.elapsed();
|
||||
|
||||
let result = ExecutionResult {
|
||||
task_id: ctx.task_id.clone(),
|
||||
exit_code,
|
||||
success: exit_code == 0,
|
||||
duration,
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
resource_usage: None,
|
||||
};
|
||||
|
||||
if let Some(tx) = event_tx {
|
||||
let event = if result.success {
|
||||
ExecutionEvent::TaskCompleted {
|
||||
task_id: ctx.task_id.clone(),
|
||||
result: result.clone(),
|
||||
}
|
||||
} else {
|
||||
ExecutionEvent::TaskFailed {
|
||||
task_id: ctx.task_id.clone(),
|
||||
error: format!("Exit code: {}", exit_code),
|
||||
}
|
||||
};
|
||||
let _ = tx.send(event).await;
|
||||
}
|
||||
|
||||
if result.success {
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(ExecutionError::ExecutionFailed(format!(
|
||||
"Task {} failed with exit code {}",
|
||||
ctx.task_id, exit_code
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_dependency(
|
||||
&self,
|
||||
package: &[String],
|
||||
pkgman: &dyn PackageManager,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<ExecutionResult, ExecutionError> {
|
||||
let cmds = pkgman.install(package);
|
||||
let mut combined_result = ExecutionResult {
|
||||
task_id: ctx.task_id.clone(),
|
||||
exit_code: 0,
|
||||
success: true,
|
||||
duration: std::time::Duration::default(),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
resource_usage: None,
|
||||
};
|
||||
for cmd in cmds {
|
||||
log::debug!("Installing dependency with command: {:?}", cmd);
|
||||
let result = self.execute(cmd, ctx, event_tx).await?;
|
||||
// TODO: Aggregate results properly instead of just taking the last one
|
||||
combined_result.exit_code = result.exit_code;
|
||||
combined_result.success &= result.success;
|
||||
combined_result.duration += result.duration;
|
||||
combined_result.stdout.push_str(&result.stdout);
|
||||
combined_result.stderr.push_str(&result.stderr);
|
||||
}
|
||||
Ok(combined_result)
|
||||
}
|
||||
|
||||
pub async fn cleanup(&self) -> Result<(), String> {
|
||||
if let Some(ref cgroup) = self.cgroup_manager {
|
||||
cgroup
|
||||
.destroy()
|
||||
.map_err(|e| format!("Failed to destroy cgroup: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for crate::types::Engine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CapturedCall {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
env_vars: std::collections::HashMap<String, String>,
|
||||
working_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockExecutor {
|
||||
calls: Arc<Mutex<VecDeque<CapturedCall>>>,
|
||||
}
|
||||
|
||||
impl MockExecutor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
calls: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn capture(
|
||||
&self,
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
env_vars: std::collections::HashMap<String, String>,
|
||||
working_dir: PathBuf,
|
||||
) {
|
||||
self.calls.lock().unwrap().push_back(CapturedCall {
|
||||
program,
|
||||
args,
|
||||
env_vars,
|
||||
working_dir,
|
||||
});
|
||||
}
|
||||
|
||||
fn pop(&self) -> Option<CapturedCall> {
|
||||
self.calls.lock().unwrap().pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
struct TestExecutor {
|
||||
mock: MockExecutor,
|
||||
}
|
||||
|
||||
impl TestExecutor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
mock: MockExecutor::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_command(
|
||||
&self,
|
||||
cmd: String,
|
||||
ctx: &ExecutionContext,
|
||||
_event_tx: EventSender,
|
||||
) -> Result<ExecutionResult, ExecutionError> {
|
||||
let mut env_vars = ctx.env_vars.clone();
|
||||
env_vars.insert("WS_TASKID".to_string(), ctx.task_id.clone());
|
||||
env_vars.insert("WS_PIPELINE".to_string(), ctx.pipeline_name.clone());
|
||||
env_vars.insert("WS_USERNAME".to_string(), ctx.username.clone());
|
||||
|
||||
self.mock.capture(
|
||||
ctx.shell.clone(),
|
||||
vec!["-c".to_string(), cmd.clone()],
|
||||
env_vars,
|
||||
ctx.working_dir.clone(),
|
||||
);
|
||||
|
||||
Ok(ExecutionResult {
|
||||
task_id: ctx.task_id.clone(),
|
||||
exit_code: 0,
|
||||
success: true,
|
||||
duration: Duration::from_millis(10),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
resource_usage: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_script(
|
||||
&self,
|
||||
script_path: &PathBuf,
|
||||
ctx: &ExecutionContext,
|
||||
_event_tx: EventSender,
|
||||
) -> Result<ExecutionResult, ExecutionError> {
|
||||
let abs_path = if script_path.is_absolute() {
|
||||
script_path.clone()
|
||||
} else {
|
||||
ctx.working_dir.join(script_path)
|
||||
};
|
||||
|
||||
self.mock.capture(
|
||||
ctx.shell.clone(),
|
||||
vec!["-c".to_string(), abs_path.to_string_lossy().into_owned()],
|
||||
ctx.env_vars.clone(),
|
||||
ctx.working_dir.clone(),
|
||||
);
|
||||
|
||||
Ok(ExecutionResult {
|
||||
task_id: ctx.task_id.clone(),
|
||||
exit_code: 0,
|
||||
success: true,
|
||||
duration: Duration::from_millis(10),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
resource_usage: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_context() -> ExecutionContext {
|
||||
ExecutionContext {
|
||||
task_id: "test-task-1".to_string(),
|
||||
pipeline_name: "test-pipeline".to_string(),
|
||||
username: "testuser".to_string(),
|
||||
working_dir: PathBuf::from("/tmp"),
|
||||
env_vars: std::collections::HashMap::new(),
|
||||
timeout: Some(Duration::from_secs(30)),
|
||||
cgroup_path: None,
|
||||
shell: "bash".to_string(),
|
||||
dry_run: false,
|
||||
privileged: false,
|
||||
standalone: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_command_captures_args() {
|
||||
let executor = TestExecutor::new();
|
||||
let ctx = create_test_context();
|
||||
|
||||
let result = executor
|
||||
.execute_command("echo hello".to_string(), &ctx, None)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let call = executor.mock.pop();
|
||||
assert!(call.is_some());
|
||||
|
||||
let call = call.unwrap();
|
||||
assert_eq!(call.program, "bash");
|
||||
assert!(call.args.contains(&"-c".to_string()));
|
||||
assert!(call.args.contains(&"echo hello".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_script_captures_path() {
|
||||
let executor = TestExecutor::new();
|
||||
let ctx = create_test_context();
|
||||
|
||||
let script_path = PathBuf::from("/tmp/test_script.sh");
|
||||
let result = executor.execute_script(&script_path, &ctx, None).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
let call = executor.mock.pop().unwrap();
|
||||
assert!(call.args.contains(&"-c".to_string()));
|
||||
assert!(call.args.iter().any(|a| a.contains("test_script.sh")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_command_with_env_vars() {
|
||||
let executor = TestExecutor::new();
|
||||
let mut ctx = create_test_context();
|
||||
ctx.env_vars
|
||||
.insert("TEST_VAR".to_string(), "test_value".to_string());
|
||||
|
||||
executor
|
||||
.execute_command("echo $TEST_VAR".to_string(), &ctx, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let call = executor.mock.pop().unwrap();
|
||||
assert_eq!(
|
||||
call.env_vars.get("TEST_VAR"),
|
||||
Some(&"test_value".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_includes_ws_environment() {
|
||||
let executor = TestExecutor::new();
|
||||
let ctx = create_test_context();
|
||||
|
||||
executor
|
||||
.execute_command("ls".to_string(), &ctx, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let call = executor.mock.pop().unwrap();
|
||||
|
||||
assert!(call.env_vars.contains_key("WS_TASKID"));
|
||||
assert!(call.env_vars.contains_key("WS_PIPELINE"));
|
||||
assert!(call.env_vars.contains_key("WS_USERNAME"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_command_preserves_working_dir() {
|
||||
let executor = TestExecutor::new();
|
||||
let mut ctx = create_test_context();
|
||||
ctx.working_dir = PathBuf::from("/custom/path");
|
||||
|
||||
executor
|
||||
.execute_command("pwd".to_string(), &ctx, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let call = executor.mock.pop().unwrap();
|
||||
assert_eq!(call.working_dir, PathBuf::from("/custom/path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_local_executor_new_creates_default() {
|
||||
let executor = super::super::types::Engine::new();
|
||||
assert!(executor.cgroup_manager.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_context_default() {
|
||||
let ctx = ExecutionContext::default();
|
||||
|
||||
assert!(ctx.task_id.is_empty());
|
||||
assert_eq!(ctx.shell, "bash");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_result_success() {
|
||||
let result = ExecutionResult {
|
||||
task_id: "test".to_string(),
|
||||
exit_code: 0,
|
||||
success: true,
|
||||
duration: Duration::from_secs(1),
|
||||
stdout: "output".to_string(),
|
||||
stderr: "".to_string(),
|
||||
resource_usage: None,
|
||||
};
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(result.exit_code, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_result_failure() {
|
||||
let result = ExecutionResult {
|
||||
task_id: "test".to_string(),
|
||||
exit_code: 1,
|
||||
success: false,
|
||||
duration: Duration::from_secs(1),
|
||||
stdout: "".to_string(),
|
||||
stderr: "error".to_string(),
|
||||
resource_usage: None,
|
||||
};
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.exit_code, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execution_error_variants() {
|
||||
let _err1 = ExecutionError::InvalidCommand("test".to_string());
|
||||
let _err2 = ExecutionError::ScriptNotFound(PathBuf::from("/nonexistent"));
|
||||
let _err3 = ExecutionError::PackageManagerNotFound;
|
||||
let _err4 = ExecutionError::Timeout;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resource_limits_default() {
|
||||
let limits = ResourceLimits::default();
|
||||
|
||||
assert!(limits.memory_bytes.is_none());
|
||||
assert!(limits.cpu_weight.is_none());
|
||||
assert_eq!(limits.cpu_period_us, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
pub mod cgroups;
|
||||
pub mod command;
|
||||
pub mod error;
|
||||
pub mod executor;
|
||||
pub mod pm;
|
||||
pub mod repology;
|
||||
pub mod time;
|
||||
pub mod types;
|
||||
pub mod upm;
|
||||
|
||||
// Convenience re-exports
|
||||
pub use error::{ExecutionError, HasExitCode,
|
||||
EXITCODE_OK, EXITCODE_GENERAL_ERROR, EXITCODE_INVALID_ARGS,
|
||||
EXITCODE_IO_ERROR, EXITCODE_PARSE_ERROR, EXITCODE_EXECUTION_ERROR,
|
||||
EXITCODE_TIMEOUT, EXITCODE_PRIV_DROP_FAILED};
|
||||
pub use types::{Engine, EventSender, ExecutionContext, ExecutionEvent, ExecutionResult,
|
||||
StreamType, ResourceLimits, ResourceUsage, DeltaExecutionContext};
|
||||
pub use command::CustomCommand;
|
||||
pub use repology::RepologyEndpoint;
|
||||
pub use time::{parse_duration_to_ms, deserialize_duration_ms, deserialize_option_duration_ms};
|
||||
pub use pm::PackageManager;
|
||||
pub use upm::UserPackageManager;
|
||||
@@ -0,0 +1,86 @@
|
||||
use os_info::Info;
|
||||
use serde_yaml::Value;
|
||||
use tokio::process::Command;
|
||||
// TODO: apt
|
||||
// mod apt;
|
||||
mod pacman;
|
||||
|
||||
/// Trait for package manager implementations.
|
||||
///
|
||||
/// Provides a unified interface for generating commands to interact with
|
||||
/// system package managers across different Linux distributions.
|
||||
pub trait PackageManager {
|
||||
/// Returns the name of the package manager.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Determines whether this package manager supports the given distribution.
|
||||
fn adopt(&self, distro: &Info) -> bool;
|
||||
|
||||
/// Returns the binary name of the package manager.
|
||||
fn binary(&self) -> &'static str;
|
||||
|
||||
/// Generates commands to update the package database.
|
||||
fn update(&self) -> Vec<Command>;
|
||||
|
||||
/// Generates commands to install the specified packages.
|
||||
fn install(&self, package: &[String]) -> Vec<Command>;
|
||||
|
||||
// Check whether a package is installed
|
||||
// Currently not planned
|
||||
// Package manager should handle installed packages themselves
|
||||
// just as --needed in pacman
|
||||
// Also the PM module should generate command, not execute them
|
||||
// fn is_installed(&self, _package: &str) -> Option<bool> {unimplemented!()}
|
||||
|
||||
/// Generates commands to change the primary mirror repository.
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
|
||||
|
||||
/// Generates commands to add a custom repository configuration.
|
||||
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command>;
|
||||
}
|
||||
|
||||
/// Returns a list of all available package managers.
|
||||
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||
vec![
|
||||
// Box::new(apt::Apt),
|
||||
Box::new(pacman::Pacman),
|
||||
// Box::new(dnf::Dnf),
|
||||
]
|
||||
}
|
||||
|
||||
/// Detects the appropriate package manager for the given distribution.
|
||||
pub fn detect(distro: &os_info::Info) -> Option<Box<dyn PackageManager>> {
|
||||
all_managers().into_iter().find(|pm| pm.adopt(distro))
|
||||
}
|
||||
|
||||
/// Selects a package manager by name.
|
||||
pub fn select(manager: &str) -> Option<Box<dyn PackageManager>> {
|
||||
// TODO: allow specify PM
|
||||
// log::warn!("Manually select package manager is not recommended.");
|
||||
// log::warn!("Use at your own risk.");
|
||||
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_all_managers_returns_non_empty() {
|
||||
let managers = all_managers();
|
||||
assert!(!managers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_known_manager() {
|
||||
let pm = select("pacman");
|
||||
assert!(pm.is_some());
|
||||
assert_eq!(pm.unwrap().name(), "pacman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_unknown_manager() {
|
||||
let pm = select("nonexistent_pm");
|
||||
assert!(pm.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use crate::pm::PackageManager;
|
||||
use os_info::{Info, Version};
|
||||
use serde_yaml::Value;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Apt;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AptRepositories {
|
||||
url: String,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
impl PackageManager for Apt {
|
||||
fn name(&self) -> &'static str {
|
||||
"apt"
|
||||
}
|
||||
|
||||
fn adopt(&self, distro: &Info) -> bool {
|
||||
return false;
|
||||
// todo!();
|
||||
}
|
||||
|
||||
fn binary(&self) -> &'static str {
|
||||
"apt"
|
||||
}
|
||||
|
||||
fn update(&self) -> Command {
|
||||
let mut cmd = Command::new("apt");
|
||||
cmd.arg("update");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn install(&self, package: &[String]) -> Command {
|
||||
let mut cmd = Command::new("apt");
|
||||
cmd.arg("install");
|
||||
cmd.arg("-y"); // 自动确认
|
||||
cmd.args(package);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Command {
|
||||
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command> {
|
||||
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_command_contents() {
|
||||
let cmd = Apt.update();
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["update"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_command_contents() {
|
||||
let cmd = Apt.install(&["curl".to_string()]);
|
||||
let cmd = cmd.as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "apt");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["install", "-y", "curl"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 辅助函数:从 os_info 中提取 Codename
|
||||
fn extract_codename(info: Option<&Info>) -> Option<String> {
|
||||
let info = info?;
|
||||
|
||||
// 1. 直接获取 codename (os_info 较新版本支持)
|
||||
if let Some(codename) = info.codename() {
|
||||
if !codename.is_empty() {
|
||||
return Some(codename.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 version 中推断 (针对某些把 codename 放在 Custom 里的情况)
|
||||
match info.version() {
|
||||
Version::Custom(v) => {
|
||||
// 有些系统可能把 codename 放在这里,虽然不标准
|
||||
if !v.chars().next().map(|c| c.is_numeric()).unwrap_or(true) {
|
||||
return Some(v.clone());
|
||||
}
|
||||
}
|
||||
Version::Semantic(major, minor, _) => {
|
||||
// 对于 Ubuntu/Debian,如果没有 codename,有时可以用版本号兜底 (如 22.04)
|
||||
// 但 apt 源通常更喜欢 codename。如果实在没有,返回版本号字符串作为最后手段
|
||||
return Some(format!("{}.{}", major, minor));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 3. 如果 os_info 没提供,且是常见发行版,可以尝试根据 Type 猜测?
|
||||
// 不推荐硬编码猜测,因为版本太多。
|
||||
// 最好返回 None,让调用者决定是报错还是让用户手动指定。
|
||||
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
use crate::pm::PackageManager;
|
||||
use os_info::{Info, Type};
|
||||
use serde::Deserialize;
|
||||
use serde_yaml::Value;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Arch Linux package manager implementation.
|
||||
#[derive(Clone)]
|
||||
pub struct Pacman;
|
||||
|
||||
/// Repository configuration for pacman.
|
||||
#[derive(Deserialize)]
|
||||
struct PacmanRepo {
|
||||
name: String,
|
||||
server: String,
|
||||
cacheserver: Option<String>,
|
||||
siglevel: Option<SigLevel>,
|
||||
keyid: Option<String>,
|
||||
keypackage: Option<String>,
|
||||
}
|
||||
|
||||
/// Pacman signature level configuration.
|
||||
struct SigLevel {
|
||||
raw: String,
|
||||
}
|
||||
|
||||
impl SigLevel {
|
||||
pub fn new(raw: String) -> Self {
|
||||
for token in raw.split_whitespace() {
|
||||
if !VALID_SIGLEVEL_TOKENS.contains(&token) {
|
||||
log::warn!("Invalid siglevel token: {}", token);
|
||||
}
|
||||
}
|
||||
Self { raw }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SigLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SigLevel {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(SigLevel::new(s))
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_SIGLEVEL_TOKENS: &[&str] = &[
|
||||
// Token
|
||||
"Never",
|
||||
"Optional",
|
||||
"Required",
|
||||
"TrustedOnly",
|
||||
"TrustAll",
|
||||
// Package prefix
|
||||
"PackageNever",
|
||||
"PackageOptional",
|
||||
"PackageRequired",
|
||||
"PackageTrustedOnly",
|
||||
"PackageTrustAll",
|
||||
// Database prefix
|
||||
"DatabaseNever",
|
||||
"DatabaseOptional",
|
||||
"DatabaseRequired",
|
||||
"DatabaseTrustedOnly",
|
||||
"DatabaseTrustAll",
|
||||
];
|
||||
|
||||
impl PackageManager for Pacman {
|
||||
fn name(&self) -> &'static str {
|
||||
"pacman"
|
||||
}
|
||||
|
||||
fn adopt(&self, distro: &Info) -> bool {
|
||||
matches!(
|
||||
distro.os_type(),
|
||||
Type::Arch
|
||||
| Type::Manjaro
|
||||
| Type::EndeavourOS
|
||||
| Type::Garuda
|
||||
| Type::CachyOS
|
||||
| Type::Artix
|
||||
| Type::Mabox
|
||||
| Type::Nobara
|
||||
| Type::InstantOS
|
||||
)
|
||||
}
|
||||
|
||||
fn binary(&self) -> &'static str {
|
||||
"pacman"
|
||||
}
|
||||
|
||||
fn update(&self) -> Vec<Command> {
|
||||
let mut cmd = Command::new("pacman");
|
||||
cmd.arg("-Sy");
|
||||
vec![cmd]
|
||||
}
|
||||
|
||||
fn install(&self, package: &[String]) -> Vec<Command> {
|
||||
let mut cmd = Command::new("pacman");
|
||||
cmd.arg("-S");
|
||||
cmd.arg("--noconfirm");
|
||||
cmd.arg("--needed");
|
||||
cmd.args(package);
|
||||
vec![cmd]
|
||||
}
|
||||
|
||||
fn change_mirror(&self, repo: &str, _osinfo: Option<&Info>) -> Vec<Command> {
|
||||
let mirrorlist_path = "/etc/pacman.d/mirrorlist";
|
||||
|
||||
let content = format!(
|
||||
"# Generated by WSExecutor-PM\n\
|
||||
# Date: $(date)\n\
|
||||
# Original content overwritten.\n\
|
||||
\n\
|
||||
Server = {}\n",
|
||||
repo
|
||||
);
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(format!(
|
||||
"cat > {} << 'PACMAN_EOF'\n{}\nPACMAN_EOF",
|
||||
mirrorlist_path, content
|
||||
));
|
||||
|
||||
let mut keyring_cmd = Command::new("sh");
|
||||
keyring_cmd.arg("-c").arg("pacman-key --init");
|
||||
vec![cmd, keyring_cmd]
|
||||
}
|
||||
|
||||
fn add_repository(&self, repo: &Value, _osinfo: Option<&Info>) -> Vec<Command> {
|
||||
let repos: Vec<PacmanRepo> = match serde_yaml::from_value(repo.clone()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse pacman repository: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let pacman_conf = "/etc/pacman.conf";
|
||||
let mut content = String::with_capacity(512);
|
||||
content.push_str("# Modified by HBW baker\n");
|
||||
let mut cmds = vec![];
|
||||
for repo in &repos {
|
||||
// Install keyring
|
||||
if repo.keyid.is_some() && repo.keypackage.is_some() {
|
||||
log::error!(
|
||||
"Both keyid and keypackage are set for repository {}",
|
||||
repo.name
|
||||
);
|
||||
log::warn!("Ignoring...");
|
||||
continue;
|
||||
}
|
||||
if let Some(keyid) = &repo.keyid {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(format!("pacman-key --recv-keys {}", keyid));
|
||||
cmds.push(cmd);
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(format!("pacman-key --lsign-key {}", keyid));
|
||||
cmds.push(cmd);
|
||||
} else if let Some(keypackage) = &repo.keypackage {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(format!("pacman -Sy {} --needed --noconfirm", keypackage));
|
||||
cmds.push(cmd);
|
||||
}
|
||||
|
||||
// Configure repository
|
||||
let mut block = format!("[{}]\n", &repo.name);
|
||||
block.push_str(&format!("Server = {}\n", &repo.server));
|
||||
|
||||
if let Some(cacheserver) = &repo.cacheserver {
|
||||
block.push_str(&format!("CacheServer = {}\n", cacheserver));
|
||||
}
|
||||
if let Some(siglevel) = &repo.siglevel {
|
||||
block.push_str(&format!("SigLevel = {}\n", siglevel));
|
||||
}
|
||||
content.push_str(&block);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(format!(
|
||||
"cat >> {} << 'PACMAN_EOF'\n{}\nPACMAN_EOF",
|
||||
pacman_conf, content
|
||||
));
|
||||
cmds.insert(0, cmd);
|
||||
|
||||
cmds
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_name() {
|
||||
let pacman = Pacman;
|
||||
assert_eq!(pacman.name(), "pacman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binary() {
|
||||
let pacman = Pacman;
|
||||
assert_eq!(pacman.binary(), "pacman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adopt_true_for_arch_based_distros() {
|
||||
let pacman = Pacman;
|
||||
let arch_distros = [
|
||||
Type::Arch,
|
||||
Type::Manjaro,
|
||||
Type::EndeavourOS,
|
||||
Type::Garuda,
|
||||
Type::CachyOS,
|
||||
Type::Artix,
|
||||
Type::Mabox,
|
||||
Type::Nobara,
|
||||
Type::InstantOS,
|
||||
];
|
||||
for distro in arch_distros {
|
||||
let info = Info::with_type(distro);
|
||||
assert!(
|
||||
pacman.adopt(&info),
|
||||
"Pacman should adopt distro: {:?}",
|
||||
distro
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adopt_false_for_non_arch_distros() {
|
||||
let pacman = Pacman;
|
||||
let non_arch_distros = [
|
||||
Type::Ubuntu,
|
||||
Type::Debian,
|
||||
Type::Fedora,
|
||||
Type::CentOS,
|
||||
Type::RedHatEnterprise,
|
||||
Type::openSUSE,
|
||||
Type::Gentoo,
|
||||
Type::FreeBSD,
|
||||
Type::Macos,
|
||||
Type::Windows,
|
||||
];
|
||||
for distro in non_arch_distros {
|
||||
let info = Info::with_type(distro);
|
||||
assert!(
|
||||
!pacman.adopt(&info),
|
||||
"Pacman should NOT adopt distro: {:?}",
|
||||
distro
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_command_contents() {
|
||||
let cmds = Pacman.update();
|
||||
let cmd = cmds[0].as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "pacman");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["-Sy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_command_contents() {
|
||||
let cmds = Pacman.install(&["curl".to_string(), "wget".to_string()]);
|
||||
let cmd = cmds[0].as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "pacman");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["-S", "--noconfirm", "--needed", "curl", "wget"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_single_package() {
|
||||
let cmds = Pacman.install(&["package".to_string()]);
|
||||
let cmd = cmds[0].as_std();
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["-S", "--noconfirm", "--needed", "package"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_siglevel_valid_tokens() {
|
||||
let siglevel = SigLevel::new("Never".to_string());
|
||||
assert_eq!(siglevel.to_string(), "Never");
|
||||
|
||||
let siglevel = SigLevel::new("Optional Required".to_string());
|
||||
assert_eq!(siglevel.to_string(), "Optional Required");
|
||||
|
||||
let siglevel = SigLevel::new("PackageOptional DatabaseRequired".to_string());
|
||||
assert_eq!(siglevel.to_string(), "PackageOptional DatabaseRequired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_siglevel_with_invalid_tokens() {
|
||||
// Invalid tokens are logged but still accepted
|
||||
let siglevel = SigLevel::new("Never InvalidToken".to_string());
|
||||
assert_eq!(siglevel.to_string(), "Never InvalidToken");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_siglevel_deserialize() {
|
||||
let yaml = "Never";
|
||||
let siglevel: SigLevel = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(siglevel.to_string(), "Never");
|
||||
|
||||
let yaml = "PackageOptional DatabaseRequired";
|
||||
let siglevel: SigLevel = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(siglevel.to_string(), "PackageOptional DatabaseRequired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_siglevel_all_valid_tokens() {
|
||||
let all_tokens = VALID_SIGLEVEL_TOKENS.join(" ");
|
||||
let siglevel = SigLevel::new(all_tokens.clone());
|
||||
assert_eq!(siglevel.to_string(), all_tokens);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_change_mirror_command() {
|
||||
let cmds = Pacman.change_mirror("https://mirror.example.com/archlinux", None);
|
||||
let cmd = cmds[0].as_std();
|
||||
assert_eq!(cmd.get_program().to_string_lossy(), "sh");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let full_arg = args.join(" ");
|
||||
assert!(full_arg.contains("cat > /etc/pacman.d/mirrorlist"));
|
||||
assert!(full_arg.contains("https://mirror.example.com/archlinux"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_single_repo() {
|
||||
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("customrepo".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo.example.com".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let cmds = Pacman.add_repository(&repo_yaml, None);
|
||||
// Should have at least one command (the one to append to pacman.conf)
|
||||
assert!(!cmds.is_empty());
|
||||
let last_cmd = cmds.last().unwrap().as_std();
|
||||
let args: Vec<String> = last_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let full_arg = args.join(" ");
|
||||
assert!(full_arg.contains("cat >> /etc/pacman.conf"));
|
||||
assert!(full_arg.contains("[customrepo]"));
|
||||
assert!(full_arg.contains("https://repo.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_with_siglevel() {
|
||||
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("customrepo".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo.example.com".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("siglevel".to_string()),
|
||||
serde_yaml::Value::String("PackageOptional".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let cmds = Pacman.add_repository(&repo_yaml, None);
|
||||
let last_cmd = cmds.last().unwrap().as_std();
|
||||
let args: Vec<String> = last_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let full_arg = args.join(" ");
|
||||
assert!(full_arg.contains("SigLevel = PackageOptional"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_with_keyid() {
|
||||
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("customrepo".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo.example.com".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("keyid".to_string()),
|
||||
serde_yaml::Value::String("ABCDEF123456".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let cmds = Pacman.add_repository(&repo_yaml, None);
|
||||
// Should have 3 commands: recv-keys, lsign-key, and append to conf
|
||||
assert_eq!(cmds.len(), 3);
|
||||
|
||||
let recv_cmd = cmds[1].as_std();
|
||||
let recv_args: Vec<String> = recv_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
recv_args
|
||||
.join(" ")
|
||||
.contains("pacman-key --recv-keys ABCDEF123456")
|
||||
);
|
||||
|
||||
let lsign_cmd = cmds[2].as_std();
|
||||
let lsign_args: Vec<String> = lsign_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
lsign_args
|
||||
.join(" ")
|
||||
.contains("pacman-key --lsign-key ABCDEF123456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_with_keypackage() {
|
||||
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("customrepo".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo.example.com".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("keypackage".to_string()),
|
||||
serde_yaml::Value::String("gnupg".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let cmds = Pacman.add_repository(&repo_yaml, None);
|
||||
// Should have 2 commands: install keypackage and append to conf
|
||||
assert_eq!(cmds.len(), 2);
|
||||
|
||||
let install_cmd = cmds[1].as_std();
|
||||
let install_args: Vec<String> = install_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
install_args
|
||||
.join(" ")
|
||||
.contains("pacman -Sy gnupg --needed --noconfirm")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_with_cacheserver() {
|
||||
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("customrepo".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo.example.com".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("cacheserver".to_string()),
|
||||
serde_yaml::Value::String("https://cache.example.com".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let cmds = Pacman.add_repository(&repo_yaml, None);
|
||||
let last_cmd = cmds.last().unwrap().as_std();
|
||||
let args: Vec<String> = last_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let full_arg = args.join(" ");
|
||||
assert!(full_arg.contains("CacheServer = https://cache.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_multiple_repos() {
|
||||
let repos_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("repo1".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo1.example.com".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("repo2".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo2.example.com".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let cmds = Pacman.add_repository(&repos_yaml, None);
|
||||
let last_cmd = cmds.last().unwrap().as_std();
|
||||
let args: Vec<String> = last_cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let full_arg = args.join(" ");
|
||||
assert!(full_arg.contains("[repo1]"));
|
||||
assert!(full_arg.contains("[repo2]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_both_keyid_and_keypackage_logs_error() {
|
||||
let repo_yaml = serde_yaml::from_value(serde_yaml::Value::Sequence(vec![
|
||||
serde_yaml::Value::Mapping(
|
||||
[
|
||||
(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String("customrepo".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("server".to_string()),
|
||||
serde_yaml::Value::String("https://repo.example.com".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("keyid".to_string()),
|
||||
serde_yaml::Value::String("ABCDEF123456".to_string()),
|
||||
),
|
||||
(
|
||||
serde_yaml::Value::String("keypackage".to_string()),
|
||||
serde_yaml::Value::String("gnupg".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let cmds = Pacman.add_repository(&repo_yaml, None);
|
||||
// Should have no key-related commands when both are set (error case)
|
||||
assert!(cmds.is_empty() || cmds.len() == 1); // Only the conf append cmd if any
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_invalid_yaml_returns_empty() {
|
||||
let invalid_yaml = serde_yaml::Value::String("invalid: [yaml".to_string());
|
||||
let cmds = Pacman.add_repository(&invalid_yaml, None);
|
||||
assert!(cmds.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
pub mod local;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Repology endpoint configuration for package name resolution.
|
||||
///
|
||||
/// # Deserialize
|
||||
///
|
||||
/// Accepts: `disabled`, `none`, `local`, `remote`, `server`, `default`, or empty string.
|
||||
/// Empty string and `default` both map to `RepologyEndpoint::Default`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `serde` error for unknown variant names.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub enum RepologyEndpoint {
|
||||
/// Default Repology endpoint.
|
||||
#[default]
|
||||
Default,
|
||||
/// Repology integration disabled.
|
||||
Disabled,
|
||||
/// No Repology endpoint configured.
|
||||
None,
|
||||
/// Local Repology instance.
|
||||
Local,
|
||||
/// Remote Repology service.
|
||||
Remote,
|
||||
/// Server-based Repology endpoint.
|
||||
Server,
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for RepologyEndpoint {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::{self, Visitor};
|
||||
use std::fmt;
|
||||
|
||||
struct RepologyEndpointVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for RepologyEndpointVisitor {
|
||||
type Value = RepologyEndpoint;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("disabled, none, local, remote, server, or default")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
match v {
|
||||
"disabled" => Ok(RepologyEndpoint::Disabled),
|
||||
"none" => Ok(RepologyEndpoint::None),
|
||||
"local" => Ok(RepologyEndpoint::Local),
|
||||
"remote" => Ok(RepologyEndpoint::Remote),
|
||||
"server" => Ok(RepologyEndpoint::Server),
|
||||
"default" | "" => Ok(RepologyEndpoint::Default),
|
||||
_ => Err(de::Error::unknown_variant(
|
||||
v,
|
||||
&["disabled", "none", "local", "remote", "server", "default"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(RepologyEndpointVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for RepologyEndpoint {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
RepologyEndpoint::Default => serializer.serialize_str("default"),
|
||||
RepologyEndpoint::Disabled => serializer.serialize_str("disabled"),
|
||||
RepologyEndpoint::None => serializer.serialize_str("none"),
|
||||
RepologyEndpoint::Local => serializer.serialize_str("local"),
|
||||
RepologyEndpoint::Remote => serializer.serialize_str("remote"),
|
||||
RepologyEndpoint::Server => serializer.serialize_str("server"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RepologyError {
|
||||
Unimplemented,
|
||||
RemoteNotSupported,
|
||||
}
|
||||
|
||||
pub async fn resolve_package_names(
|
||||
packages: &[String],
|
||||
mapped: bool,
|
||||
endpoint: &RepologyEndpoint,
|
||||
target_pm: &str,
|
||||
) -> Result<HashMap<String, Vec<String>>, RepologyError> {
|
||||
let mut result = HashMap::new();
|
||||
|
||||
if packages.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if mapped {
|
||||
result.insert(target_pm.to_string(), packages.to_vec());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
match endpoint {
|
||||
RepologyEndpoint::Local | RepologyEndpoint::Default => {
|
||||
let resolved = local::resolve(packages, target_pm).await;
|
||||
if !resolved.is_empty() {
|
||||
result.insert(target_pm.to_string(), resolved);
|
||||
}
|
||||
}
|
||||
RepologyEndpoint::None => {
|
||||
result.insert(target_pm.to_string(), packages.to_vec());
|
||||
}
|
||||
RepologyEndpoint::Disabled => {}
|
||||
RepologyEndpoint::Remote | RepologyEndpoint::Server => {
|
||||
return Err(RepologyError::Unimplemented);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_disabled() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("disabled").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_none() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("none").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_local() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("local").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_remote() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("remote").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_server() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("server").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Server);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_default() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("default").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_default_string() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("default").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_variant_error() {
|
||||
let result: Result<RepologyEndpoint, _> = serde_yaml::from_str("unknown");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_roundtrip() {
|
||||
for ep in [
|
||||
RepologyEndpoint::Default,
|
||||
RepologyEndpoint::Disabled,
|
||||
RepologyEndpoint::None,
|
||||
RepologyEndpoint::Local,
|
||||
RepologyEndpoint::Remote,
|
||||
RepologyEndpoint::Server,
|
||||
] {
|
||||
let yaml = serde_yaml::to_string(&ep).unwrap();
|
||||
let deserialized: RepologyEndpoint = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized, ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
type PmName = &'static str;
|
||||
type PkgName = &'static str;
|
||||
|
||||
fn pkg(packages: &[(PmName, PkgName)]) -> Vec<(PmName, PkgName)> {
|
||||
packages.to_vec()
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref MAPPING: HashMap<PkgName, Vec<(PmName, PkgName)>> = {
|
||||
let mut m = HashMap::new();
|
||||
|
||||
m.insert("rustup", pkg(&[
|
||||
("pacman", "rustup"),
|
||||
("apt", "rustup"),
|
||||
("dnf", "rustup"),
|
||||
]));
|
||||
|
||||
m.insert("cargo", pkg(&[
|
||||
("pacman", "cargo"),
|
||||
("apt", "cargo"),
|
||||
("dnf", "cargo"),
|
||||
]));
|
||||
|
||||
m.insert("rustc", pkg(&[
|
||||
("pacman", "rustc"),
|
||||
("apt", "rustc"),
|
||||
("dnf", "rustc"),
|
||||
]));
|
||||
|
||||
m.insert("go", pkg(&[
|
||||
("pacman", "go"),
|
||||
("apt", "golang-go"),
|
||||
("dnf", "golang"),
|
||||
]));
|
||||
|
||||
m.insert("gofmt", pkg(&[
|
||||
("pacman", "go"),
|
||||
("apt", "golang-go"),
|
||||
("dnf", "golang"),
|
||||
]));
|
||||
|
||||
m.insert("nodejs", pkg(&[
|
||||
("pacman", "nodejs"),
|
||||
("apt", "nodejs"),
|
||||
("dnf", "nodejs"),
|
||||
]));
|
||||
|
||||
m.insert("npm", pkg(&[
|
||||
("pacman", "npm"),
|
||||
("apt", "npm"),
|
||||
("dnf", "npm"),
|
||||
]));
|
||||
|
||||
m.insert("yarn", pkg(&[
|
||||
("pacman", "yarn"),
|
||||
("apt", "yarn"),
|
||||
("dnf", "yarn"),
|
||||
]));
|
||||
|
||||
m.insert("pnpm", pkg(&[
|
||||
("pacman", "pnpm"),
|
||||
("apt", "pnpm"),
|
||||
("dnf", "pnpm"),
|
||||
]));
|
||||
|
||||
m.insert("python", pkg(&[
|
||||
("pacman", "python"),
|
||||
("apt", "python3"),
|
||||
("dnf", "python3"),
|
||||
]));
|
||||
|
||||
m.insert("pip", pkg(&[
|
||||
("pacman", "python-pip"),
|
||||
("apt", "python3-pip"),
|
||||
("dnf", "python3-pip"),
|
||||
]));
|
||||
|
||||
m
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves package names to the target package manager.
|
||||
///
|
||||
/// Looks up each package in the internal mapping and returns the corresponding
|
||||
/// package names for the specified package manager.
|
||||
pub async fn resolve(packages: &[String], target_pm: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for pkg in packages {
|
||||
if let Some(mappings) = MAPPING.get(pkg.as_str()) {
|
||||
for (pm, name) in mappings {
|
||||
if *pm == target_pm {
|
||||
result.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_rustup_to_pacman() {
|
||||
let packages = vec!["rustup".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert_eq!(resolved, vec!["rustup"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_go_to_apt() {
|
||||
let packages = vec!["go".to_string()];
|
||||
let resolved = resolve(&packages, "apt").await;
|
||||
assert_eq!(resolved, vec!["golang-go"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_go_to_pacman() {
|
||||
let packages = vec!["go".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert_eq!(resolved, vec!["go"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_python_to_dnf() {
|
||||
let packages = vec!["python".to_string()];
|
||||
let resolved = resolve(&packages, "dnf").await;
|
||||
assert_eq!(resolved, vec!["python3"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_multiple_packages() {
|
||||
let packages = vec!["rustup".to_string(), "cargo".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert_eq!(resolved, vec!["rustup", "cargo"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_unknown_package() {
|
||||
let packages = vec!["nonexistent_package".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_unknown_package_manager() {
|
||||
let packages = vec!["rustup".to_string()];
|
||||
let resolved = resolve(&packages, "unknown_pm").await;
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use serde::{Deserializer, de::Visitor};
|
||||
|
||||
/// Maximum duration in milliseconds that can be represented as i32::MAX.
|
||||
pub const INT_MAX_MS: u64 = i32::MAX as u64;
|
||||
|
||||
/// Parses a duration string to milliseconds.
|
||||
///
|
||||
/// Accepts formats like "30s", "5m", "1h", "1d", etc.
|
||||
/// Warns when year ("y") or month ("mon") units are used, treating them
|
||||
/// as 365 days and 30 days respectively.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the input is invalid or exceeds [`INT_MAX_MS`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use workshop_engine::time::parse_duration_to_ms;
|
||||
/// assert_eq!(parse_duration_to_ms("30s").unwrap(), 30_000);
|
||||
/// assert_eq!(parse_duration_to_ms("5m").unwrap(), 300_000);
|
||||
/// assert_eq!(parse_duration_to_ms("1h").unwrap(), 3_600_000);
|
||||
/// ```
|
||||
pub fn parse_duration_to_ms(input: &str) -> Result<u64, String> {
|
||||
let lowered = input.to_lowercase();
|
||||
if lowered.contains('y') || lowered.contains("mon") {
|
||||
log::warn!(
|
||||
"Duration '{}' uses year/month unit. Assuming 1y=365d, 1mon=30d. \
|
||||
This is approximate and may not match calendar time.",
|
||||
input
|
||||
);
|
||||
}
|
||||
|
||||
let duration =
|
||||
duration_str::parse(input).map_err(|e| format!("Invalid duration '{}': {}", input, e))?;
|
||||
|
||||
let ms = duration.as_millis() as u64;
|
||||
|
||||
if ms > INT_MAX_MS {
|
||||
return Err(format!(
|
||||
"Duration '{}' ({} ms) exceeds maximum {} ms",
|
||||
input, ms, INT_MAX_MS
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ms)
|
||||
}
|
||||
|
||||
/// Serde visitor for deserializing a duration in milliseconds.
|
||||
///
|
||||
/// Accepts either:
|
||||
/// - A number (treated as seconds and converted to milliseconds)
|
||||
/// - A duration string like "30s", "5m", "1h"
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for negative values, invalid strings, or values exceeding [`INT_MAX_MS`].
|
||||
pub fn deserialize_duration_ms<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct DurationMsVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DurationMsVisitor {
|
||||
type Value = u64;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a number (seconds) or duration string like '30s', '5m'")
|
||||
}
|
||||
|
||||
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
if value < 0.0 {
|
||||
return Err(E::custom("Duration cannot be negative"));
|
||||
}
|
||||
let ms = (value * 1000.0).round() as u64;
|
||||
if ms > INT_MAX_MS {
|
||||
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
|
||||
}
|
||||
Ok(ms)
|
||||
}
|
||||
|
||||
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
let ms = value * 1000;
|
||||
if ms > INT_MAX_MS {
|
||||
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
|
||||
}
|
||||
Ok(ms)
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
parse_duration_to_ms(value).map_err(E::custom)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(DurationMsVisitor)
|
||||
}
|
||||
|
||||
/// Serde visitor for deserializing an optional duration in milliseconds.
|
||||
///
|
||||
/// Accepts `null`, a number (seconds), or a duration string.
|
||||
/// Returns `None` for null or unit values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for negative values, invalid strings, or values exceeding [`INT_MAX_MS`].
|
||||
pub fn deserialize_option_duration_ms<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct OptionDurationMsVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for OptionDurationMsVisitor {
|
||||
type Value = Option<u64>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("null, a number (seconds), or duration string")
|
||||
}
|
||||
|
||||
fn visit_none<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_unit<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
if value < 0.0 {
|
||||
return Err(E::custom("Duration cannot be negative"));
|
||||
}
|
||||
let ms = (value * 1000.0).round() as u64;
|
||||
if ms > INT_MAX_MS {
|
||||
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
|
||||
}
|
||||
Ok(Some(ms))
|
||||
}
|
||||
|
||||
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
let ms = value * 1000;
|
||||
if ms > INT_MAX_MS {
|
||||
return Err(E::custom(format!("Duration {}s exceeds maximum", value)));
|
||||
}
|
||||
Ok(Some(ms))
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
parse_duration_to_ms(value).map(Some).map_err(E::custom)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_option(OptionDurationMsVisitor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
// parse_duration_to_ms tests
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_seconds() {
|
||||
assert_eq!(parse_duration_to_ms("30s").unwrap(), 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_minutes() {
|
||||
assert_eq!(parse_duration_to_ms("5m").unwrap(), 300_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_hours() {
|
||||
assert_eq!(parse_duration_to_ms("1h").unwrap(), 3_600_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_days() {
|
||||
assert_eq!(parse_duration_to_ms("2d").unwrap(), 172_800_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_milliseconds() {
|
||||
assert_eq!(parse_duration_to_ms("500ms").unwrap(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_invalid_error() {
|
||||
let result = parse_duration_to_ms("invalid");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid duration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_overflow_error() {
|
||||
// Value exceeding INT_MAX_MS
|
||||
let result = parse_duration_to_ms("2147483648s"); // > i32::MAX ms
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("exceeds maximum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_negative_error() {
|
||||
let result = parse_duration_to_ms("-30s");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// Serde deserialization tests for deserialize_duration_ms
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct DurationStruct {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_duration_from_number() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct Test {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
val: u64,
|
||||
}
|
||||
let json = r#"{"val": 30}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.val, 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_duration_from_string() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct Test {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
val: u64,
|
||||
}
|
||||
let json = r#"{"val": "30s"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.val, 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_duration_negative_error() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Test {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
val: u64,
|
||||
}
|
||||
let json = r#"{"val": -30}"#;
|
||||
let result = serde_json::from_str::<Test>(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// Serde deserialization tests for deserialize_option_duration_ms
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct OptionalDurationStruct {
|
||||
#[serde(deserialize_with = "deserialize_option_duration_ms")]
|
||||
duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_option_duration_null() {
|
||||
let json = r#"{"duration_ms": null}"#;
|
||||
let t: OptionalDurationStruct = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.duration_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_option_duration_from_number() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct DirectOpt {
|
||||
duration_ms: Option<u64>,
|
||||
}
|
||||
let json = r#"{"duration_ms": 60}"#;
|
||||
let t: DirectOpt = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.duration_ms, Some(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_option_duration_from_string() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct DirectOptStr {
|
||||
duration_ms: Option<String>,
|
||||
}
|
||||
let json = r#"{"duration_ms": "2m"}"#;
|
||||
let t: DirectOptStr = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.duration_ms, Some("2m".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub use crate::cgroups::CgroupManager;
|
||||
pub use crate::error::{EXITCODE_EXECUTION_ERROR, EXITCODE_IO_ERROR, ExecutionError};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourceUsage {
|
||||
pub cpu_time_secs: f64,
|
||||
pub max_memory_kb: u64,
|
||||
pub io_read_kb: u64,
|
||||
pub io_write_kb: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecutionContext {
|
||||
pub task_id: String,
|
||||
pub pipeline_name: String,
|
||||
pub username: String,
|
||||
pub working_dir: PathBuf,
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub timeout: Option<Duration>,
|
||||
pub cgroup_path: Option<PathBuf>,
|
||||
pub shell: String,
|
||||
pub dry_run: bool,
|
||||
pub privileged: bool,
|
||||
pub standalone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeltaExecutionContext {
|
||||
pub working_dir: Option<PathBuf>,
|
||||
pub env_vars: HashMap<String, Option<String>>,
|
||||
pub timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for ExecutionContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
task_id: String::new(),
|
||||
pipeline_name: String::new(),
|
||||
username: String::new(),
|
||||
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
env_vars: HashMap::new(),
|
||||
timeout: None,
|
||||
cgroup_path: None,
|
||||
shell: String::from("bash"),
|
||||
dry_run: false,
|
||||
privileged: false,
|
||||
standalone: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ResourceLimits {
|
||||
pub memory_bytes: Option<u64>,
|
||||
pub cpu_weight: Option<u64>,
|
||||
pub cpu_max_us: Option<u64>,
|
||||
pub cpu_period_us: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExecutionResult {
|
||||
pub task_id: String,
|
||||
pub exit_code: i32,
|
||||
pub success: bool,
|
||||
pub duration: Duration,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub resource_usage: Option<ResourceUsage>,
|
||||
}
|
||||
|
||||
impl Default for ExecutionResult {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
task_id: String::new(),
|
||||
exit_code: 0,
|
||||
success: true,
|
||||
duration: Duration::default(),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
resource_usage: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum StreamType {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ExecutionEvent {
|
||||
TaskStarted {
|
||||
task_id: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
OutputChunk {
|
||||
task_id: String,
|
||||
stream: StreamType,
|
||||
data: String,
|
||||
},
|
||||
TaskCompleted {
|
||||
task_id: String,
|
||||
result: ExecutionResult,
|
||||
},
|
||||
TaskFailed {
|
||||
task_id: String,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub type EventSender = Option<mpsc::Sender<ExecutionEvent>>;
|
||||
|
||||
pub struct Engine {
|
||||
pub cgroup_manager: Option<CgroupManager>,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::types::EventSender;
|
||||
use async_trait::async_trait;
|
||||
use serde_yaml::Value;
|
||||
mod custom;
|
||||
|
||||
use crate::types::ExecutionContext;
|
||||
use crate::error::DependencyError;
|
||||
|
||||
/// System dependencies required by a user package manager.
|
||||
pub struct UPMSysDeps {
|
||||
/// List of package names to install via the system package manager.
|
||||
pub packages: Vec<String>,
|
||||
/// Whether package names have been mapped to the target package manager.
|
||||
pub mapped: bool,
|
||||
}
|
||||
|
||||
impl UPMSysDeps {
|
||||
/// Creates a new UPMSysDeps instance.
|
||||
pub fn new(packages: Vec<String>, mapped: bool) -> Self {
|
||||
Self { packages, mapped }
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for user package manager implementations.
|
||||
///
|
||||
/// User package managers (Nix, Guix, etc.) operate at the user level
|
||||
/// and may require system-level dependencies to be installed first.
|
||||
#[async_trait]
|
||||
pub trait UserPackageManager {
|
||||
/// Returns the name of the user package manager.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Returns system dependencies required by this user package manager.
|
||||
fn system_dependency(&self, config: &Value) -> UPMSysDeps;
|
||||
|
||||
/// Main function of User Package Manager.
|
||||
async fn main(
|
||||
&self,
|
||||
config: &Value,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<(), DependencyError>;
|
||||
}
|
||||
|
||||
/// Returns a list of all available user package managers.
|
||||
pub fn all_managers() -> Vec<Box<dyn UserPackageManager>> {
|
||||
vec![Box::new(custom::Custom)]
|
||||
}
|
||||
|
||||
/// Selects a user package manager by name.
|
||||
pub fn select(manager: &str) -> Option<Box<dyn UserPackageManager>> {
|
||||
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_all_managers_returns_non_empty() {
|
||||
let managers = all_managers();
|
||||
assert!(!managers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_known_manager() {
|
||||
let pm = select("custom");
|
||||
assert!(pm.is_some());
|
||||
assert_eq!(pm.unwrap().name(), "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_unknown_manager() {
|
||||
let pm = select("nonexistent_upm");
|
||||
assert!(pm.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::types::Engine;
|
||||
use crate::types::DeltaExecutionContext;
|
||||
use crate::upm::UPMSysDeps;
|
||||
use crate::error::DependencyError;
|
||||
use crate::command::CustomCommand;
|
||||
|
||||
use super::UserPackageManager;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Custom;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
struct CustomConfig(Vec<CustomCommand>);
|
||||
|
||||
#[async_trait]
|
||||
impl UserPackageManager for Custom {
|
||||
fn name(&self) -> &'static str {
|
||||
"custom"
|
||||
}
|
||||
|
||||
fn system_dependency(&self, _config: &serde_yaml::Value) -> super::UPMSysDeps {
|
||||
UPMSysDeps::new(vec![], true)
|
||||
}
|
||||
|
||||
async fn main(
|
||||
&self,
|
||||
config: &serde_yaml::Value,
|
||||
ctx: &crate::types::ExecutionContext,
|
||||
event_tx: &crate::types::EventSender,
|
||||
) -> Result<(), DependencyError> {
|
||||
let custom_config: CustomConfig = match serde_yaml::from_value(config.clone()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Unable to parse user.custom: {}", e);
|
||||
return Err(DependencyError::ParseError());
|
||||
}
|
||||
};
|
||||
let engine = Engine::new();
|
||||
for (index, cmd) in custom_config.0.iter().enumerate() {
|
||||
let deltactx = DeltaExecutionContext {
|
||||
working_dir: cmd.working_dir.as_ref().map(PathBuf::from),
|
||||
env_vars: cmd.environment.clone().unwrap_or_default(),
|
||||
timeout: Some(std::time::Duration::from_millis(cmd.timeout_ms)),
|
||||
};
|
||||
log::info!("Executing custom command #{}: {}.", index, cmd.name);
|
||||
let result = engine
|
||||
.execute_command_with_delta(&cmd.command, ctx, event_tx, &deltactx)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
log::error!("Custom command {} failed: {:?}", index, e);
|
||||
return Err(DependencyError::CustomFailed(e));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user