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:
Catty Steve
2026-04-26 23:07:08 +08:00
parent e8ce2eec9e
commit 8fd807f174
75 changed files with 1791 additions and 1044 deletions
+885
View File
@@ -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"
+21
View File
@@ -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"
+115
View File
@@ -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();
}
}
+95
View File
@@ -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);
}
}
+88
View File
@@ -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
}
}
+659
View File
@@ -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);
}
}
+22
View File
@@ -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;
+86
View File
@@ -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());
}
}
+115
View File
@@ -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
}
+628
View File
@@ -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());
}
}
+197
View File
@@ -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);
}
}
}
+154
View File
@@ -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());
}
}
+311
View File
@@ -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()));
}
}
+122
View File
@@ -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>,
}
+77
View File
@@ -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());
}
}
+61
View File
@@ -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(())
}
}