From 30ae362dac9ea28cf1431aa06391b82cd7d47094 Mon Sep 17 00:00:00 2001 From: Chi Wang Date: Mon, 27 Sep 2021 12:35:03 +0800 Subject: [PATCH] Add bazelci-agent which can currently watch BEP json file and upload test logs to buildkite (#1222) --- .github/workflows/release-agent.yml | 123 +++++ agent/.gitignore | 1 + agent/Cargo.lock | 741 ++++++++++++++++++++++++++++ agent/Cargo.toml | 21 + agent/rust-toolchain.toml | 2 + agent/src/artifact/mod.rs | 1 + agent/src/artifact/upload.rs | 443 +++++++++++++++++ agent/src/lib.rs | 1 + agent/src/main.rs | 83 ++++ agent/tests/artifact/mod.rs | 1 + agent/tests/artifact/upload.rs | 39 ++ agent/tests/data/test_bep.json | 3 + agent/tests/data/test_bep_win.json | 3 + agent/tests/tests.rs | 1 + 14 files changed, 1463 insertions(+) create mode 100644 .github/workflows/release-agent.yml create mode 100644 agent/.gitignore create mode 100644 agent/Cargo.lock create mode 100644 agent/Cargo.toml create mode 100644 agent/rust-toolchain.toml create mode 100644 agent/src/artifact/mod.rs create mode 100644 agent/src/artifact/upload.rs create mode 100644 agent/src/lib.rs create mode 100644 agent/src/main.rs create mode 100644 agent/tests/artifact/mod.rs create mode 100644 agent/tests/artifact/upload.rs create mode 100644 agent/tests/data/test_bep.json create mode 100644 agent/tests/data/test_bep_win.json create mode 100644 agent/tests/tests.rs diff --git a/.github/workflows/release-agent.yml b/.github/workflows/release-agent.yml new file mode 100644 index 0000000000..fc379b600a --- /dev/null +++ b/.github/workflows/release-agent.yml @@ -0,0 +1,123 @@ +# The way this works is the following: +# +# The create-release job runs purely to initialize the GitHub release itself +# and to output upload_url for the following job. +# +# The build-release job runs only once create-release is finished. It gets the +# release upload URL from create-release job outputs, then builds the release +# executables for each supported platform and attaches them as release assets +# to the previously created release. +# +# The key here is that we create the release only once. +# +# Reference: +# https://eugene-babichenko.github.io/blog/2020/05/09/github-actions-cross-platform-auto-releases/ + +name: release-agent +on: + push: + # Enable when testing release infrastructure on a branch. + # branches: + # - test + tags: + - "agent-[0-9]+.[0-9]+.[0-9]+" +jobs: + create-release: + name: create-release + runs-on: ubuntu-latest + # env: + # Set to force version number, e.g., when no tag exists. + # AGENT_VERSION: TEST-0.0.0 + outputs: + upload_url: ${{ steps.release.outputs.upload_url }} + agent_version: ${{ env.AGENT_VERSION }} + steps: + - name: Get the release version from the tag + shell: bash + if: env.AGENT_VERSION == '' + run: | + # Apparently, this is the right way to get a tag name. Really? + # + # See: https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 + echo "AGENT_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + echo "version is: ${{ env.AGENT_VERSION }}" + - name: Create GitHub release + id: release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.AGENT_VERSION }} + release_name: ${{ env.AGENT_VERSION }} + + build-release: + name: build-release + needs: ['create-release'] + runs-on: ${{ matrix.os }} + env: + # Emit backtraces on panics. + RUST_BACKTRACE: 1 + strategy: + matrix: + build: [linux, macos, win-msvc] + include: + - build: linux + os: ubuntu-18.04 + rust: nightly + target: x86_64-unknown-linux-musl + - build: macos + os: macos-latest + rust: nightly + target: x86_64-apple-darwin + - build: win-msvc + os: windows-latest + rust: nightly + target: x86_64-pc-windows-msvc + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + profile: minimal + override: true + target: ${{ matrix.target }} + + - name: Test release binary + working-directory: agent + run: cargo test --verbose --release --target ${{ matrix.target }} + + - name: Build release binary + working-directory: agent + run: cargo build --verbose --release --target ${{ matrix.target }} + + - name: Strip release binary (linux and macos) + if: matrix.build == 'linux' || matrix.build == 'macos' + working-directory: agent + run: strip "target/${{ matrix.target }}/release/bazelci-agent" + + - name: Build release asset + shell: bash + run: | + staging="bazelci-${{ needs.create-release.outputs.agent_version }}-${{ matrix.target }}" + if [ "${{ matrix.os }}" = "windows-latest" ]; then + cp "agent/target/${{ matrix.target }}/release/bazelci-agent.exe" "$staging.exe" + echo "ASSET=$staging.exe" >> $GITHUB_ENV + else + cp "agent/target/${{ matrix.target }}/release/bazelci-agent" "$staging" + echo "ASSET=$staging" >> $GITHUB_ENV + fi + - name: Upload release asset + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ env.ASSET }} + asset_name: ${{ env.ASSET }} + asset_content_type: application/octet-stream diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000000..9f970225ad --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1 @@ +target/ \ No newline at end of file diff --git a/agent/Cargo.lock b/agent/Cargo.lock new file mode 100644 index 0000000000..5da3710657 --- /dev/null +++ b/agent/Cargo.lock @@ -0,0 +1,741 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" + +[[package]] +name = "assert_cmd" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b800c4403e8105d959595e1f88119e78bc12bc874c4336973658b648a746ba93" +dependencies = [ + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bazelci-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "predicates", + "rand", + "serde_json", + "structopt", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bstr" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "winapi", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term 0.11.0", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "itertools" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ddb889f9d0d08a67338271fa9b62996bc788c7796a5c18cf057420aaed5eaf" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[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.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb00336871be5ed2c8ed44b60ae9959dc5b9f08539422ed43f09e34ecaeba21" + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matchers" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project-lite" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443" + +[[package]] +name = "ppv-lite86" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" + +[[package]] +name = "predicates" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c143348f141cc87aab5b950021bac6145d0e5ae754b0591de23244cee42c9308" +dependencies = [ + "difflib", + "float-cmp", + "itertools", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e35a3326b75e49aa85f5dc6ec15b41108cf5aee58eabb1f274dd18b73c2451" + +[[package]] +name = "predicates-tree" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dd0fd014130206c9352efbdc92be592751b2b9274dff685348341082c6ea3d" +dependencies = [ + "predicates-core", + "treeline", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f5105d4fdaab20335ca9565e106a5d9b82b6219b5ba735731124ac6711d23d" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "serde" +version = "1.0.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" + +[[package]] +name = "serde_json" +version = "1.0.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9e390c27c3c0ce8bc5d725f6e4d30a29d26659494aa4b17535f7522c5c950" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740223c51853f3145fe7c90360d2d4232f2b62e3449489c207eccde818979982" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "structopt" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf9d950ef167e25e0bdb073cf1d68e9ad2795ac826f2f3f59647817cf23c0bfa" +dependencies = [ + "clap", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134d838a2c9943ac3125cf6df165eda53493451b719f3255b2a26b85f772d0ba" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f107db402c2c2055242dbf4d2af0e69197202e9faacbef9571bbe47f5a1b84" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thread_local" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tinyvec" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5241dd6f21443a3606b432718b166d3cedc962fd4b8bea54a8bc7f514ebda986" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tracing" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ba9ab62b7d6497a8638dfda5e5c4fb3b2d5a7fca4118f2b96151c8ef1a437e" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98863d0dd09fa59a1b79c6750ad80dbda6b75f4e71c437a6a1a8cb91a8bcbd77" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46125608c26121c81b0c6d693eab5a420e416da7e43c426d2e8f7df8da8a3acf" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "tracing-log" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6923477a48e41c1951f1999ef8bb5a3023eb723ceadafe78ffb65dc366761e3" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb65ea441fbb84f9f6748fd496cf7f63ec9af5bca94dd86456978d055e8eb28b" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62af966210b88ad5776ee3ba12d5f35b8d6a2b2a12168f3080cf02b814d7376b" +dependencies = [ + "ansi_term 0.12.1", + "chrono", + "lazy_static", + "matchers", + "regex", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "treeline" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" + +[[package]] +name = "unicode-bidi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085" + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/agent/Cargo.toml b/agent/Cargo.toml new file mode 100644 index 0000000000..8dcdf26b3e --- /dev/null +++ b/agent/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "bazelci-agent" +version = "0.1.0" +edition = "2018" + +[dependencies] +tracing = "0.1" +tracing-subscriber = "0.2" +anyhow = "1.0" +serde_json = "1.0" +clap = "2.33" +structopt = "0.3" +rand = "0.8" +url = "2.2" + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "2.0" + +[profile.release] +lto = true diff --git a/agent/rust-toolchain.toml b/agent/rust-toolchain.toml new file mode 100644 index 0000000000..271800cb2f --- /dev/null +++ b/agent/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/agent/src/artifact/mod.rs b/agent/src/artifact/mod.rs new file mode 100644 index 0000000000..3a2200b232 --- /dev/null +++ b/agent/src/artifact/mod.rs @@ -0,0 +1 @@ +pub mod upload; \ No newline at end of file diff --git a/agent/src/artifact/upload.rs b/agent/src/artifact/upload.rs new file mode 100644 index 0000000000..224310811e --- /dev/null +++ b/agent/src/artifact/upload.rs @@ -0,0 +1,443 @@ +use anyhow::{anyhow, Context, Result}; +use serde_json::Value; +use std::{ + env, + fs::{self, File}, + io::{BufRead, BufReader, Seek, SeekFrom}, + path::{Path, PathBuf, MAIN_SEPARATOR}, + process, + thread::sleep, + time::Duration, +}; +use tracing::error; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Mode { + // Upload as Buildkite's artifacts + Buildkite, +} + +/// Upload artifacts (e.g. test logs) by reading the BEP JSON file. +/// +/// The file is read in a loop until "last message" is reached, encountered consective errors. +pub fn upload( + dry: bool, + debug: bool, + build_event_json_file: Option<&Path>, + mode: Mode, + delay: Option, + monitor_flaky_tests: bool, +) -> Result<()> { + if let Some(build_event_json_file) = build_event_json_file { + watch_bep_json_file( + dry, + debug, + build_event_json_file, + mode, + delay, + monitor_flaky_tests, + )?; + } + + Ok(()) +} + +fn watch_bep_json_file( + dry: bool, + debug: bool, + build_event_json_file: &Path, + mode: Mode, + delay: Option, + monitor_flaky_tests: bool, +) -> Result<()> { + if let Some(delay) = delay { + sleep(delay); + } + let status = ["FAILED", "TIMEOUT", "FLAKY"]; + let mut parser = BepJsonParser::new(build_event_json_file); + let max_retries = 5; + let mut retries = max_retries; + let mut test_result_offset = 0; + let mut last_offset = 0; + + 'parse_loop: loop { + match parser.parse() { + Ok(_) => { + if parser.offset != last_offset { + // We have made progress, reset the retry counter + retries = max_retries; + last_offset = parser.offset; + + let local_exec_root = parser.local_exec_root.as_ref().map(|str| Path::new(str)); + for test_summary in parser.test_summaries[test_result_offset..] + .iter() + .filter(|test_result| status.contains(&test_result.overall_status.as_str())) + { + for failed_test in test_summary.failed.iter() { + if let Err(error) = + upload_test_log(dry, local_exec_root, &failed_test.uri, mode) + { + error!("{:?}", error); + } + } + } + test_result_offset = parser.test_summaries.len(); + } + + if parser.done { + break 'parse_loop; + } + } + Err(error) => { + retries -= 1; + // Abort since we keep getting errors + if retries == 0 { + return Err(error); + } + + error!("{:?}", error); + } + } + + sleep(Duration::from_secs(1)); + } + + let should_upload_bep_json_file = + debug || (monitor_flaky_tests && parser.has_overall_test_status("FLAKY")); + if should_upload_bep_json_file { + if let Err(error) = upload_bep_json_file(dry, build_event_json_file, mode) { + error!("{:?}", error); + } + } + + Ok(()) +} + +fn upload_bep_json_file(dry: bool, build_event_json_file: &Path, mode: Mode) -> Result<()> { + upload_artifact(dry, None, build_event_json_file, mode) +} + +fn execute_command(dry: bool, cwd: Option<&Path>, program: &str, args: &[&str]) -> Result<()> { + println!("{} {}", program, args.join(" ")); + + if dry { + return Ok(()); + } + + let mut command = process::Command::new(program); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.args(args); + + let status = command + .status() + .with_context(|| format!("Failed to execute command `{} {}`", program, args.join(" ")))?; + + if !status.success() { + return Err(anyhow!( + "Failed to execute command `{} {}`: exit status {}", + program, + args.join(" "), + status + )); + } + + Ok(()) +} + +fn upload_artifact_buildkite(dry: bool, cwd: Option<&Path>, artifact: &Path) -> Result<()> { + let artifact = artifact.display().to_string(); + execute_command( + dry, + cwd, + "buildkite-agent", + &["artifact", "upload", artifact.as_str()], + ) +} + +fn upload_artifact(dry: bool, cwd: Option<&Path>, artifact: &Path, mode: Mode) -> Result<()> { + match mode { + Mode::Buildkite => upload_artifact_buildkite(dry, cwd, artifact), + } +} + +#[allow(dead_code)] +fn test_label_to_path(tmpdir: &Path, label: &str, attempt: i32) -> PathBuf { + // replace '/' and ':' with path separator + let path: String = label + .chars() + .map(|c| match c { + '/' | ':' => MAIN_SEPARATOR, + _ => c, + }) + .collect(); + let path = path.trim_start_matches(MAIN_SEPARATOR); + let mut path = PathBuf::from(path); + + if attempt == 0 { + path.push("test.log"); + } else { + path.push(format!("attempt_{}.log", attempt)); + } + + tmpdir.join(&path) +} + +#[allow(dead_code)] +fn make_tmpdir_path(should_create_dir_all: bool) -> Result { + let base = env::temp_dir(); + loop { + let i: u32 = rand::random(); + let tmpdir = base.join(format!("bazelci-agent-{}", i)); + if !tmpdir.exists() { + if should_create_dir_all { + fs::create_dir_all(&tmpdir)?; + } + return Ok(tmpdir); + } + } +} + +fn uri_to_file_path(uri: &str) -> Result { + const FILE_PROTOCOL: &'static str = "file://"; + if uri.starts_with(FILE_PROTOCOL) { + if let Ok(path) = url::Url::parse(uri)?.to_file_path() { + return Ok(path); + } + } + + Err(anyhow!("Invalid file URI: {}", uri)) +} + +fn upload_test_log( + dry: bool, + local_exec_root: Option<&Path>, + test_log: &str, + mode: Mode, +) -> Result<()> { + let path = uri_to_file_path(test_log)?; + let artifact = if let Some(local_exec_root) = local_exec_root { + if let Ok(relative_path) = path.strip_prefix(local_exec_root) { + relative_path + } else { + &path + } + } else { + &path + }; + + upload_artifact(dry, local_exec_root, &artifact, mode) +} + +#[derive(Debug)] +struct TestSummary { + overall_status: String, + failed: Vec, +} + +#[derive(Debug)] +struct FailedTest { + uri: String, +} + +struct BepJsonParser { + path: PathBuf, + offset: u64, + line: usize, + done: bool, + buf: String, + + local_exec_root: Option, + test_summaries: Vec, +} + +impl BepJsonParser { + pub fn new(path: &Path) -> BepJsonParser { + Self { + path: path.to_path_buf(), + offset: 0, + line: 1, + done: false, + buf: String::new(), + + local_exec_root: None, + test_summaries: Vec::new(), + } + } + + /// Parse the BEP JSON file until "last message" encounted or EOF reached. + /// + /// Errors encounted before "last message", e.g. + /// 1. Can't open/seek the file + /// 2. Can't decode the line into a JSON object + /// are propagated. + pub fn parse(&mut self) -> Result<()> { + let mut file = File::open(&self.path) + .with_context(|| format!("Failed to open file {}", self.path.display()))?; + file.seek(SeekFrom::Start(self.offset)).with_context(|| { + format!( + "Failed to seek file {} to offset {}", + self.path.display(), + self.offset + ) + })?; + + let mut reader = BufReader::new(file); + loop { + self.buf.clear(); + let bytes_read = reader.read_line(&mut self.buf)?; + if bytes_read == 0 { + return Ok(()); + } + match BuildEvent::from_json_str(&self.buf) { + Ok(build_event) => { + self.line += 1; + self.offset = self.offset + bytes_read as u64; + + if build_event.is_last_message() { + self.done = true; + return Ok(()); + } else if build_event.is_workspace() { + self.on_workspace(&build_event); + } else if build_event.is_test_summary() { + self.on_test_summary(&build_event); + } + } + Err(error) => { + return Err(anyhow!( + "{}:{}: {:?}", + self.path.display(), + self.line, + error + )); + } + } + } + } + + fn on_workspace(&mut self, build_event: &BuildEvent) { + self.local_exec_root = build_event + .get("workspaceInfo.localExecRoot") + .and_then(|v| v.as_str()) + .map(|str| Path::new(str).to_path_buf()); + } + + fn on_test_summary(&mut self, build_event: &BuildEvent) { + let overall_status = build_event + .get("testSummary.overallStatus") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let failed = build_event + .get("testSummary.failed") + .and_then(|v| v.as_array()) + .map(|failed| { + failed + .iter() + .map(|entry| FailedTest { + uri: entry + .get("uri") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }) + .collect::>() + }) + .unwrap_or(vec![]); + self.test_summaries.push(TestSummary { + overall_status, + failed, + }) + } + + pub fn has_overall_test_status(&self, status: &str) -> bool { + for test_log in self.test_summaries.iter() { + if test_log.overall_status == status { + return true; + } + } + + false + } +} + +pub struct BuildEvent { + value: Value, +} + +impl BuildEvent { + pub fn from_json_str(str: &str) -> Result { + let value = serde_json::from_str::(str)?; + if !value.is_object() { + return Err(anyhow!("Not a JSON object")); + } + + Ok(Self { value }) + } + + pub fn is_test_summary(&self) -> bool { + self.get("id.testSummary").is_some() + } + + pub fn is_workspace(&self) -> bool { + self.get("id.workspace").is_some() + } + + pub fn is_last_message(&self) -> bool { + self.get("lastMessage") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + } + + pub fn get(&self, path: &str) -> Option<&Value> { + let mut value = Some(&self.value); + for path in path.split(".") { + value = value.and_then(|value| value.get(path)); + } + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_label_to_path_works() { + let tmpdir = std::env::temp_dir(); + + assert_eq!( + test_label_to_path(&tmpdir, "//:test", 0), + tmpdir.join("test/test.log") + ); + + assert_eq!( + test_label_to_path(&tmpdir, "//foo/bar", 0), + tmpdir.join("foo/bar/test.log") + ); + + assert_eq!( + test_label_to_path(&tmpdir, "//foo/bar", 1), + tmpdir.join("foo/bar/attempt_1.log") + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn uri_to_file_path_works() { + assert_eq!( + &uri_to_file_path("file:///c:/foo/bar").unwrap(), + Path::new("c:/foo/bar") + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn uri_to_file_path_works() { + assert_eq!( + &uri_to_file_path("file:///foo/bar").unwrap(), + Path::new("/foo/bar") + ); + } +} diff --git a/agent/src/lib.rs b/agent/src/lib.rs new file mode 100644 index 0000000000..a876aa4a5f --- /dev/null +++ b/agent/src/lib.rs @@ -0,0 +1 @@ +pub mod artifact; \ No newline at end of file diff --git a/agent/src/main.rs b/agent/src/main.rs new file mode 100644 index 0000000000..712ec31140 --- /dev/null +++ b/agent/src/main.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use clap::arg_enum; +use std::{path::PathBuf, time::Duration}; +use structopt::StructOpt; + +use bazelci_agent::artifact::upload; + +arg_enum! { + #[allow(non_camel_case_types)] + enum UploadMode { + // Upload as Buildkite's artifacts + buildkite, + } +} + +/// Upload/download artifacts from Bazel CI tasks +#[derive(StructOpt)] +enum ArtifactCommand { + /// Upload artifacts (e.g. test logs for failed tests) from Bazel CI tasks + #[structopt(rename_all = "snake")] + Upload { + /// Don't actually upload files for debug purpose + #[structopt(long)] + dry: bool, + /// Upload various files for debug purpose + #[structopt(long)] + debug: bool, + /// The file contains the JSON serialisation of the build event protocol. + /// The agent "watches" this file until "last message" encountered + #[structopt(long, parse(from_os_str))] + build_event_json_file: Option, + /// The desitnation the artifacts should be uploaded to + #[structopt(long, possible_values = &UploadMode::variants(), case_insensitive = true)] + mode: Option, + /// The seconds to wait before watching the BEP file + #[structopt(long)] + delay: Option, + /// BEP json file is uploaded if there are flaky tests + #[structopt(long)] + monitor_flaky_tests: bool, + }, +} + +#[derive(StructOpt)] +#[structopt(rename_all = "snake")] +enum Command { + Artifact(ArtifactCommand), +} + +fn main() -> Result<()> { + let cmd = Command::from_args(); + + let subscriber = tracing_subscriber::FmtSubscriber::new(); + tracing::subscriber::set_global_default(subscriber).unwrap(); + + match cmd { + Command::Artifact(cmd) => match cmd { + ArtifactCommand::Upload { + dry, + debug, + build_event_json_file, + mode, + delay, + monitor_flaky_tests, + } => { + let mode = match mode { + Some(UploadMode::buildkite) => upload::Mode::Buildkite, + None => upload::Mode::Buildkite, + }; + upload::upload( + dry, + debug, + build_event_json_file.as_ref().map(|p| p.as_path()), + mode, + delay.map(|secs| Duration::from_secs(secs)), + monitor_flaky_tests, + )?; + } + }, + } + + Ok(()) +} diff --git a/agent/tests/artifact/mod.rs b/agent/tests/artifact/mod.rs new file mode 100644 index 0000000000..e1ed694e76 --- /dev/null +++ b/agent/tests/artifact/mod.rs @@ -0,0 +1 @@ +mod upload; \ No newline at end of file diff --git a/agent/tests/artifact/upload.rs b/agent/tests/artifact/upload.rs new file mode 100644 index 0000000000..dc51f7bee2 --- /dev/null +++ b/agent/tests/artifact/upload.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use assert_cmd::prelude::*; +use std::process::Command; + +#[cfg(target_os = "windows")] +#[test] +fn test_logs_uploaded_to_buildkite() -> Result<()> { + let mut cmd = Command::cargo_bin("bazelci-agent")?; + cmd.args([ + "artifact", + "upload", + "--dry", + "--mode=buildkite", + "--build_event_json_file=tests\\data\\test_bep_win.json", + ]); + cmd.assert() + .success() + .stdout(predicates::str::contains("buildkite-agent artifact upload bazel-out\\x64_windows-fastbuild\\testlogs\\src\\test\\shell\\bazel\\resource_compiler_toolchain_test\\test.log")); + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn test_logs_uploaded_to_buildkite() -> Result<()> { + let mut cmd = Command::cargo_bin("bazelci-agent")?; + cmd.args([ + "artifact", + "upload", + "--dry", + "--mode=buildkite", + "--build_event_json_file=tests/data/test_bep.json", + ]); + cmd.assert() + .success() + .stdout(predicates::str::contains("buildkite-agent artifact upload bazel-out/darwin-fastbuild/testlogs/src/test/shell/bazel/starlark_repository_test/shard_4_of_6/test_attempts/attempt_1.log")); + + Ok(()) +} diff --git a/agent/tests/data/test_bep.json b/agent/tests/data/test_bep.json new file mode 100644 index 0000000000..92d3c386c4 --- /dev/null +++ b/agent/tests/data/test_bep.json @@ -0,0 +1,3 @@ +{"id":{"workspace":{}},"workspaceInfo":{"localExecRoot":"/private/var/tmp/_bazel_buildkite/78a0792bf9bb0133b1a4a7d083181fcb/execroot/io_bazel"}} +{"id":{"testSummary":{"label":"//src/test/shell/bazel:starlark_repository_test","configuration":{"id":"7479eaa1eeb472e5c3fdd9f0b604289ffbe45a36edb8a7f474df0c95501b4d00"}}},"testSummary":{"totalRunCount":7,"failed":[{"uri":"file:///private/var/tmp/_bazel_buildkite/78a0792bf9bb0133b1a4a7d083181fcb/execroot/io_bazel/bazel-out/darwin-fastbuild/testlogs/src/test/shell/bazel/starlark_repository_test/shard_4_of_6/test_attempts/attempt_1.log"}],"overallStatus":"FLAKY","firstStartTimeMillis":"1630444947193","lastStopTimeMillis":"1630445154997","totalRunDurationMillis":"338280","runCount":1,"shardCount":6}} +{"id":{"progress":{}},"progress":{},"lastMessage":true} \ No newline at end of file diff --git a/agent/tests/data/test_bep_win.json b/agent/tests/data/test_bep_win.json new file mode 100644 index 0000000000..456dd2de4b --- /dev/null +++ b/agent/tests/data/test_bep_win.json @@ -0,0 +1,3 @@ +{"id":{"workspace":{}},"workspaceInfo":{"localExecRoot":"C:/b/vajal562/execroot/io_bazel"}} +{"id":{"testSummary":{"label":"//src/test/shell/bazel:resource_compiler_toolchain_test","configuration":{"id":"8d2a4b7b4d6c17e5b6c4a124717b04f611b2f08879ed37d414f7c5fdc0d1316f"}}},"testSummary":{"totalRunCount":3,"failed":[{"uri":"file:///C:/b/vajal562/execroot/io_bazel/bazel-out/x64_windows-fastbuild/testlogs/src/test/shell/bazel/resource_compiler_toolchain_test/test.log"}],"overallStatus":"FAILED","firstStartTimeMillis":"1631772381437","lastStopTimeMillis":"1631772391388","totalRunDurationMillis":"9951","runCount":1}} +{"id":{"progress": {}},"lastMessage":true} diff --git a/agent/tests/tests.rs b/agent/tests/tests.rs new file mode 100644 index 0000000000..26d246fa86 --- /dev/null +++ b/agent/tests/tests.rs @@ -0,0 +1 @@ +mod artifact; \ No newline at end of file