diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ca6a3f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,17 @@ +on: + release: + types: [created] + +jobs: + release: + name: Build Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Compile and release + uses: rust-build/rust-build.action@v1.4.4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SRC_DIR: "bin" + with: + RUSTTARGET: x86_64-unknown-linux-musl diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..655c5f5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Rust + +on: + push: + branches: ["main", "develop"] + pull_request: + types: [opened, reopened, synchronize] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3db9c3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.DS_Store +.cargo +Makefile diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0195069 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1549 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "actions-runner" +version = "0.1.0" +dependencies = [ + "anyhow", + "builder", + "camino", + "chrono", + "clap", + "config", + "fern", + "initialiser", + "log", + "manager", + "runner", + "thiserror", +] + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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 = "anstream" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +dependencies = [ + "backtrace", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" + +[[package]] +name = "builder" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "log", + "thiserror", + "util", +] + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "camino" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.48.5", +] + +[[package]] +name = "clap" +version = "4.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "config" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fern" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" +dependencies = [ + "log", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-io", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "github" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "nix", + "reqwest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "h2" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3d0e0f38255e7fa3cf31335b3a56f05febd18025f4db5ef7a0cfb4f8da651f" + +[[package]] +name = "http" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "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 = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "initialiser" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "config", + "github", + "log", + "nix", + "serde", + "serde_json", + "thiserror", + "util", +] + +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "js-sys" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "manager" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "config", + "github", + "log", + "mockall", + "rand", + "serde", + "serde_json", + "signal-hook", + "thiserror", + "util", +] + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "reqwest" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.48.0", +] + +[[package]] +name = "runner" +version = "0.1.0" +dependencies = [ + "anyhow", + "github", + "log", + "thiserror", + "util", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustls" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + +[[package]] +name = "thiserror" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "util" +version = "0.1.0" +dependencies = [ + "camino", + "cfg-if", + "config", + "lazy_static", + "log", + "mockall", + "thiserror", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" + +[[package]] +name = "web-sys" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winnow" +version = "0.5.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cf47b659b318dccbd69cc4797a39ae128f533dce7902a1096044d1967b9c16" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b3932d3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[workspace] +resolver = "2" +members = [ + "bin", + "initialiser", + "manager", + "builder", + "runner", + "github", + "util", + "config" +] + +[workspace.dependencies] +anyhow = { version = "*", features = ["backtrace"] } +thiserror = "*" +clap = { version = "*", features = ["derive"] } +serde = { version = "*", features = ["derive"] } +serde_json = "*" +toml = "*" +log = "*" +fern = "*" +chrono = "*" +nix = { version = "*", features = ["fs", "mount"] } +reqwest = { version = "*", default-features = false, features = ["json", "blocking", "rustls-tls"] } +rand = "*" +mockall = "*" +cfg-if = "*" +camino = { version = "*", features = ["serde1"] } +lazy_static = "*" + +[profile.dev] +split-debuginfo = "unpacked" + +[profile.release] +strip = true # Automatically strip symbols from the binary. +opt-level = "z" # Optimize for size. +lto = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aadf0b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 the Probes developers. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1fc428 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Actions Runner + +Actions Runner (`actions-runner`) is a tool that helps build and run a (group of) Firecracker VMs that +are to be used with GitHub Actions. + +## Requirements + +For the builder the following packages are required: + +* `qemu` +* `docker` +* A `Dockerfile` to build the rootfs image, this image needs a `runner` user with a home directory at `/home/runner` and a version of the GitHub actions runner installed in `/home/runner/`. If `Docker` is installed _within_ the container, make sure that `docker` is in the `runner` user's group and that the `runner` user has access to the docker socket. + +For the runner the following packages are required: + +* `firecracker` +* A linux kernel (we use: [5.10]( https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/x86_64/kernels/vmlinux-5.10.bin)) +* A rootfs image, created by the builder +* A configuration file, see below for an example +* A GitHub Personal Access Token with the `repo` scope, so we can add the runner to the organization. + + +## Building an image + +The builder takes a Dockerfile and creates a new rootfs image. It copies +the `actions-runner` binary into the rootfs image and sets it as the entrypoint, so we can control how the server is setup (networking, caching, etc.). + +For example: + +```bash +./actions-runner build Dockerfile result.img +``` + +This builds a new rootfs image from the Dockerfile and saves it as `result.img`. The `--debug` flag is optional and will print debug information. + + +## Running a VM + +The runner takes a configuration file and runs one or more groups of VMs. Each group of VMs is defined by a `group` in the configuration file. + +The sizes are always in Gigabytes. + +```toml +network_interface="enp0s31f6" +run_path="/srv" +github_pat="ghp_1234567890" +github_org="matsimitsu" + +[[roles]] +name="your-project" +rootfs_image="/home/runner/containers/your-project-2024-01-20.img" +kernel_image="/home/runner/kernels/vmlinux-5.10.bin" +cpus=4 +memory_size=1 +cache_size=1 +instance_count=4 +cache_paths=["docker:/var/lib/docker"] +labels=["your-project-2024-01-20"] +``` + +You can now run the VMs with the following command: + +```bash +./actions-runner run --config config.toml +``` + + +### Debugging a VM + +You can run a `debug` instance of a role by setting the `--debug-role` flag. +For example, to start a single vm in debug mode for the config file above, +you can run: + +```bash +./actions-runner runner --config config.toml --debug-role your-project --log-level debug +``` + +This will start a single VM with the `your-project` role, and binds +stdin/stdout to the terminal, meaning you can see and manipulate the VM directly. + +You can run this command while the runner is running, and it will start a new, separate VM. + + +## Contributing + +This project was created to run our own GitHub actions, we're not necessarily +looking into expanding this into a full-fledged standalione project. However, we're happy to accept contributions that fit our use case. + +We'll most likely not accept contributions that would make this project more +generic, as we're not looking to maintain a generic actions runner. + +If you have doubts, please open an issue and we can discuss it! + +Please follow our [Contributing guide][contributing-guide] in our +documentation and follow our [Code of Conduct][coc]. + +Running `cargo fmt` before contributing changes would reduce diffs for future +contributions. + +Also, we would be very happy to send you Stroopwafles. Have look at everyone +we send a package to so far on our [Stroopwafles page][waffles-page]. diff --git a/bin/Cargo.toml b/bin/Cargo.toml new file mode 100644 index 0000000..b890902 --- /dev/null +++ b/bin/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "actions-runner" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true +clap.workspace = true +fern.workspace = true +chrono.workspace = true +log.workspace = true +camino.workspace = true + +[dependencies.manager] +path = "../manager" + +[dependencies.builder] +path = "../builder" + +[dependencies.initialiser] +path = "../initialiser" + +[dependencies.runner] +path = "../runner" + +[dependencies.config] +path = "../config" diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000..bf30e6a --- /dev/null +++ b/bin/README.md @@ -0,0 +1,17 @@ +# Runner + +Responsible for running the firecracker instances. + +## Usage + +```bash +actions-runner run --config /path/to/config.toml +``` + +## Debug a role + +`--debug_role` automatically sets the log level to `debug`. + +```bash +actions-runner run --config /path/to/config.toml --debug_role +``` diff --git a/bin/src/main.rs b/bin/src/main.rs new file mode 100644 index 0000000..1fecab7 --- /dev/null +++ b/bin/src/main.rs @@ -0,0 +1,150 @@ +use anyhow::Result; +use builder::Builder; +use camino::Utf8PathBuf; +use chrono::Utc; +use clap::{Parser, Subcommand}; +use config::manager::ManagerConfig; +use log; +use manager::Manager; +use std::env; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +struct Args { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Runs the manager, which will start the instances and manage them + Run(ManageArgs), + + /// Build new image from a Dockerfile + Build(BuildArgs), +} + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct BuildArgs { + dockerfile: Utf8PathBuf, + + output: Utf8PathBuf, + + #[arg(short, long)] + size: Option, + + #[arg(short, long)] + log_level: Option, +} + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct ManageArgs { + #[arg(short, long)] + config: Utf8PathBuf, + + #[arg(short, long)] + debug_role: Option, + + #[arg(short, long, default_value_t = 201)] + instance_index: u8, + + #[arg(short, long)] + log_level: Option, +} + +fn main() -> Result { + match env::args().next() { + Some(path) if path.ends_with("actions-init") => { + init(&path)?; + } + Some(path) if path.ends_with("actions-run") => { + run()?; + } + _ => { + let args = Args::parse(); + match args.command { + Commands::Build(args) => build(args)?, + Commands::Run(args) => manage(args)?, + } + } + } + + Ok(ExitCode::SUCCESS) +} + +fn run() -> Result<()> { + setup_logger(log::LevelFilter::Debug).expect("Could not setup logger"); + + let runner = runner::Runner::new(); + runner.run()?; + + Ok(()) +} + +fn init(path: &str) -> Result<()> { + setup_logger(log::LevelFilter::Debug).expect("Could not setup logger"); + + let initialiser = initialiser::Initialiser::new(path); + initialiser.run()?; + + Ok(()) +} + +fn build(args: BuildArgs) -> Result<()> { + setup_logger(args.log_level.unwrap_or(log::LevelFilter::Info)).expect("Could not setup logger"); + + let builder = Builder::new(&args.dockerfile, &args.output, args.size)?; + builder.build()?; + + Ok(()) +} + +fn manage(args: ManageArgs) -> Result<()> { + setup_logger(args.log_level.unwrap_or(log::LevelFilter::Info)).expect("Could not setup logger"); + + let config = + ManagerConfig::from_file(&args.config.clone().into()).expect("Could not load config"); + let mut manager = Manager::new(config); + + match args.debug_role { + Some(role) => { + log::info!( + "Debugging role: `{}` with index: '{}' from config: `{}`", + role, + args.instance_index, + args.config + ); + manager + .debug(&role, args.instance_index) + .expect("Could not debug instance"); + } + None => { + log::info!("Starting with config: {}", args.config); + manager.setup()?; + manager.run()?; + } + } + + Ok(()) +} + +fn setup_logger(log_level: log::LevelFilter) -> Result<(), fern::InitError> { + fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "[{} {} {}] {}", + Utc::now().to_rfc3339(), + record.level(), + record.target(), + message + )) + }) + .level(log_level) + .chain(std::io::stdout()) + .apply()?; + Ok(()) +} diff --git a/builder/Cargo.toml b/builder/Cargo.toml new file mode 100644 index 0000000..828c47e --- /dev/null +++ b/builder/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "builder" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true +log.workspace = true +camino.workspace = true + +[dependencies.util] +path = "../util" diff --git a/builder/README.md b/builder/README.md new file mode 100644 index 0000000..7c54d53 --- /dev/null +++ b/builder/README.md @@ -0,0 +1,21 @@ +# Builder + +Responsible for converting a Dockerfile to a rootfs image. + +## Usage + +```bash +actions-runner build /path/to/Dockerfile /path/to/result.img +``` + +Use `--log-level debug` to see debug information. + + +## What does it do? + +The builder runs the `Docker build` command to generate a container id. +It then uses the container id to export the container's filesystem to a tarball. + +We generate a new empty rootfs image with `qemu-img`, format it as ext4, and mount it, so we can add files to the image. + +We then extract the Docker tarball into the rootfs image, copy ourselves into it, and set ourselves as the entrypoint. diff --git a/builder/src/docker.rs b/builder/src/docker.rs new file mode 100644 index 0000000..25beeb5 --- /dev/null +++ b/builder/src/docker.rs @@ -0,0 +1,47 @@ +use crate::BuildError; +use anyhow::Result; +use camino::Utf8PathBuf; +use std::process::Command; +use util::{exec, exec_spawn}; + +pub fn build_image(source_path: &Utf8PathBuf) -> Result { + let output = + exec(Command::new("docker").args(["build", "-q", "--file", source_path.as_str(), "."]))?; + + let trimmed_line = output + .stdout + .into_iter() + .filter(|c| !c.is_ascii_whitespace()) + .collect::>(); + + Ok(String::from_utf8_lossy(&trimmed_line).to_string()) +} + +pub fn create_container(image_id: &str) -> Result { + let output = exec(Command::new("docker").args(["run", "-td", image_id]))?; + + // Get the container id from the output, ignoring whitespace and newlines + let trimmed_line = output + .stdout + .into_iter() + .filter(|c| !c.is_ascii_whitespace()) + .collect::>(); + + Ok(String::from_utf8_lossy(&trimmed_line).to_string()) +} + +pub fn export_container(container_id: &str, mount_path: &Utf8PathBuf) -> Result<(), BuildError> { + let output = exec_spawn( + Command::new("docker") + .args(["cp", &format!("{}:/", container_id), "-"]) + .stdout(std::process::Stdio::piped()), + )?; + + let _ = exec( + Command::new("tar") + .args(["xf", "-", "-C", mount_path.as_str()]) + .stdin(output.stdout.unwrap()), + )?; + + Ok(()) +} diff --git a/builder/src/lib.rs b/builder/src/lib.rs new file mode 100644 index 0000000..d36c80d --- /dev/null +++ b/builder/src/lib.rs @@ -0,0 +1,136 @@ +use camino::Utf8PathBuf; +use log::*; +use std::env; +use thiserror::Error; +use util::{fs, mount}; + +pub mod docker; +pub mod qemu; + +const MOUNT_PATH: &str = "/tmp/actions-runner/mnt"; +const WORK_PATH: &str = "/tmp/actions-runner"; +const DEFAULT_IMAGE_SIZE_GB: u8 = 10; + +#[derive(Error, Debug)] +pub enum BuildError { + #[error("IO error: {:?}", .0)] + IO(#[from] std::io::Error), + #[error("Command execution error: {:?}", self)] + Command(#[from] util::CommandExecutionError), + #[error("Utf8 conversion error: {:?}", self)] + Utf8(#[from] std::string::FromUtf8Error), + #[error("Utf8 conversion error: {:?}", self)] + PathBuf(#[from] camino::FromPathBufError), + #[error("Docker build error: {:?}", .0)] + DockerBuild(String), + #[error("Qemu build error: {stderr}", stderr = "0.stderr")] + QemuBuild(util::CommandExecutionError), + #[error("Could not find our own binary: {:?}", .0)] + SelfNotFound(std::io::Error), +} + +pub struct Builder { + own_path: Utf8PathBuf, + source_path: Utf8PathBuf, + output_path: Utf8PathBuf, + work_path: Utf8PathBuf, + mount_path: Utf8PathBuf, + size_gb: u8, +} + +impl Builder { + pub fn new( + source_path: &Utf8PathBuf, + output_path: &Utf8PathBuf, + size_gb: Option, + ) -> Result { + let current_dir: Utf8PathBuf = env::current_dir()?.try_into()?; + let own_path: Utf8PathBuf = env::current_exe()?.try_into()?; + + Ok(Self { + own_path: own_path.into(), + source_path: [¤t_dir, source_path].iter().collect(), + output_path: [¤t_dir, output_path].iter().collect(), + size_gb: size_gb.unwrap_or(DEFAULT_IMAGE_SIZE_GB), + work_path: WORK_PATH.into(), + mount_path: MOUNT_PATH.into(), + }) + } + + pub fn build_inner(&self) -> Result<(), BuildError> { + // Build the given Dockerfile into an image, and get the image ID + debug!("Building image from: '{}'", self.source_path); + let image_id = docker::build_image(&self.source_path)?; + + // Get the container ID from the image ID + let container_id = docker::create_container(&image_id)?; + + // Create the mount directory, we use this to copy the data into an image + debug!("Creating directory on: '{}'", &self.mount_path); + fs::mkdir_p(&self.mount_path)?; + + // Create the rootfs image, and mount it. + debug!( + "Creating rootfs in: '{}' with size: {}GB", + &self.work_path, self.size_gb + ); + let image_path = qemu::create_fs(&self.work_path, self.size_gb)?; + + // Create a filesystem on the image + debug!("Creating ext4 filesystem on: {}", &image_path); + fs::mkfs_ext4(&image_path)?; + + // Mount the image so we can add files to it + debug!( + "Mounting root image: '{}' on: {}", + &image_path, self.mount_path + ); + mount::mount_image(&image_path, &self.mount_path)?; + + // Copy the data from the container into the image + debug!( + "Exporting container: '{}' to: {}", + &container_id, self.mount_path + ); + docker::export_container(&container_id, &self.mount_path)?; + + // Copy our own binary into the image + debug!( + "Copy ourselves from '{}' to '{}'", + &self.own_path, + self.mount_path.join("sbin/actions-runner-init") + ); + fs::copy_sparse( + &self.own_path, + &self.mount_path.join("sbin/actions-runner-init"), + )?; + + debug!("Unmounting the image from: '{}'", &self.mount_path); + // Unmount the image + mount::unmount(&self.mount_path)?; + + debug!( + "Copying image from: '{}' to: '{}'", + &image_path, &self.output_path + ); + fs::copy_sparse(&image_path, &self.output_path)?; + + // Cleanup + fs::rm_rf(&self.work_path)?; + + debug!("Done!"); + Ok(()) + } + + // Runs the inner build, and cleans up after failure + pub fn build(&self) -> Result<(), BuildError> { + match self.build_inner() { + Ok(res) => Ok(res), + Err(e) => { + // let _ = mount::unmount(&self.mount_path); + //let _ = fs::rm_rf(&self.work_path); + Err(e) + } + } + } +} diff --git a/builder/src/qemu.rs b/builder/src/qemu.rs new file mode 100644 index 0000000..893e3c9 --- /dev/null +++ b/builder/src/qemu.rs @@ -0,0 +1,20 @@ +use crate::BuildError; +use camino::Utf8PathBuf; +use std::process::Command; +use util::exec; + +pub const IMAGE_NAME: &str = "image.ext4"; + +pub fn create_fs(path: &Utf8PathBuf, size_gb: u8) -> Result { + let image_path = path.join(IMAGE_NAME); + + exec(Command::new("qemu-img").args([ + "create", + "-f", + "raw", + image_path.as_str(), + &format!("{}G", size_gb), + ]))?; + + Ok(image_path) +} diff --git a/config/Cargo.toml b/config/Cargo.toml new file mode 100644 index 0000000..7e4a680 --- /dev/null +++ b/config/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "config" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow.workspace = true +serde.workspace = true +toml.workspace = true +thiserror.workspace = true +camino.workspace = true diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..58d31ae --- /dev/null +++ b/config/README.md @@ -0,0 +1,4 @@ +# Config + +This crate contains the configuration for both the Manager, +and Firecracker. diff --git a/config/src/firecracker.rs b/config/src/firecracker.rs new file mode 100644 index 0000000..0a41a39 --- /dev/null +++ b/config/src/firecracker.rs @@ -0,0 +1,40 @@ +use camino::Utf8PathBuf; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct FirecrackerConfig { + pub boot_source: BootSource, + pub drives: Vec, + pub network_interfaces: Vec, + pub machine_config: MachineConfig, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct BootSource { + pub kernel_image_path: String, + pub boot_args: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Drive { + pub drive_id: String, + pub path_on_host: Utf8PathBuf, + pub is_root_device: bool, + pub is_read_only: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_type: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct NetworkInterface { + pub iface_id: String, + pub guest_mac: String, + pub host_dev_name: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MachineConfig { + pub vcpu_count: u32, + pub mem_size_mib: u32, +} diff --git a/config/src/lib.rs b/config/src/lib.rs new file mode 100644 index 0000000..af37b77 --- /dev/null +++ b/config/src/lib.rs @@ -0,0 +1,19 @@ +use thiserror::*; +use toml; + +pub mod firecracker; +pub mod manager; + +pub const DEFAULT_BOOT_ARGS: &str = + "random.trust_cpu=on reboot=k panic=1 pci=off overlay_root=vdb init=/sbin/actions-init"; +pub const NETWORK_MAGIC_MAC_START: &str = "06:00"; +pub const NETWORK_MASK_SHORT: u8 = 30; +pub const NETWORK_MAX_ALLOCATIONS: u8 = 200; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("IO error: {:?}", self)] + Io(#[from] std::io::Error), + #[error("Config TOML error: {:?}", self)] + Toml(#[from] toml::de::Error), +} diff --git a/config/src/manager.rs b/config/src/manager.rs new file mode 100644 index 0000000..fad3832 --- /dev/null +++ b/config/src/manager.rs @@ -0,0 +1,89 @@ +use anyhow::Result; +use camino::Utf8PathBuf; +use serde::Deserialize; +use toml; + +#[derive(Deserialize, Debug, Clone)] +pub struct ManagerConfig { + pub network_interface: String, + pub run_path: Utf8PathBuf, + pub roles: Vec, + pub github_org: String, + pub github_pat: String, +} + +impl ManagerConfig { + pub fn from_file(path: &Utf8PathBuf) -> Result { + let config_str = std::fs::read_to_string(path)?; + let config = toml::from_str(&config_str)?; + Ok(config) + } +} + +const fn _default_overlay_size() -> u32 { + 10 // 10GB +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Role { + pub name: String, + pub rootfs_image: Utf8PathBuf, + pub kernel_image: Utf8PathBuf, + pub kernel_cmdline: Option, + pub cpus: u32, + pub memory_size: u32, + pub cache_size: u32, + #[serde(default = "_default_overlay_size")] + pub overlay_size: u32, + pub instance_count: u8, + #[serde(default)] + pub cache_paths: Vec, + #[serde(default)] + pub labels: Vec, +} + +impl Role { + pub fn slug(&self) -> String { + self.name.to_lowercase() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_from_file() { + let config = ManagerConfig::from_file(&helpers::test_fixtures_file("config.toml")) + .expect("Could not load config"); + + assert_eq!(&config.network_interface, "eth0"); + } + + mod helpers { + use camino::Utf8PathBuf; + use std::env::current_dir; + + pub fn test_fixtures_path() -> Utf8PathBuf { + let current_dir: Utf8PathBuf = current_dir() + .expect("Could not get current dir") + .try_into() + .expect("Invalid path"); + + // Check for two levels of nesting + if current_dir.join("../test_fixtures").exists() { + current_dir.join("../test_fixtures") + } else { + current_dir.join("../../test_fixtures") + } + } + + pub fn test_fixtures_file(file: &str) -> Utf8PathBuf { + let path = test_fixtures_path().join(file); + if !path.exists() { + panic!("Test fixture file {:?} does not exist", path); + } + path + } + } +} diff --git a/example/Dockerfile b/example/Dockerfile new file mode 100644 index 0000000..249c22f --- /dev/null +++ b/example/Dockerfile @@ -0,0 +1,107 @@ +FROM ubuntu:latest as base + +# udev is needed for booting a "real" VM, setting up the ttyS0 console properly +# kmod is needed for modprobing modules +# systemd is needed for running as PID 1 as /sbin/init +# ca-certificates, gnupg, lsb-release are needed for docker +RUN apt update && apt install -y \ + curl \ + wget \ + dbus \ + kmod \ + tar \ + util-linux \ + iproute2 \ + iputils-ping \ + net-tools \ + openssh-server \ + ca-certificates \ + gnupg \ + lsb-release \ + systemd \ + sudo \ + bash \ + udev \ + parallel \ + bridge-utils \ + iputils-ping \ + net-tools \ + locales \ + unzip xvfb libnss3-dev libgdk-pixbuf2.0-dev libgtk-3-dev libxss-dev libasound2 \ + git \ + jq \ + nano \ + libyaml-dev \ + build-essential + +# Do tzdata separately to avoid interactive prompts +RUN DEBIAN_FRONTEND=noninteractive apt-get install -y tzdata + +# Update locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +# Install and enable docker +RUN mkdir -m 0755 -p /etc/apt/keyrings && \ + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null && \ + apt update && \ + apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \ + apt clean && \ + rm -rf /var/lib/apt/lists/* && \ + systemctl enable docker && \ + update-alternatives --set iptables /usr/sbin/iptables-legacy + +# Set the root password for logging in through the VM's ttyS0 console +RUN echo "root:root" | chpasswd + +RUN groupadd user && \ + useradd -m -d /home/runner -s /bin/bash -g user -G docker runner + +# Actions uses this for precompiled binaries +RUN mkdir -p /opt/hostedtoolcache \ + && chown -R runner:user /opt/hostedtoolcache \ + && chmod g+rwx /opt/hostedtoolcache + +WORKDIR /home/runner + +RUN GITHUB_RUNNER_VERSION=$(curl --silent "https://api.github.com/repos/actions/runner/releases/latest" | jq -r '.tag_name[1:]') \ + && curl -Ls https://github.com/actions/runner/releases/download/v${GITHUB_RUNNER_VERSION}/actions-runner-linux-x64-$GITHUB_RUNNER_VERSION.tar.gz | tar zx \ + && chown -R runner:user /home/runner + +FROM base as runner + +RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor | tee /etc/apt/trusted.gpg.d/google.gpg >/dev/null \ + && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list + +RUN apt update && apt install -y \ + libjemalloc-dev \ + protobuf-compiler \ + libyaml-dev \ + libreadline-dev \ + ${CHROME_VERSION:-google-chrome-stable} \ + && rm /etc/apt/sources.list.d/google-chrome.list \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* + +ARG CHROME_DRIVER_VERSION +RUN if [ ! -z "$CHROME_DRIVER_VERSION" ]; \ + then CHROME_DRIVER_URL=https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/$CHROME_DRIVER_VERSION/linux64/chromedriver-linux64.zip ; \ + else echo "Geting ChromeDriver latest version from https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_" \ + && CHROME_MAJOR_VERSION=$(google-chrome --version | sed -E "s/.* ([0-9]+)(\.[0-9]+){3}.*/\1/") \ + && CHROME_DRIVER_VERSION=$(wget -qO- https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_MAJOR_VERSION} | sed 's/\r$//') \ + && CHROME_DRIVER_URL=https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/$CHROME_DRIVER_VERSION/linux64/chromedriver-linux64.zip ; \ + fi \ + && echo "Using ChromeDriver from: "$CHROME_DRIVER_URL \ + && echo "Using ChromeDriver version: "$CHROME_DRIVER_VERSION \ + && wget --no-verbose -O /tmp/chromedriver_linux64.zip $CHROME_DRIVER_URL \ + && rm -rf /opt/selenium/chromedriver \ + && unzip /tmp/chromedriver_linux64.zip -d /opt/selenium \ + && rm /tmp/chromedriver_linux64.zip \ + && mv /opt/selenium/chromedriver-linux64/chromedriver /opt/selenium/chromedriver-$CHROME_DRIVER_VERSION \ + && chmod 755 /opt/selenium/chromedriver-$CHROME_DRIVER_VERSION \ + && ln -fs /opt/selenium/chromedriver-$CHROME_DRIVER_VERSION /usr/bin/chromedriver + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --default-toolchain 1.74.0 -y diff --git a/example/config.toml b/example/config.toml new file mode 100644 index 0000000..5ae3549 --- /dev/null +++ b/example/config.toml @@ -0,0 +1,16 @@ +network_interface="enp0s31f6" +run_path="/srv" +github_pat="ghp_1234567890" +github_org="matsimitsu" + +[[roles]] +name="your-project" +rootfs_image="/home/runner/containers/your-project-2024-01-20/rootfs.img" +kernel_image="/home/runner/containers/your-project-2024-01-20/vmlinux.bin" +cpus=4 +memory_size=1 +cache_size=1 +overlay_size=1 +instance_count=4 +cache_paths=["docker:/var/lib/docker"] +labels=["your-project", "your-project-2024-01-20"] diff --git a/github/Cargo.toml b/github/Cargo.toml new file mode 100644 index 0000000..1f7e088 --- /dev/null +++ b/github/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "github" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +reqwest.workspace = true +anyhow.workspace = true +nix.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +log.workspace = true diff --git a/github/README.md b/github/README.md new file mode 100644 index 0000000..fd500a9 --- /dev/null +++ b/github/README.md @@ -0,0 +1,8 @@ +# GitHub + +This crate is responsible for getting a Runner token from the provided +GitHub Personal Access Token (PAT), it is used to authenticate the Runner +with GitHub. + +We pass this token through the `kernel boot args` in the Manager, and pick it up +inside the VM with our custom entrypoint. diff --git a/github/src/lib.rs b/github/src/lib.rs new file mode 100644 index 0000000..d5c87d3 --- /dev/null +++ b/github/src/lib.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use reqwest; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct RegistrationTokenResult { + pub token: String, + pub expires_at: String, +} + +#[derive(Debug, Clone)] +pub struct GitHub { + pub org: String, + pub pat: String, + client: reqwest::blocking::Client, +} + +impl GitHub { + pub fn new(org: &str, pat: &str) -> Self { + GitHub { + org: org.to_string(), + pat: pat.to_string(), + client: reqwest::blocking::Client::new(), + } + } + + pub fn registration_token(&self) -> Result { + let registration_token_result = self + .client + .post(&format!( + "https://api.github.com/orgs/{}/actions/runners/registration-token", + self.org + )) + .header("Authorization", format!("Bearer {}", self.pat)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "actions-runner") + .send()? + .json::()?; + Ok(registration_token_result.token) + } + + pub fn remove_runner(&self, runner_name: &str) -> Result<()> { + self.client + .post(&format!( + "https://api.github.com/orgs/{}/actions/runners/remove-token", + self.org + )) + .header("Authorization", format!("Bearer {}", self.pat)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "actions-runner") + .json(&serde_json::json!({ + "runner_name": runner_name + })) + .send()?; + Ok(()) + } +} diff --git a/initialiser/Cargo.toml b/initialiser/Cargo.toml new file mode 100644 index 0000000..ca4ad67 --- /dev/null +++ b/initialiser/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "initialiser" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow.workspace = true +nix.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +log.workspace = true +camino.workspace = true + +[dependencies.util] +path = "../util" + +[dependencies.github] +path = "../github" + +[dependencies.config] +path = "../config" diff --git a/initialiser/README.md b/initialiser/README.md new file mode 100644 index 0000000..5532b8d --- /dev/null +++ b/initialiser/README.md @@ -0,0 +1,20 @@ +# Initialiser + +This crate is responsible for initialising a newly booted VM. + +It: + +- Sets up the network interface, so the VM can communicate with the outside world. +- Sets up the persisted Cache disk, so we can persist packages/docker images between runs. +- Sets up a runner systemd service, so we can run the GitHub Action runner after the boot process is complete. + +We don't start the runner from this init script, but we set it as a new (one-shot) service. This becase we want to only start the GitHub Action runner after other processes have finished booting (e.g. Docker). + +The service has a `ExecStopPost` command that will reboot the VM after the runner has finished running the GitHub Action. (this signals to Firecracker to stop the VM and boot a fresh one). + +You might notice we only have one binary file and it performs several jobs. +The user-facing jobs are separated into different subcommands, (`actions-runner build`, `actions-runner run`), but the internal jobs rely on the name of the binary. + +To start this initialiser, make sure it's renamed (or copied) from `actions-runner` to `actions-init`. + +The same goes for the GitHub Actions runner process. This initialiser copies itself into `/sbin/actions-run`. diff --git a/initialiser/src/cache.rs b/initialiser/src/cache.rs new file mode 100644 index 0000000..380a288 --- /dev/null +++ b/initialiser/src/cache.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use std::fs::{set_permissions, Permissions}; +use std::os::unix::fs::{symlink, PermissionsExt}; +use thiserror::Error; +use util::{fs, mount, CommandExecutionError}; + +const CACHE_PATH: &str = "/cache"; + +#[derive(Error, Debug)] +pub enum CacheError { + #[error("IO error: {:?}", self)] + Io(#[from] std::io::Error), + #[error("Could not mount: {:?}", self)] + Mount(#[from] CommandExecutionError), +} + +pub fn setup_cache(cache_str: &str) -> Result<(), CacheError> { + fs::mkdir_p(CACHE_PATH)?; + mount::mount_ext4("/dev/vdb", CACHE_PATH)?; + set_permissions(CACHE_PATH, Permissions::from_mode(0o777))?; + + let cache_links = cache_str.split(","); + for cache_link in cache_links { + let cache_link = cache_link.trim(); + let cache_parts: Vec<&str> = cache_link.split(":").collect(); + if cache_parts.len() != 2 { + return Err(CacheError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Invalid cache link: {}", cache_link), + ))); + } + let cache_root = format!("{}/{}", CACHE_PATH, cache_parts[0]); + let cache_path = cache_parts[1]; + + fs::mkdir_p(&cache_root)?; + symlink(&cache_root, &cache_path)?; + } + + Ok(()) +} diff --git a/initialiser/src/lib.rs b/initialiser/src/lib.rs new file mode 100644 index 0000000..94220f0 --- /dev/null +++ b/initialiser/src/lib.rs @@ -0,0 +1,100 @@ +use anyhow::Result; +use camino::{Utf8Path, Utf8PathBuf}; +use log::*; +use std::env; +use std::fs::copy; +use std::os::unix::process::CommandExt; +use std::process::Command; + +mod cache; +mod network; +mod service; + +pub struct Initialiser { + pub own_path: Utf8PathBuf, +} + +impl Initialiser { + pub fn new(path: impl AsRef) -> Self { + let path = path.as_ref(); + + Initialiser { + own_path: Utf8PathBuf::from(path), + } + } + + pub fn run(&self) -> Result<()> { + debug!("Setup network"); + match network::setup_network() { + Ok(Some(interface)) => info!( + "Network setup complete: {} {} > {}", + interface.ifname, interface.own_address, interface.host_address + ), + Ok(None) => info!("No magic address found, skipping network setup"), + Err(e) => { + error!("Network setup failed: {}\n\n", e); + return Err(e.into()); + } + } + + debug!("Setup dns"); + match network::setup_dns() { + Ok(_) => info!("DNS setup complete"), + Err(e) => { + error!("DNS setup failed: {}", e); + return Err(e.into()); + } + } + + debug!("Setup cache"); + match env::var("cache_paths") { + Ok(cache_paths) => { + match cache::setup_cache(&cache_paths) { + Ok(_) => info!("Cache setup complete"), + Err(e) => { + error!("Cache setup failed: {}", e); + return Err(e.into()); + } + }; + } + Err(_) => { + info!("No 'cache' kernel arg found, skipping cache setup"); + } + } + + debug!("Setup actions-runner"); + match ( + env::var("github_org"), + env::var("github_token"), + env::var("github_runner_name"), + env::var("github_runner_labels"), + ) { + ( + Ok(github_org), + Ok(github_token), + Ok(github_runner_name), + Ok(github_runner_labels), + ) => { + debug!("Copy self to actions-runner"); + copy(&self.own_path, &Utf8PathBuf::from("/sbin/actions-run"))?; + + debug!("Set runner init script"); + service::setup_service( + &github_org, + &github_token, + &github_runner_name, + &github_runner_labels, + )?; + + debug!("Symlink init script to start at boot"); + service::enable_service()?; + } + _ => { + info!("No 'github_org', 'github_token' or 'github_runner_name' kernel arg found, skipping actions-runner setup"); + } + } + + Command::new("/sbin/init").exec(); + Ok(()) + } +} diff --git a/initialiser/src/network.rs b/initialiser/src/network.rs new file mode 100644 index 0000000..e3e4b91 --- /dev/null +++ b/initialiser/src/network.rs @@ -0,0 +1,101 @@ +use config::{NETWORK_MAGIC_MAC_START, NETWORK_MASK_SHORT}; +use serde::Deserialize; +use serde_json; +use std::{fs::write, net::Ipv4Addr, process::Command}; +use thiserror::Error; +use util::{exec, network::mac_to_ip}; + +const RESOLV_CONF: &str = "nameserver 1.1.1.1\noptions use-vc\n"; +const RESOLV_CONF_PATH: &str = "/etc/resolv.conf"; + +#[derive(Error, Debug)] +pub enum NetworkError { + #[error("IO error: {:?}", self)] + Io(#[from] std::io::Error), + #[error("UTF8 error: {:?}", self)] + Utf8(#[from] std::string::FromUtf8Error), + #[error("Command error: {:?}", self)] + Command(#[from] util::CommandExecutionError), + #[error("JSON error: {:?}", self)] + Json(#[from] serde_json::Error), + #[error("No interface found with mac address starting with 06:00")] + NoInterfaceFound, + #[error("No valid IP adddress in mac address: {}", .0)] + MacToIpError(#[from] util::network::MacToIpError), +} + +#[derive(Deserialize, Debug)] +pub struct NetworkAddress { + pub ifname: String, + #[serde(rename = "address")] + pub mac: String, +} + +#[derive(Deserialize, Debug)] +pub struct NetworkInterface { + pub ifname: String, + pub mac: String, + pub own_address: Ipv4Addr, + pub host_address: Ipv4Addr, +} + +pub fn get_interfaces() -> Result, NetworkError> { + let command_output = exec(Command::new("ip").arg("-j").arg("address"))?; + let output_string = String::from_utf8_lossy(&command_output.stdout).to_string(); + let interfaces: Vec = serde_json::from_str(&output_string)?; + + Ok(interfaces) +} + +pub fn get_magic_address() -> Result { + for interface in get_interfaces()? { + if interface.mac.starts_with(NETWORK_MAGIC_MAC_START) { + return Ok(interface); + } + } + Err(NetworkError::NoInterfaceFound.into()) +} + +pub fn setup_network() -> Result, NetworkError> { + let magic_address = match get_magic_address() { + Ok(i) => i, + Err(_) => return Ok(None), + }; + let own_ip = mac_to_ip(&magic_address.mac)?; + let host_ip = Ipv4Addr::new( + own_ip.octets()[0], + own_ip.octets()[1], + own_ip.octets()[2], + 1, + ); + + exec(Command::new("ip").args([ + "addr", + "add", + &format!("{}/{}", own_ip.to_string(), NETWORK_MASK_SHORT), + "dev", + &magic_address.ifname, + ]))?; + + exec(Command::new("ip").args(["link", "set", &magic_address.ifname, "up"]))?; + + exec(Command::new("ip").args([ + "route", + "add", + "default", + "via", + host_ip.to_string().as_str(), + ]))?; + + Ok(Some(NetworkInterface { + ifname: magic_address.ifname.to_string(), + mac: magic_address.mac.to_string(), + own_address: own_ip, + host_address: host_ip, + })) +} + +pub fn setup_dns() -> Result<(), NetworkError> { + write(RESOLV_CONF_PATH, RESOLV_CONF)?; + Ok(()) +} diff --git a/initialiser/src/service.rs b/initialiser/src/service.rs new file mode 100644 index 0000000..f649dad --- /dev/null +++ b/initialiser/src/service.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use std::fs::write; +use std::os::unix::fs::symlink; + +pub const SERVICE_PATH: &str = "/etc/systemd/system/runner.service"; +pub const SERVICE_TEMPLATE: &str = r#" +[Unit] +Description=Actions Runner +After=network.target + +[Service] +ExecStart=/sbin/actions-run +KillMode=control-group +KillSignal=SIGTERM +TimeoutStopSec=5min +WorkingDirectory=/home/runner +User=runner +Restart=never +Environment="GITHUB_ORG={github_org}" +Environment="GITHUB_TOKEN={github_token}" +Environment="GITHUB_RUNNER_NAME={github_runner_name}" +Environment="GITHUB_RUNNER_LABELS={github_runner_labels}" +ExecStopPost=+/usr/sbin/reboot +"#; + +pub fn setup_service( + github_org: &str, + github_token: &str, + github_runner_name: &str, + github_runner_labels: &str, +) -> Result<()> { + let service = SERVICE_TEMPLATE + .replace("{github_org}", github_org) + .replace("{github_token}", github_token) + .replace("{github_runner_name}", github_runner_name) + .replace("{github_runner_labels}", github_runner_labels); + + write(SERVICE_PATH, service)?; + + Ok(()) +} + +pub fn enable_service() -> Result<()> { + symlink( + SERVICE_PATH, + "/etc/systemd/system/multi-user.target.wants/runner.service", + )?; + + Ok(()) +} diff --git a/manager/Cargo.toml b/manager/Cargo.toml new file mode 100644 index 0000000..4412bfb --- /dev/null +++ b/manager/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "manager" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +log.workspace = true +serde_json.workspace = true +rand.workspace = true +camino.workspace = true +signal-hook = "*" + +[dependencies.github] +path = "../github" + +[dependencies.config] +path = "../config" + +[dependencies.util] +path = "../util" + +[dev-dependencies] +mockall.workspace = true diff --git a/manager/README.md b/manager/README.md new file mode 100644 index 0000000..b49f887 --- /dev/null +++ b/manager/README.md @@ -0,0 +1,12 @@ +# Manager + +This crate is responsible for managing the VM lifecycle, and it's what gets called when you run `actions-runner run`. + +It sets up the required networking on the host machine, to allow internet +connectivity inside the VM. It also sets up the Cache disk, so we can persist data between runs. + +Finally, it starts the Firecracker VM, and waits for it to finish. + +There's also a debug feature that starts a Firecracker VM with stdin/out/err connected to the host machine, so you can see the output of the VM, and maniulate it. + +We pass variables from outside the VM to inside the VM through the kernel boot args, and pick them up with our custom entrypoint. diff --git a/manager/src/disk.rs b/manager/src/disk.rs new file mode 100644 index 0000000..4aba121 --- /dev/null +++ b/manager/src/disk.rs @@ -0,0 +1,52 @@ +use camino::Utf8PathBuf; +use util::fs; + +#[derive(Debug)] +pub struct Disk { + pub size: u32, + pub path: Utf8PathBuf, + pub name: String, + pub format: DiskFormat, +} + +#[derive(Debug)] +pub enum DiskFormat { + Ext4, +} + +impl Disk { + pub fn new(path: &Utf8PathBuf, name: &str, size: u32, format: DiskFormat) -> Self { + Self { + path: path.clone(), + name: name.to_string(), + size, + format, + } + } + + pub fn size_in_megabytes(&self) -> u64 { + self.size as u64 * 1024 + } + + pub fn filename(&self) -> Utf8PathBuf { + match self.format { + DiskFormat::Ext4 => Utf8PathBuf::from(format!("{}.ext4", &self.name)), + } + } + + pub fn path_with_filename(&self) -> Utf8PathBuf { + self.path.join(&self.filename()) + } + + pub fn setup(&self) -> Result<(), std::io::Error> { + match self.format { + DiskFormat::Ext4 => self.setup_ext4(), + } + } + + pub fn setup_ext4(&self) -> Result<(), std::io::Error> { + fs::dd(self.path_with_filename(), self.size_in_megabytes())?; + fs::mkfs_ext4(&self.path_with_filename())?; + Ok(()) + } +} diff --git a/manager/src/instance.rs b/manager/src/instance.rs new file mode 100644 index 0000000..371b05b --- /dev/null +++ b/manager/src/instance.rs @@ -0,0 +1,316 @@ +use crate::{ + disk::{Disk, DiskFormat}, + network::NetworkAllocation, +}; +use anyhow::Result; +use camino::Utf8PathBuf; +use config::{ + firecracker::{BootSource, Drive, FirecrackerConfig, MachineConfig, NetworkInterface}, + manager::Role, + DEFAULT_BOOT_ARGS, +}; +use github::GitHub; +use log::*; +use rand::distributions::{Alphanumeric, DistString}; +use serde_json; +use std::{fs, process::Command}; +use util::fs::{copy_sparse, rm_rf}; + +pub enum InstanceState { + NotStarted, + Running, + NotRunning, + Errorred, +} + +#[derive(Debug)] +pub struct Instance { + network_allocation: NetworkAllocation, + work_dir: Utf8PathBuf, + kernel_image: Utf8PathBuf, + kernel_cmdline: Option, + rootfs_image: Utf8PathBuf, + cpus: u32, + memory_size: u32, + cache_paths: Vec, + cache: Disk, + idx: u8, + role: String, + github: GitHub, + labels: Vec, + child: Option, +} + +impl Instance { + pub fn new( + network_allocation: NetworkAllocation, + github: GitHub, + work_dir: &Utf8PathBuf, + role: &Role, + idx: u8, + ) -> Self { + let instance_dir: Utf8PathBuf = work_dir.join(&role.slug()).join(format!("{}", idx)); + let cache = Disk::new(&instance_dir, "cache", role.cache_size, DiskFormat::Ext4); + + Self { + network_allocation, + work_dir: instance_dir.clone(), + kernel_image: role.kernel_image.clone(), + kernel_cmdline: role.kernel_cmdline.clone(), + rootfs_image: role.rootfs_image.clone(), + cpus: role.cpus, + memory_size: role.memory_size, + cache_paths: role.cache_paths.clone(), + role: role.slug(), + labels: role.labels.clone(), + github, + cache, + idx, + child: None, + } + } + + pub fn log_prefix(&self) -> String { + format!("[{} {}]", self.role, self.idx) + } + + pub fn name(&self) -> String { + format!( + "{}-{}-{}", + self.role, + self.idx, + Alphanumeric.sample_string(&mut rand::thread_rng(), 4), + ) + } + + pub fn setup(&mut self) -> Result<()> { + info!("Running instance with: {:?}", self); + + debug!( + "{} Creating work dir: '{}'", + self.log_prefix(), + self.work_dir + ); + fs::create_dir_all(&self.work_dir)?; + + debug!( + "{} Setup network with tap: '{}', host address: '{}'", + self.log_prefix(), + self.network_allocation.tap_name, + self.network_allocation.host_ip, + ); + self.network_allocation.setup()?; + + debug!( + "{} Initialize shared cache on path: '{}' (size: {}GB)", + self.log_prefix(), + self.cache.path_with_filename(), + self.cache.size, + ); + self.cache.setup()?; + + Ok(()) + } + + pub fn boot_args(&self) -> Result { + let mut boot_args = vec![DEFAULT_BOOT_ARGS.to_string()]; + + // Add GitHub token + boot_args.push(format!( + "github_token={}", + &self.github.registration_token()? + )); + boot_args.push(format!("github_org={}", &self.github.org)); + + // Add cache paths + if !self.cache_paths.is_empty() { + boot_args.push(format!( + "cache_paths=\"{}\"", + self.cache_paths + .iter() + .map(|cp| cp.to_string()) + .collect::>() + .join(",") + )); + } + + // Add overridden boot args + if let Some(ref cmdline) = &self.kernel_cmdline { + boot_args.push(cmdline.to_string()); + } + + boot_args.push(format!("github_runner_name={}", self.name())); + boot_args.push(format!("github_runner_labels={}", self.labels())); + + Ok(boot_args.join(" ")) + } + + pub fn labels(&self) -> String { + let mut labels = self.labels.clone(); + labels.push(self.role.to_string()); + labels.join(",") + } + + pub fn config(&self) -> Result { + let boot_source = BootSource { + kernel_image_path: self.kernel_image.to_string(), + boot_args: self.boot_args()?, + }; + + let mut drives = Vec::new(); + drives.push(Drive { + drive_id: "rootfs".to_string(), + path_on_host: self.work_dir.join("rootfs.ext4"), + is_root_device: true, + is_read_only: false, + cache_type: None, + }); + + drives.push(Drive { + drive_id: "cache".to_string(), + path_on_host: self.cache.path_with_filename(), + is_root_device: false, + is_read_only: false, + cache_type: None, + }); + + let mut network_interfaces = Vec::new(); + network_interfaces.push(NetworkInterface { + iface_id: "eth0".to_string(), + guest_mac: self.network_allocation.guest_mac.clone(), + host_dev_name: self.network_allocation.tap_name.clone(), + }); + + let machine_config = MachineConfig { + vcpu_count: self.cpus, + mem_size_mib: self.memory_size * 1024, + }; + + Ok(FirecrackerConfig { + boot_source, + drives, + network_interfaces, + machine_config, + }) + } + + pub fn setup_run(&mut self) -> Result<()> { + debug!( + "{} Copy rootfs from: '{}'to '{}'", + self.log_prefix(), + self.rootfs_image, + self.work_dir.join("rootfs.ext4"), + ); + let _ = rm_rf(&self.work_dir.join("rootfs.ext4")); + copy_sparse(&self.rootfs_image, &self.work_dir.join("rootfs.ext4"))?; + + debug!( + "{} Generate config: '{}'", + self.log_prefix(), + self.work_dir.join("config.json") + ); + + fs::write( + self.work_dir.join("config.json"), + serde_json::to_string(&self.config()?)?, + )?; + Ok(()) + } + + pub fn cleanup(&self) -> Result<()> { + let _ = rm_rf(&self.work_dir); + Ok(()) + } + + pub fn reset(&mut self) { + self.child = None; + } + + pub fn stop(&mut self) -> Result<()> { + info!("{} Shutting down instance", self.log_prefix()); + + match self.child.as_mut() { + Some(child) => { + child.kill()?; + child.wait()?; + } + None => { + info!("{} No instance to shut down", self.log_prefix()); + } + } + Ok(()) + } + + pub fn start(&mut self) -> Result<()> { + self.setup_run()?; + + debug!("{} Running firecracker", self.log_prefix()); + let child = Command::new("firecracker") + .args(["--config-file", "config.json", "--no-api"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .current_dir(&self.work_dir) + .spawn()?; + self.child = Some(child); + Ok(()) + } + + pub fn run_once(&mut self) -> Result<()> { + self.setup_run()?; + + debug!("{} Running firecracker", self.log_prefix()); + Command::new("firecracker") + .args(["--config-file", "config.json", "--no-api"]) + .current_dir(&self.work_dir) + .status() + .expect("Failed to start process"); + Ok(()) + } + + pub fn state(&mut self) -> InstanceState { + match self.child.as_mut() { + Some(child) => match child.try_wait() { + Ok(Some(status)) => { + if status.success() { + InstanceState::NotRunning + } else { + InstanceState::Errorred + } + } + Ok(None) => InstanceState::Running, + Err(_) => InstanceState::Errorred, + }, + None => InstanceState::NotStarted, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8PathBuf; + + #[test] + fn test_instance_setup() { + let workdir: Utf8PathBuf = "/tmp/test_instance_setup".into(); + let github = GitHub::new("test", "test"); + let network_allocation = NetworkAllocation::new("eth0", 1); + let role = Role { + name: "test".to_string(), + kernel_image: Utf8PathBuf::from("kernel"), + kernel_cmdline: None, + rootfs_image: Utf8PathBuf::from("rootfs"), + cpus: 1, + memory_size: 1, + cache_size: 1, + overlay_size: 1, + instance_count: 1, + cache_paths: Vec::new(), + labels: Vec::new(), + }; + + let mut _instance = Instance::new(network_allocation, github.clone(), &workdir, &role, 1); + //instance.setup().expect("Could not setup instance"); + } +} diff --git a/manager/src/lib.rs b/manager/src/lib.rs new file mode 100644 index 0000000..13168a5 --- /dev/null +++ b/manager/src/lib.rs @@ -0,0 +1,142 @@ +use crate::{ + instance::{Instance, InstanceState}, + network::{Forwarding, NetworkAllocation}, +}; +use anyhow::Result; +use config::manager::ManagerConfig; +use github::GitHub; +use log::*; +use signal_hook::{consts::SIGINT, iterator::Signals}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +pub mod disk; +pub mod instance; +pub mod network; + +pub struct Manager { + pub config: ManagerConfig, + pub instances: Vec, + pub shutdown: Arc, +} + +impl Manager { + pub fn new(config: ManagerConfig) -> Self { + let mut signals = Signals::new(&[SIGINT]).unwrap(); + let shutdown = Arc::new(AtomicBool::new(false)); + let cloned_shutdown = shutdown.clone(); + + thread::spawn(move || { + for sig in signals.forever() { + info!("Received signal {:?}", sig); + shutdown.store(true, Ordering::Relaxed); + } + }); + + Self { + config, + instances: Vec::new(), + shutdown: cloned_shutdown, + } + } + + pub fn setup(&mut self) -> Result<()> { + let network_forwarding = Forwarding::new(&self.config.network_interface); + network_forwarding.setup()?; + + let github = GitHub::new(&self.config.github_org, &self.config.github_pat); + + for role in &self.config.roles { + for _ in 0..role.instance_count { + let idx = self.instances.len() as u8 + 1; + + let network_allocation = + NetworkAllocation::new(&self.config.network_interface, idx); + + let mut instance = Instance::new( + network_allocation, + github.clone(), + &self.config.run_path, + &role, + idx, + ); + instance.setup()?; + self.instances.push(instance); + } + } + Ok(()) + } + + pub fn run(&mut self) -> Result<()> { + loop { + if self.shutdown.load(Ordering::Relaxed) { + info!("Shutting down."); + for instance in &mut self.instances { + info!("{} Stopping instance", instance.log_prefix()); + + if let Err(e) = instance.stop() { + error!("{} Failed to stop instance: {}", instance.log_prefix(), e); + } + let _ = instance.cleanup(); + } + break; + } + + for instance in &mut self.instances { + match instance.state() { + InstanceState::Running => (), + InstanceState::NotStarted | InstanceState::NotRunning => { + info!("{} Starting instance", instance.log_prefix()); + if let Err(e) = instance.start() { + error!("{} Failed to start instance: {}", instance.log_prefix(), e); + } + } + InstanceState::Errorred => { + error!("{} Instance has errored.", instance.log_prefix()); + thread::sleep(Duration::from_secs(20)); + instance.reset(); + } + } + } + thread::sleep(Duration::from_secs(1)); + } + + Ok(()) + } + + pub fn debug(&mut self, role: &str, idx: u8) -> Result<()> { + let network_forwarding = Forwarding::new(&self.config.network_interface); + let network_allocation = NetworkAllocation::new(&self.config.network_interface, idx); + let github = GitHub::new(&self.config.github_org, &self.config.github_pat); + let mut role = self + .config + .roles + .iter() + .find(|r| r.name == role) + .expect("Could not find role.") + .clone(); + + // Set output to console + role.kernel_cmdline = match role.kernel_cmdline { + Some(ref cmdline) => Some(format!("console=ttyS0 {}", cmdline)), + None => Some("console=ttyS0".to_string()), + }; + + let mut instance = Instance::new( + network_allocation, + github, + &self.config.run_path, + &role, + idx, + ); + network_forwarding.setup()?; + instance.setup()?; + + instance.run_once()?; + + instance.cleanup()?; + Ok(()) + } +} diff --git a/manager/src/network/allocation.rs b/manager/src/network/allocation.rs new file mode 100644 index 0000000..7df1edc --- /dev/null +++ b/manager/src/network/allocation.rs @@ -0,0 +1,68 @@ +use config::NETWORK_MASK_SHORT; +use std::net::Ipv4Addr; +use std::process::Command; +use util::{exec, network::ip_to_mac, CommandExecutionError}; + +#[derive(Debug)] +pub struct NetworkAllocation { + pub interface: String, + pub host_ip: Ipv4Addr, + pub guest_mac: String, + pub client_ip: Ipv4Addr, + pub tap_name: String, +} + +impl NetworkAllocation { + pub fn new(interface: &str, idx: u8) -> Self { + let host_ip = Ipv4Addr::new(172, 16, idx, 1); + let client_ip = Ipv4Addr::new(172, 16, idx, 2); + Self { + interface: interface.to_string(), + guest_mac: ip_to_mac(&client_ip), + tap_name: format!("tap{}", idx), + host_ip, + client_ip, + } + } + + pub fn setup(&self) -> Result<(), CommandExecutionError> { + // Remove existing tap device + // ip link del "$TAP_DEV" 2> /dev/null + let _ = exec(Command::new("ip").args(["link", "del", &self.tap_name])); + + // Create tap device + // ip tuntap add dev "$TAP_DEV" mode tap + let _ = + exec(Command::new("ip").args(["tuntap", "add", "dev", &self.tap_name, "mode", "tap"])); + + // Add address to tap device + // ip addr add "$TAP_IP$MASK_SHORT" dev "$TAP_DEV" + let _ = exec(Command::new("ip").args([ + "addr", + "add", + &format!("{}/{}", self.host_ip, NETWORK_MASK_SHORT), + "dev", + &self.tap_name, + ])); + + // Bring up tap device + // ip link set dev "$TAP_DEV" up + let _ = exec(Command::new("ip").args(["link", "set", "dev", &self.tap_name, "up"])); + + // Set up internet access + // iptables -I FORWARD 1 -i $TAP_DEV -o $1 -j ACCEPT + let _ = exec(Command::new("iptables").args([ + "-I", + "FORWARD", + "1", + "-i", + &self.tap_name, + "-o", + &self.interface, + "-j", + "ACCEPT", + ])); + + Ok(()) + } +} diff --git a/manager/src/network/allocator.rs b/manager/src/network/allocator.rs new file mode 100644 index 0000000..410a71b --- /dev/null +++ b/manager/src/network/allocator.rs @@ -0,0 +1,38 @@ +use super::NetworkAllocation; +use config::NETWORK_MAX_ALLOCATIONS; +use std::collections::BTreeMap; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AllocatorError { + #[error("No free IPs")] + NoFreeIps, +} + +pub struct NetworkAllocator { + pub interface: String, + pub allocations: BTreeMap, +} + +impl NetworkAllocator { + pub fn new(interface: &str) -> Self { + Self { + interface: interface.to_string(), + allocations: BTreeMap::new(), + } + } + + pub fn allocate(&self) -> Result { + for idx in 0..NETWORK_MAX_ALLOCATIONS { + if !self.allocations.contains_key(&idx) { + return Ok(NetworkAllocation::new(&self.interface, idx)); + } + } + Err(AllocatorError::NoFreeIps) + } + + pub fn deallocate(&mut self, idx: &u8) -> Result<(), AllocatorError> { + self.allocations.remove(idx); + Ok(()) + } +} diff --git a/manager/src/network/forwarding.rs b/manager/src/network/forwarding.rs new file mode 100644 index 0000000..5dfca67 --- /dev/null +++ b/manager/src/network/forwarding.rs @@ -0,0 +1,48 @@ +use std::process::Command; +use util::{exec, CommandExecutionError}; + +pub struct Forwarding { + pub interface: String, +} + +impl Forwarding { + pub fn new(interface: &str) -> Self { + Self { + interface: interface.to_string(), + } + } + + pub fn setup(&self) -> Result<(), CommandExecutionError> { + // Enable IP forwarding + // sh -c "echo 1 > /proc/sys/net/ipv4/ip_forward" + let _ = exec(Command::new("sh").args(["-c", "echo 1 > /proc/sys/net/ipv4/ip_forward"])); + + // Set up nat + // iptables -t nat -A POSTROUTING -o $1 -j MASQUERADE + let _ = exec(Command::new("iptables").args([ + "-t", + "nat", + "-A", + "POSTROUTING", + "-o", + &self.interface, + "-j", + "MASQUERADE", + ])); + + // Set up forwarding + // iptables -I FORWARD 1 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + let _ = exec(Command::new("iptables").args([ + "-I", + "FORWARD", + "1", + "-m", + "conntrack", + "--ctstate", + "RELATED,ESTABLISHED", + "-j", + "ACCEPT", + ])); + Ok(()) + } +} diff --git a/manager/src/network/mod.rs b/manager/src/network/mod.rs new file mode 100644 index 0000000..33c7f1d --- /dev/null +++ b/manager/src/network/mod.rs @@ -0,0 +1,5 @@ +pub use {allocation::NetworkAllocation, allocator::NetworkAllocator, forwarding::Forwarding}; + +pub mod allocation; +pub mod allocator; +pub mod forwarding; diff --git a/runner/Cargo.toml b/runner/Cargo.toml new file mode 100644 index 0000000..04e6555 --- /dev/null +++ b/runner/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "runner" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +log.workspace = true +anyhow.workspace = true +thiserror.workspace = true + +[dependencies.github] +path = "../github" + +[dependencies.util] +path = "../util" diff --git a/runner/README.md b/runner/README.md new file mode 100644 index 0000000..c6035c1 --- /dev/null +++ b/runner/README.md @@ -0,0 +1,7 @@ +# Runner + +This crate is responsible for running the GitHub Actions Runner. + +It's copied by the initialiser into `/sbin/actions-run` and started as a systemd service. + +It relies on a number of environment variables, set by the initialiser, to authenticate with GitHub and start the runner. diff --git a/runner/src/lib.rs b/runner/src/lib.rs new file mode 100644 index 0000000..7de411d --- /dev/null +++ b/runner/src/lib.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use std::process::Command; +use util::exec; + +pub struct Runner {} + +impl Runner { + pub fn new() -> Self { + Runner {} + } + + pub fn run(&self) -> Result<()> { + exec( + Command::new("/home/runner/config.sh") + .arg("--url") + .arg(format!( + "https://github.com/{}", + std::env::var("GITHUB_ORG").unwrap() + )) + .arg("--token") + .arg(std::env::var("GITHUB_TOKEN").unwrap()) + .arg("--unattended") + .arg("--ephemeral") + .arg("--name") + .arg(std::env::var("GITHUB_RUNNER_NAME").unwrap()) + .arg("--labels") + .arg(std::env::var("GITHUB_RUNNER_LABELS").unwrap()), + )?; + + exec(&mut Command::new("/home/runner/run.sh"))?; + Ok(()) + } +} diff --git a/test_fixtures/config.toml b/test_fixtures/config.toml new file mode 100644 index 0000000..1a5a1b6 --- /dev/null +++ b/test_fixtures/config.toml @@ -0,0 +1,14 @@ +network_interface="eth0" +run_path="/srv" +github_pat="ghp_1234567890" +github_org="matsimitsu" + +[[roles]] +name="your-project" +rootfs_image="/home/runner/containers/your-project-1.0.0/rootfs.img" +kernel_image="/home/runner/containers/your-project-1.0.0/kernel.bin" +cpus=4 +memory_size=1024 +cache_size=1024 +overlay_size=1024 +instance_count=4 diff --git a/util/Cargo.toml b/util/Cargo.toml new file mode 100644 index 0000000..d60a9f5 --- /dev/null +++ b/util/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "util" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +thiserror.workspace = true +log.workspace = true +cfg-if.workspace = true +camino.workspace = true +lazy_static.workspace = true +mockall.workspace = true + +[dependencies.config] +path = "../config" diff --git a/util/README.md b/util/README.md new file mode 100644 index 0000000..bd31f73 --- /dev/null +++ b/util/README.md @@ -0,0 +1,4 @@ +# Util + +This crate contains utility functions that are used across the other crates. +For example, the `exec` function is used to execute a command and return the output. It's setup in a way that we can stub out the responses in test mode. diff --git a/util/src/fs.rs b/util/src/fs.rs new file mode 100644 index 0000000..8c65e59 --- /dev/null +++ b/util/src/fs.rs @@ -0,0 +1,141 @@ +use super::*; +use camino::Utf8Path; +use std::process::Command; + +pub fn copy_sparse(from: impl AsRef, to: impl AsRef) -> std::io::Result<()> { + let from = from.as_ref(); + let to = to.as_ref(); + + exec(Command::new("cp").args(["--sparse=always", from.as_str(), to.as_str()])) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + Ok(()) +} + +pub fn rm_rf(path: impl AsRef) -> std::io::Result<()> { + let path = path.as_ref(); + + exec(Command::new("rm").args(["-rf", path.as_str()])) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + Ok(()) +} + +pub fn mkdir_p(path: impl AsRef) -> std::io::Result<()> { + let path = path.as_ref(); + + exec(Command::new("mkdir").args(["-p", path.as_str()])) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + Ok(()) +} + +pub fn mkfs_ext4(path: impl AsRef) -> std::io::Result<()> { + let path = path.as_ref(); + + exec(Command::new("mkfs.ext4").arg(path.as_str())) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + Ok(()) +} + +pub fn dd(path: impl AsRef, size_in_mb: u64) -> std::io::Result<()> { + let path = path.as_ref(); + + exec(Command::new("dd").args([ + "if=/dev/zero", + &format!("of={}", &path), + "conv=sparse", + "bs=1M", + &format!("count={}", size_in_mb), + ])) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::ExitStatusExt; + + #[test] + fn test_copy_sparse() { + let _m = MTX.lock(); + + let ctx = mock_inner::internal_exec_context(); + ctx.expect() + .withf(|c| inner::to_string(c) == "cp --sparse=always /foo.txt /bar.txt") + .returning(|_| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: vec![], + stderr: vec![], + }) + }); + + let result = copy_sparse("/foo.txt", "/bar.txt"); + assert!(result.is_ok()); + ctx.checkpoint(); + } + + #[test] + fn test_rm_rf() { + let _m = MTX.lock(); + + let ctx = mock_inner::internal_exec_context(); + ctx.expect() + .withf(|c| inner::to_string(c) == "rm -rf /foo.txt") + .returning(|_| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: vec![], + stderr: vec![], + }) + }); + + let result = rm_rf("/foo.txt"); + assert!(result.is_ok()); + ctx.checkpoint(); + } + + #[test] + fn test_mkdir_p() { + let _m = MTX.lock(); + + let ctx = mock_inner::internal_exec_context(); + ctx.expect() + .withf(|c| inner::to_string(c) == "mkdir -p /foo") + .returning(|_| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: vec![], + stderr: vec![], + }) + }); + + let result = mkdir_p("/foo"); + assert!(result.is_ok()); + ctx.checkpoint(); + } + + #[test] + fn test_mkfs_ext4() { + let _m = MTX.lock(); + + let ctx = mock_inner::internal_exec_context(); + ctx.expect() + .withf(|c| inner::to_string(c) == "mkfs.ext4 /dev/sda1") + .returning(|_| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: vec![], + stderr: vec![], + }) + }); + + let result = mkfs_ext4("/dev/sda1"); + assert!(result.is_ok()); + ctx.checkpoint(); + } +} diff --git a/util/src/lib.rs b/util/src/lib.rs new file mode 100644 index 0000000..50a3332 --- /dev/null +++ b/util/src/lib.rs @@ -0,0 +1,162 @@ +use cfg_if::cfg_if; +#[allow(unused)] +use lazy_static::lazy_static; +use log; +#[allow(unused)] +use std::sync::atomic::{AtomicBool, Ordering}; +#[allow(unused)] +use std::sync::Mutex; +use std::time::Instant; + +pub mod fs; +pub mod mount; +pub mod network; + +#[derive(Debug)] +pub struct CommandResult { + pub command: String, + pub stdout: String, + pub stderr: String, + pub status: std::process::ExitStatus, +} + +impl std::fmt::Display for CommandResult { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "Command '{}' executed and failed with status: {}", + self.command, self.status + )?; + write!(f, " stdout: {}", self.stdout)?; + write!(f, " stderr: {}", self.stderr) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum CommandExecutionError { + #[error("Failed to start execution of '{command}': {err}")] + ExecutionStart { + command: String, + err: std::io::Error, + }, + + #[error("{0}")] + CommandFailure(Box), +} + +#[cfg_attr( + any(test, automock, feature = "testing"), + mockall::automock, + allow(dead_code) +)] +pub mod inner { + use super::*; + + pub fn to_string(command: &std::process::Command) -> String { + format!( + "{} {}", + command.get_program().to_string_lossy(), + command + .get_args() + .map(|s| s.to_string_lossy().into()) + .collect::>() + .join(" ") + ) + } + + pub fn output_to_exec_error( + command: &std::process::Command, + output: &std::process::Output, + ) -> CommandExecutionError { + CommandExecutionError::CommandFailure(Box::new(CommandResult { + command: to_string(command), + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + })) + } + + pub fn internal_exec( + cmd: &mut std::process::Command, + ) -> Result { + let start = Instant::now(); + + let output = cmd + .output() + .map_err(|err| CommandExecutionError::ExecutionStart { + command: to_string(cmd), + err, + })?; + + if !output.status.success() { + return Err(output_to_exec_error(cmd, &output)); + } + + let duration = start.elapsed(); + log::trace!("Command {:?} executed in {}ms", cmd, duration.as_millis()); + + Ok(output) + } + + pub fn internal_exec_spawn( + cmd: &mut std::process::Command, + ) -> Result { + let output = cmd + .spawn() + .map_err(|err| CommandExecutionError::ExecutionStart { + command: to_string(cmd), + err, + })?; + + Ok(output) + } +} + +#[cfg(any(test, feature = "testing"))] +pub static USE_MOCKS: AtomicBool = AtomicBool::new(true); + +pub fn exec( + cmd: &mut std::process::Command, +) -> Result { + log::trace!( + "Executing command {:?} with args {:?}", + cmd.get_program(), + cmd.get_args() + ); + cfg_if! { + if #[cfg(any(test, feature = "testing"))] { + if USE_MOCKS.load(Ordering::SeqCst) { + mock_inner::internal_exec(cmd) + } else { + inner::internal_exec(cmd) + } + } else { + inner::internal_exec(cmd) + } + } +} + +pub fn exec_spawn( + cmd: &mut std::process::Command, +) -> Result { + log::trace!( + "Executing command {:?} with args {:?}", + cmd.get_program(), + cmd.get_args() + ); + cfg_if! { + if #[cfg(any(test, feature = "testing"))] { + if USE_MOCKS.load(Ordering::SeqCst) { + mock_inner::internal_exec_spawn(cmd) + } else { + inner::internal_exec_spawn(cmd) + } + } else { + inner::internal_exec_spawn(cmd) + } + } +} + +lazy_static! { + pub static ref MTX: Mutex<()> = Mutex::new(()); +} diff --git a/util/src/mount.rs b/util/src/mount.rs new file mode 100644 index 0000000..9e61c0f --- /dev/null +++ b/util/src/mount.rs @@ -0,0 +1,98 @@ +use super::*; +use camino::Utf8Path; +use std::process::Command; + +pub fn mount_image( + from: impl AsRef, + to: impl AsRef, +) -> Result<(), CommandExecutionError> { + let from = from.as_ref(); + let to = to.as_ref(); + + let _ = exec(Command::new("mount").args([from.as_str(), to.as_str()]))?; + Ok(()) +} + +pub fn mount_ext4( + from: impl AsRef, + to: impl AsRef, +) -> Result<(), CommandExecutionError> { + let from = from.as_ref(); + let to = to.as_ref(); + + let _ = exec(Command::new("mount").args(["-t", "ext4", from.as_str(), to.as_str()]))?; + Ok(()) +} + +pub fn unmount(path: impl AsRef) -> Result<(), CommandExecutionError> { + let path = path.as_ref(); + let _ = exec(Command::new("umount").arg(path.as_str()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::ExitStatusExt; + + #[test] + fn test_mount_image() { + let _m = MTX.lock(); + + let ctx = mock_inner::internal_exec_context(); + ctx.expect() + .withf(|c| inner::to_string(c) == "mount /dev/sda1 /mnt") + .returning(|_| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: vec![], + stderr: vec![], + }) + }); + + let result = mount_image("/dev/sda1", "/mnt"); + assert!(result.is_ok()); + ctx.checkpoint(); + } + + #[test] + fn test_mount_ext4() { + let _m = MTX.lock(); + + let ctx = mock_inner::internal_exec_context(); + ctx.expect() + .withf(|c| inner::to_string(c) == "mount -t ext4 /dev/sda1 /mnt") + .returning(|_| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: vec![], + stderr: vec![], + }) + }); + + let result = mount_ext4("/dev/sda1", "/mnt"); + assert!(result.is_ok()); + ctx.checkpoint(); + } + + #[test] + fn test_unmount() { + let _m = MTX.lock(); + + let ctx = mock_inner::internal_exec_context(); + ctx.expect() + .withf(|c| inner::to_string(c) == "umount /dev/sda1") + .returning(|_| { + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: vec![], + stderr: vec![], + }) + }); + + let result = unmount("/dev/sda1"); + assert!(result.is_ok()); + ctx.checkpoint(); + } +} diff --git a/util/src/network.rs b/util/src/network.rs new file mode 100644 index 0000000..7ec33d4 --- /dev/null +++ b/util/src/network.rs @@ -0,0 +1,62 @@ +use config::NETWORK_MAGIC_MAC_START; +use std::net::Ipv4Addr; +use std::num::ParseIntError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MacToIpError { + #[error("Could not parse octed from string: {}", self)] + ParseIntError(#[from] ParseIntError), + #[error("No valid IP adddress in mac address: {}", .0)] + NoIpInMac(String), +} + +// Convert an IP address to a MAC address +pub fn ip_to_mac(ip: &Ipv4Addr) -> String { + format!( + "{}:{:02x}:{:02x}:{:02x}:{:02x}", + NETWORK_MAGIC_MAC_START, + ip.octets()[0], + ip.octets()[1], + ip.octets()[2], + ip.octets()[3] + ) +} + +// Convert an IP address to a MAC address +pub fn mac_to_ip(mac: &str) -> Result { + let octets = mac + .replace("06:00:", "") + .split(":") + .map(|octet| u8::from_str_radix(octet, 16)) + .collect::, ParseIntError>>() + .map_err(|_| MacToIpError::NoIpInMac(mac.to_string()))?; + + let address = Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]); + Ok(address) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mac_to_ip() { + let mac = "06:00:ac:10:c9:01"; + let ip = super::mac_to_ip(mac).unwrap(); + assert_eq!(ip, Ipv4Addr::new(172, 16, 201, 1)); + } + + #[test] + fn test_ip_to_mac() { + assert_eq!( + ip_to_mac(&Ipv4Addr::new(172, 16, 0, 1)), + "06:00:ac:10:00:01" + ); + + assert_eq!( + ip_to_mac(&Ipv4Addr::new(172, 16, 10, 2)), + "06:00:ac:10:0a:02" + ); + } +}