From 1b1fc16221aa8c7ac1ac2dc413035bae73fd146d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 14 Aug 2024 18:34:19 +0100 Subject: [PATCH 01/45] WIP tasks --- Cargo.lock | 407 +++++++++++++++++--- devenv.nix | 6 + devenv/Cargo.toml | 11 + devenv/src/cli.rs | 13 + devenv/src/devenv.rs | 37 +- devenv/src/lib.rs | 3 + devenv/src/main.rs | 9 +- devenv/src/tasks.rs | 759 ++++++++++++++++++++++++++++++++++++++ docs/.pages | 1 + docs/tasks.md | 53 +++ src/modules/tasks.nix | 101 +++++ src/modules/top-level.nix | 1 + tests/tasks/.gitignore | 2 + tests/tasks/devenv.nix | 19 + 14 files changed, 1369 insertions(+), 53 deletions(-) create mode 100644 devenv/src/tasks.rs create mode 100644 docs/tasks.md create mode 100644 src/modules/tasks.nix create mode 100644 tests/tasks/.gitignore create mode 100644 tests/tasks/devenv.nix diff --git a/Cargo.lock b/Cargo.lock index 52bc649fe..a392c0000 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,6 +83,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "autocfg" version = "1.1.0" @@ -154,9 +160,9 @@ checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" [[package]] name = "castaway" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" dependencies = [ "rustversion", ] @@ -314,6 +320,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.4.2", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -385,8 +416,10 @@ name = "devenv" version = "1.1.0" dependencies = [ "ansiterm", + "assert_matches", "clap", "cli-table", + "crossterm", "dotlock", "fs2", "hex", @@ -394,6 +427,7 @@ dependencies = [ "indoc", "miette", "nix", + "petgraph", "regex", "reqwest", "schemars", @@ -403,6 +437,10 @@ dependencies = [ "serde_yaml", "sha2", "tempdir", + "tempfile", + "test-log", + "thiserror", + "tokio", "tracing", "which", "whoami", @@ -479,6 +517,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -501,6 +560,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fnv" version = "1.0.7" @@ -678,6 +743,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hex" version = "0.4.3" @@ -863,7 +934,7 @@ checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" dependencies = [ "bitflags 2.4.2", "libc", - "redox_syscall", + "redox_syscall 0.4.1", ] [[package]] @@ -872,11 +943,21 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" -version = "0.4.20" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "markdown" @@ -887,6 +968,15 @@ dependencies = [ "unicode-id", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.1" @@ -941,13 +1031,15 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.10" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi", "libc", + "log", "wasi", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -980,6 +1072,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "object" version = "0.32.2" @@ -1045,18 +1147,57 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "owo-colors" version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caff54706df99d2a78a5a4e3455ff45448d81ef1bb63c22cd14052ca0e993a3f" +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.3", + "smallvec", + "windows-targets 0.52.6", +] + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.13" @@ -1139,6 +1280,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +dependencies = [ + "bitflags 2.4.2", +] + [[package]] name = "redox_users" version = "0.4.4" @@ -1158,8 +1308,17 @@ checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.5", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1170,9 +1329,15 @@ checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.2", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.2" @@ -1242,9 +1407,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.31" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.4.2", "errno", @@ -1349,6 +1514,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f3adfbe1c90a6a9643433e490ef1605c6a99f93be37e4c83fe5149fca9698c6" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "2.9.2" @@ -1461,6 +1632,45 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.9" @@ -1603,14 +1813,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.10.1" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" dependencies = [ "cfg-if", "fastrand", + "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1632,6 +1843,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "test-log" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dffced63c2b5c7be278154d76b479f9f9920ed34e7574201407f0b14e2bbb93" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5999e24eaa32083191ba4e425deb75cdf25efefabe5aaccb7446dd0d4122a3f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.51", +] + [[package]] name = "textwrap" version = "0.16.1" @@ -1645,24 +1878,34 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.57" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.57" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", "syn 2.0.51", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1680,17 +1923,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.36.0" +version = "1.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" dependencies = [ "backtrace", "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", - "windows-sys 0.48.0", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.51", ] [[package]] @@ -1752,6 +2008,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1807,9 +2092,9 @@ checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" [[package]] name = "unsafe-libyaml" @@ -1834,6 +2119,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1962,7 +2253,7 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" dependencies = [ - "redox_syscall", + "redox_syscall 0.4.1", "wasite", "web-sys", ] @@ -2013,7 +2304,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.3", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -2033,17 +2333,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.3" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d380ba1dc7187569a8a9e91ed34b8ccfc33123bbacb8c0aed2d1ad7f3ef2dc5f" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.3", - "windows_aarch64_msvc 0.52.3", - "windows_i686_gnu 0.52.3", - "windows_i686_msvc 0.52.3", - "windows_x86_64_gnu 0.52.3", - "windows_x86_64_gnullvm 0.52.3", - "windows_x86_64_msvc 0.52.3", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -2054,9 +2355,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.3" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68e5dcfb9413f53afd9c8f86e56a7b4d86d9a2fa26090ea2dc9e40fba56c6ec6" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -2066,9 +2367,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.3" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dab469ebbc45798319e69eebf92308e541ce46760b49b18c6b3fe5e8965b30f" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -2078,9 +2379,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.3" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4e9b6a7cac734a8b4138a4e1044eac3404d8326b6c0f939276560687a033fb" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -2090,9 +2397,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.3" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b0ec9c422ca95ff34a78755cfa6ad4a51371da2a5ace67500cf7ca5f232c58" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -2102,9 +2409,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.3" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704131571ba93e89d7cd43482277d6632589b18ecf4468f591fbae0a8b101614" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -2114,9 +2421,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.3" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42079295511643151e98d61c38c0acc444e52dd42ab456f7ccfd5152e8ecf21c" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -2126,9 +2433,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.3" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winreg" diff --git a/devenv.nix b/devenv.nix index 25961a272..666e91b80 100644 --- a/devenv.nix +++ b/devenv.nix @@ -2,6 +2,8 @@ env.DEVENV_NIX = inputs.nix.packages.${pkgs.stdenv.system}.nix; # ignore annoying browserlists warning that breaks pre-commit hooks env.BROWSERSLIST_IGNORE_OLD_DATA = "1"; + env.RUST_LOG = "devenv=debug"; + env.RUST_LOG_SPAN_EVENTS = "full"; packages = [ pkgs.cairo @@ -208,6 +210,10 @@ EOF ''; }; + + tasks.sleep.exec = "sleep 10"; + tasks.sleep.depends = [ "enterShell" ]; + pre-commit.hooks = { nixpkgs-fmt.enable = true; #shellcheck.enable = true; diff --git a/devenv/Cargo.toml b/devenv/Cargo.toml index ee0ecf2ed..209adb2d6 100644 --- a/devenv/Cargo.toml +++ b/devenv/Cargo.toml @@ -11,8 +11,10 @@ default-run = "devenv" [dependencies] ansiterm.workspace = true +assert_matches = "1.5.0" clap.workspace = true cli-table.workspace = true +crossterm = "0.28.1" dotlock.workspace = true fs2.workspace = true hex.workspace = true @@ -20,6 +22,7 @@ include_dir.workspace = true indoc.workspace = true miette.workspace = true nix.workspace = true +petgraph = "0.6.5" regex.workspace = true reqwest.workspace = true schemars.workspace = true @@ -29,6 +32,14 @@ serde_json.workspace = true serde_yaml.workspace = true sha2.workspace = true tempdir.workspace = true +tempfile = "3.12.0" +test-log = { version = "0.2.16", features = ["trace"] } +thiserror = "1.0.63" +tokio = { version = "1.39.3", features = [ + "process", + "macros", + "rt-multi-thread", +] } tracing.workspace = true which.workspace = true whoami.workspace = true diff --git a/devenv/src/cli.rs b/devenv/src/cli.rs index a9ecb0999..a6d1ef503 100644 --- a/devenv/src/cli.rs +++ b/devenv/src/cli.rs @@ -165,6 +165,12 @@ pub(crate) enum Commands { command: ProcessesCommand, }, + #[command(about = "Run tasks. https://devenv.sh/tasks/")] + Tasks { + #[command(subcommand)] + command: TasksCommand, + }, + #[command(about = "Run tests. http://devenv.sh/tests/", alias = "ci")] Test { #[arg(short, long, help = "Don't override .devenv to a temporary directory.")] @@ -241,6 +247,13 @@ pub(crate) enum ProcessesCommand { // TODO: Status/Attach } +#[derive(Subcommand, Clone)] +#[clap(about = "Run tasks. https://devenv.sh/tasks/")] +pub(crate) enum TasksCommand { + #[command(about = "Run tasks.")] + Run { tasks: Vec }, +} + #[derive(Subcommand, Clone)] #[clap( about = "Build, copy, or run a container. https://devenv.sh/containers/", diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index 5e8bbc2e4..3725c88f5 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -1,4 +1,4 @@ -use super::{cli, command, config, log}; +use super::{cli, command, config, log, tasks}; use clap::crate_version; use cli_table::Table; use cli_table::{print_stderr, WithTitle}; @@ -568,6 +568,41 @@ impl Devenv { Ok(self.has_processes.unwrap()) } + pub async fn tasks_run(&mut self, roots: Vec) -> Result<()> { + if roots.is_empty() { + bail!("No tasks specified."); + } + let config = { + let _logprogress = self.log_progress.with_newline("Evaluating tasks"); + self.run_nix( + "nix", + &[ + "build", + ".#devenv.task.config", + "--no-link", + "--print-out-paths", + ], + &command::Options::default(), + )? + }; + // parse tasks config + let config_content = + std::fs::read_to_string(String::from_utf8_lossy(&config.stdout).trim()) + .expect("Failed to read config file"); + let tasks: Vec = + serde_json::from_str(&config_content).expect("Failed to parse tasks config"); + // run tasks + let config = tasks::Config { roots, tasks }; + let mut tui = tasks::TasksUi::new(config).await?; + let tasks_status = tui.run().await?; + + if tasks_status.failed > 0 || tasks_status.dependency_failed > 0 { + Err(miette::bail!("Some tasks failed")) + } else { + Ok(()) + } + } + pub fn test(&mut self) -> Result<()> { self.assemble(true)?; diff --git a/devenv/src/lib.rs b/devenv/src/lib.rs index 1274afaa5..aa2c14302 100644 --- a/devenv/src/lib.rs +++ b/devenv/src/lib.rs @@ -1,8 +1,11 @@ +#[macro_use] +extern crate assert_matches; mod cli; pub mod command; pub mod config; mod devenv; pub mod log; +pub mod tasks; pub use cli::{default_system, GlobalOptions}; pub use devenv::{Devenv, DevenvOptions}; diff --git a/devenv/src/main.rs b/devenv/src/main.rs index 483cedf9a..7093bb656 100644 --- a/devenv/src/main.rs +++ b/devenv/src/main.rs @@ -3,13 +3,15 @@ mod command; mod config; mod devenv; mod log; +mod tasks; use clap::{crate_version, Parser}; -use cli::{Cli, Commands, ContainerCommand, InputsCommand, ProcessesCommand}; +use cli::{Cli, Commands, ContainerCommand, InputsCommand, ProcessesCommand, TasksCommand}; use devenv::Devenv; use miette::Result; -fn main() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { let cli = Cli::parse(); let level = if cli.global_options.verbose { @@ -126,6 +128,9 @@ fn main() -> Result<()> { } ProcessesCommand::Down {} => devenv.down(), }, + Commands::Tasks { command } => match command { + TasksCommand::Run { tasks } => devenv.tasks_run(tasks).await, + }, Commands::Inputs { command } => match command { InputsCommand::Add { name, url, follows } => devenv.inputs_add(&name, &url, &follows), }, diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs new file mode 100644 index 000000000..82a351385 --- /dev/null +++ b/devenv/src/tasks.rs @@ -0,0 +1,759 @@ +use assert_matches::assert_matches; +use crossterm::{ + cursor, execute, + style::{self, Stylize}, + terminal::{Clear, ClearType}, +}; +use miette::Diagnostic; +use petgraph::algo::toposort; +use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::{Dfs, EdgeRef}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::io::{self, Write}; +use std::process::Stdio; +use std::sync::Arc; +use std::{ + collections::{HashMap, HashSet}, + fs, +}; +use std::{fmt::Display, os::unix::fs::PermissionsExt}; +use test_log::test; +use thiserror::Error; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc::{channel, Receiver, Sender}; +use tokio::sync::{Mutex, RwLock}; +use tokio::task::JoinSet; +use tokio::time::{Duration, Instant}; +use tracing::{error, info, instrument}; + +#[derive(Error, Diagnostic, Debug)] +pub enum Error { + #[error(transparent)] + IoError(#[from] std::io::Error), + TaskNotFound(String), + TasksNotFound(Vec<(String, String)>), + InvalidTaskName(String), + // TODO: be more precies where the cycle happens + CycleDetected(String), +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::IoError(e) => write!(f, "IO Error: {}", e), + Error::TasksNotFound(tasks) => write!( + f, + "Task dependencies not found: {}", + tasks + .iter() + .map(|(task, dep)| format!("{} is depending on non-existent {}", task, dep)) + .collect::>() + .join(", ") + ), + Error::TaskNotFound(task) => write!(f, "Task does not exist: {}", task), + Error::CycleDetected(task) => write!(f, "Cycle detected at task: {}", task), + Error::InvalidTaskName(task) => write!( + f, + "Invalid task name: {}, expected [a-zA-Z-_]:[a-zA-Z-_]", + task + ), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct TaskConfig { + name: String, + #[serde(default)] + depends: Vec, + #[serde(default)] + command: Option, + #[serde(default)] + status: Option, +} + +#[derive(Deserialize)] +pub struct Config { + pub tasks: Vec, + pub roots: Vec, +} + +impl TryFrom for Config { + type Error = serde_json::Error; + + fn try_from(json: serde_json::Value) -> Result { + serde_json::from_value(json) + } +} + +#[derive(Debug)] +enum TaskCompleted { + Success(Duration), + Skipped, + Failed(Duration), + DependencyFailed, +} + +impl TaskCompleted { + fn has_failed(&self) -> bool { + matches!( + self, + TaskCompleted::Failed(_) | TaskCompleted::DependencyFailed + ) + } +} + +#[derive(Debug)] +enum TaskStatus { + Pending, + Running(Instant), + Completed(TaskCompleted), +} + +#[derive(Debug)] +struct TaskState { + task: TaskConfig, + status: TaskStatus, +} + +impl TaskState { + fn new(task: TaskConfig) -> Self { + Self { + task, + status: TaskStatus::Pending, + } + } + + #[instrument] + async fn run(&mut self) -> TaskCompleted { + let now = Instant::now(); + self.status = TaskStatus::Running(now); + if let Some(status) = &self.task.status { + let mut child = Command::new(status) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to execute status"); + + match child.wait().await { + Err(_) => {} + Ok(status) => { + if status.success() { + return TaskCompleted::Skipped; + } + } + } + } + if let Some(cmd) = &self.task.command { + let mut child = Command::new(cmd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to execute command"); + + let stdout = child.stdout.take().expect("Failed to open stdout"); + let stderr = child.stderr.take().expect("Failed to open stderr"); + + let mut stderr_reader = BufReader::new(stderr).lines(); + let mut stdout_reader = BufReader::new(stdout).lines(); + + loop { + tokio::select! { + result = stdout_reader.next_line() => { + match result { + Ok(Some(line)) => info!(stdout = %line), + Ok(None) => break, + Err(e) => error!("Error reading stdout: {}", e), + } + } + result = stderr_reader.next_line() => { + match result { + Ok(Some(line)) => error!(stderr = %line), + Ok(None) => break, + Err(e) => error!("Error reading stderr: {}", e), + } + } + result = child.wait() => { + match result { + Ok(status) => { + if status.success() { + return TaskCompleted::Success(now.elapsed()); + } else { + return TaskCompleted::Failed(now.elapsed()); + } + }, + Err(e) => { + error!("Error waiting for command: {}", e); + return TaskCompleted::Failed(now.elapsed()); + } + } + } + } + } + } + return TaskCompleted::Skipped; + } +} + +#[derive(Debug)] +struct Tasks { + roots: Vec, + sender_tx: Sender, + graph: DiGraph>, ()>, + tasks_order: Vec, +} + +impl Tasks { + async fn new(config: Config) -> Result<(Self, Receiver), Error> { + let (sender_tx, receiver_rx) = channel(1000); + let mut graph = DiGraph::new(); + let mut task_indices = HashMap::new(); + for task in config.tasks { + let name = task.name.clone(); + if !task.name.contains(':') + || task.name.split(':').count() != 2 + || task.name.starts_with(':') + || task.name.ends_with(':') + || !task + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == ':' || c == '_' || c == '-') + { + return Err(Error::InvalidTaskName(name)); + } + let index = graph.add_node(Arc::new(RwLock::new(TaskState::new(task)))); + task_indices.insert(name, index); + } + let mut roots = Vec::new(); + for name in config.roots { + if let Some(index) = task_indices.get(&name) { + roots.push(*index); + } else { + return Err(Error::TaskNotFound(name)); + } + } + let mut tasks = Self { + roots, + sender_tx, + graph, + tasks_order: vec![], + }; + tasks.resolve_dependencies(task_indices).await?; + tasks.schedule().await?; + Ok((tasks, receiver_rx)) + } + + async fn resolve_dependencies( + &mut self, + task_indices: HashMap, + ) -> Result<(), Error> { + let mut unresolved = HashSet::new(); + let mut edges_to_add = Vec::new(); + + for index in self.graph.node_indices() { + let task_state = &self.graph[index].read().await; + + for dep_name in &task_state.task.depends { + if let Some(dep_idx) = task_indices.get(dep_name) { + edges_to_add.push((*dep_idx, index)); + } else { + unresolved.insert((task_state.task.name.clone(), dep_name.clone())); + } + } + } + + for (dep_idx, idx) in edges_to_add { + self.graph.add_edge(dep_idx, idx, ()); + } + + if unresolved.is_empty() { + Ok(()) + } else { + Err(Error::TasksNotFound(unresolved.into_iter().collect())) + } + } + + #[instrument(skip(self))] + async fn schedule(&mut self) -> Result<(), Error> { + // TODO: we traverse the graph twice, see https://github.com/petgraph/petgraph/issues/661 + let mut subgraph = DiGraph::new(); + + // Map to track which nodes in the original graph correspond to which nodes in the new subgraph + let mut node_map = HashMap::new(); + let mut visited = HashSet::new(); + + // Traverse the graph starting from the root nodes + for root_index in &self.roots { + let mut dfs = Dfs::new(&self.graph, *root_index); + + while let Some(node) = dfs.next(&self.graph) { + if visited.insert(node) { + // Add the node to the new subgraph and map it + let new_node = subgraph.add_node(self.graph[node].clone()); + node_map.insert(node, new_node); + + // Copy edges to the new subgraph + for edge in self.graph.edges(node) { + let target = edge.target(); + if visited.contains(&target) { + // Both nodes must already be added to subgraph + let new_source = node_map[&node]; + let new_target = node_map[&target]; + subgraph.add_edge(new_source, new_target, ()); + } + } + } + } + } + + self.graph = subgraph; + + match toposort(&self.graph, None) { + Ok(indexes) => { + self.tasks_order = indexes; + Ok(()) + } + Err(cycle) => Err(Error::CycleDetected( + self.graph[cycle.node_id()].read().await.task.name.clone(), + )), + } + } + + #[instrument(skip(self))] + async fn run(&mut self) -> Result<(), Error> { + let mut running_tasks = JoinSet::new(); + + for index in &self.tasks_order { + let task_state = &self.graph[*index]; + + loop { + let mut dependencies_completed = true; + for dep_index in self + .graph + .neighbors_directed(*index, petgraph::Direction::Outgoing) + { + match &self.graph[dep_index].read().await.status { + TaskStatus::Completed(completed) => { + if completed.has_failed() { + let mut task_state = self.graph[dep_index].write().await; + task_state.status = + TaskStatus::Completed(TaskCompleted::DependencyFailed); + continue; + } + } + TaskStatus::Pending => { + dependencies_completed = false; + break; + } + TaskStatus::Running(_) => { + dependencies_completed = false; + break; + } + } + } + + if dependencies_completed { + break; + } + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + let task_state_clone = Arc::clone(task_state); + + running_tasks.spawn(async move { + let mut task_state = task_state_clone.write().await; + let completed = task_state.run().await; + task_state.status = TaskStatus::Completed(completed); + }); + } + + while let Some(res) = running_tasks.join_next().await { + match res { + Ok(_) => (), + Err(e) => eprintln!("Task failed: {:?}", e), + } + } + + Ok(()) + } +} + +struct TaskUpdate { + name: String, + status: TaskStatus, +} + +pub struct TasksStatus { + lines: Vec, + pub pending: usize, + pub running: usize, + pub succeeded: usize, + pub failed: usize, + pub skipped: usize, + pub dependency_failed: usize, +} + +impl TasksStatus { + fn new() -> Self { + Self { + lines: vec![], + pending: 0, + running: 0, + succeeded: 0, + failed: 0, + skipped: 0, + dependency_failed: 0, + } + } +} + +pub struct TasksUi { + tasks: Arc>, + receiver_rx: Receiver, +} + +impl TasksUi { + pub async fn new(config: Config) -> Result { + let (tasks, receiver_rx) = Tasks::new(config).await?; + Ok(Self { + tasks: Arc::new(Mutex::new(tasks)), + receiver_rx, + }) + } + + async fn get_tasks_status(&self) -> TasksStatus { + let mut tasks_status = TasksStatus::new(); + let tasks = self.tasks.lock().await; + + for index in &tasks.tasks_order { + let task_state = tasks.graph[*index].read().await; + let (status_text, duration) = match &task_state.status { + TaskStatus::Pending => { + tasks_status.pending += 1; + continue; + } + TaskStatus::Running(started) => { + tasks_status.running += 1; + ("Running".blue().bold(), Some(started.elapsed())) + } + TaskStatus::Completed(TaskCompleted::Skipped) => { + tasks_status.skipped += 1; + ("Skipped".blue().bold(), None) + } + TaskStatus::Completed(TaskCompleted::Success(duration)) => { + tasks_status.succeeded += 1; + ("Succeeded".green().bold(), Some(*duration)) + } + TaskStatus::Completed(TaskCompleted::Failed(duration)) => { + tasks_status.failed += 1; + ("Failed".red().bold(), Some(*duration)) + } + TaskStatus::Completed(TaskCompleted::DependencyFailed) => { + tasks_status.dependency_failed += 1; + ("Dependency failed".magenta().bold(), None) + } + }; + + let duration = match duration { + Some(d) => d.as_millis().to_string() + "ms", + None => "".to_string(), + }; + tasks_status.lines.push(format!( + "{} {} {}", + status_text, &task_state.task.name, duration + )); + } + + tasks_status + } + + pub async fn run(&mut self) -> Result { + // start processing tasks + let tasks_clone = Arc::clone(&self.tasks); + let handle = tokio::spawn(async move { + let mut tasks = tasks_clone.lock().await; + if let Err(e) = tasks.run().await { + eprintln!("Error running tasks: {:?}", e); + } + }); + + // start TUI + let mut stdout = io::stdout(); + let mut last_list_height: u16 = 0; + + loop { + let tasks_status = self.get_tasks_status().await; + + execute!( + stdout, + // Clear the screen from the cursor down + cursor::MoveUp(last_list_height), + Clear(ClearType::FromCursorDown), + style::PrintStyledContent( + format!( + "{}\nTasks: {}\n", + tasks_status.lines.join("\n"), + [ + if tasks_status.pending > 0 { + format!("{} {}", "Pending".blue().bold(), tasks_status.pending) + } else { + String::new() + }, + if tasks_status.running > 0 { + format!("{} {}", "Running".blue().bold(), tasks_status.running) + } else { + String::new() + }, + if tasks_status.skipped > 0 { + format!("{} {}", "Skipped".blue().bold(), tasks_status.skipped) + } else { + String::new() + }, + if tasks_status.succeeded > 0 { + format!("{} {}", "Succeeded".green().bold(), tasks_status.succeeded) + } else { + String::new() + }, + if tasks_status.failed > 0 { + format!("{} {}", "Failed".red().bold(), tasks_status.failed) + } else { + String::new() + }, + if tasks_status.dependency_failed > 0 { + format!( + "{} {}", + "Dependency Failed".red().bold(), + tasks_status.dependency_failed + ) + } else { + String::new() + }, + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(", ") + ) + .stylize() + ) + )?; + + last_list_height = tasks_status.lines.len() as u16 + 1; + + if handle.is_finished() { + break; + } + + // Sleep briefly to avoid excessive redraws + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + let tasks_status = self.get_tasks_status().await; + Ok(tasks_status) + } +} + +#[test(tokio::test)] +async fn test_task_name() -> Result<(), Error> { + let invalid_names = vec![ + "invalid:name!", + "invalid name", + "invalid@name", + ":invalid", + "invalid:", + "invalid", + ]; + for task in invalid_names { + assert_matches!( + Config::try_from(json!({ + "roots": [], + "tasks": [{ + "name": task.to_string() + }] + })) + .map(Tasks::new) + .unwrap() + .await, + Err(Error::InvalidTaskName(_)) + ); + } + let valid_names = vec![ + "devenv:enterShell", + "devenv:enter-shell", + "devenv:enter_shell", + ]; + for task in valid_names { + assert_matches!( + Config::try_from(serde_json::json!({ + "roots": [], + "tasks": [{ + "name": task.to_string() + }] + })) + .map(Tasks::new) + .unwrap() + .await, + Ok(_) + ); + } + Ok(()) +} + +#[test(tokio::test)] +async fn test_basic_tasks() -> Result<(), Error> { + let script1 = + create_script("#!/bin/sh\necho 'Task 1 is running' && sleep 2 && echo 'Task 1 completed'")?; + let script2 = + create_script("#!/bin/sh\necho 'Task 2 is running' && sleep 3 && echo 'Task 2 completed'")?; + let script3 = + create_script("#!/bin/sh\necho 'Task 3 is running' && sleep 1 && echo 'Task 3 completed'")?; + let script4 = create_script("#!/bin/sh\necho 'Task 4 is running' && echo 'Task 4 completed'")?; + + let (mut tasks, _) = Tasks::new( + Config::try_from(json!({ + "roots": ["myapp:task_1", "myapp:task_4"], + "tasks": [ + { + "name": "myapp:task_1", + "command": script1.to_str().unwrap() + }, + { + "name": "myapp:task_2", + "command": script2.to_str().unwrap() + }, + { + "name": "myapp:task_3", + "depends": ["myapp:task_1"], + "command": script3.to_str().unwrap() + }, + { + "name": "myapp:task_4", + "depends": ["myapp:task_3"], + "command": script4.to_str().unwrap() + } + ] + })) + .unwrap(), + ) + .await?; + tasks.run().await?; + + // Assert the order is 1, 3, 4 and they all succeed + assert_eq!(tasks.tasks_order.len(), 3); + assert_matches!( + tasks.graph[tasks.tasks_order[0]].read().await.status, + TaskStatus::Completed(TaskCompleted::Success(_)) + ); + assert_eq!( + tasks.graph[tasks.tasks_order[0]].read().await.task.name, + "myapp:task_1" + ); + assert_matches!( + tasks.graph[tasks.tasks_order[1]].read().await.status, + TaskStatus::Completed(TaskCompleted::Success(_)) + ); + assert_eq!( + tasks.graph[tasks.tasks_order[1]].read().await.task.name, + "myapp:task_3" + ); + assert_matches!( + tasks.graph[tasks.tasks_order[2]].read().await.status, + TaskStatus::Completed(TaskCompleted::Success(_)) + ); + assert_eq!( + tasks.graph[tasks.tasks_order[2]].read().await.task.name, + "myapp:task_4" + ); + Ok(()) +} + +// #[test(tokio::test)] +// async fn test_tasks_cycle() -> Result<(), Error> { +// let (mut tasks, _) = Tasks::new( +// Config::try_from(json!({ +// "roots": ["myapp:task_1"], +// "tasks": [ +// { +// "name": "myapp:task_1", +// "depends": ["myapp:task_2"], +// "command": "echo 'Task 1 is running' && sleep 2 && echo 'Task 1 completed'" +// }, +// { +// "name": "myapp:task_2", +// "depends": ["myapp:task_1"], +// "command": "echo 'Task 2 is running' && sleep 3 && echo 'Task 2 completed'" +// } +// ] +// })) +// .unwrap(), +// ) +// .await?; + +// let err = "myapp_task_2".to_string(); + +// assert!(matches!(tasks.run().await, Err(Error::CycleDetected(err)))); +// Ok(()) +// } + +#[test(tokio::test)] +async fn test_status() -> Result<(), Error> { + let run_task = |root: &'static str| async move { + let command_script1 = + create_script("#!/bin/sh\necho 'Task 1 is running' && echo 'Task 1 completed'")?; + let status_script1 = create_script("#!/bin/sh\nexit 0")?; + let command_script2 = + create_script("#!/bin/sh\necho 'Task 2 is running' && echo 'Task 2 completed'")?; + let status_script2 = create_script("#!/bin/sh\nexit 1")?; + + Tasks::new( + Config::try_from(json!({ + "roots": [root], + "tasks": [ + { + "name": "myapp:task_1", + "command": command_script1.to_str().unwrap(), + "status": status_script1.to_str().unwrap() + }, + { + "name": "myapp:task_2", + "command": command_script2.to_str().unwrap(), + "status": status_script2.to_str().unwrap() + } + ] + })) + .unwrap(), + ) + .await + }; + + let (mut tasks, _) = run_task("myapp:task_1").await.unwrap(); + tasks.run().await?; + assert_matches!( + tasks.graph[tasks.tasks_order[0]].read().await.status, + TaskStatus::Completed(TaskCompleted::Skipped) + ); + + let (mut tasks, _) = run_task("myapp:task_2").await.unwrap(); + tasks.run().await?; + assert_matches!( + tasks.graph[tasks.tasks_order[0]].read().await.status, + TaskStatus::Completed(TaskCompleted::Success(_)) + ); + + Ok(()) +} + +fn create_script(script: &str) -> std::io::Result { + let mut temp_file = tempfile::Builder::new() + .prefix(&format!("script")) + .suffix(".sh") + .tempfile()?; + temp_file.write_all(script.as_bytes())?; + temp_file + .as_file_mut() + .set_permissions(fs::Permissions::from_mode(0o755))?; + Ok(temp_file.into_temp_path()) +} diff --git a/docs/.pages b/docs/.pages index 003f29a09..2deffd37f 100644 --- a/docs/.pages +++ b/docs/.pages @@ -7,6 +7,7 @@ nav: - Basics: basics.md - Packages: packages.md - Scripts: scripts.md + - Tasks: tasks.md - Languages: - Overview: languages.md - Supported Languages: supported-languages diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 000000000..cf97a7746 --- /dev/null +++ b/docs/tasks.md @@ -0,0 +1,53 @@ +Tasks allow you to form dependencies between commands, executed in parallel. + +## Defining tasks + +```nix title="devenv.nix" +{ pkgs, ... }: + +{ + tasks."myapp:hello" = { + exec = ''echo "Hello, world!"''; + desc = "hello world in bash"; + }; +} +``` + +```shell-session +$ devenv tasks run hello +• Building shell ... +• Entering shell ... +Hello, world! +$ +``` + +## Using your favourite language + +Tasks can also reference scripts and depend on other tasks, for example when entering the shell: + +```nix title="devenv.nix" +{ pkgs, lib, config, ... }: + +{ + tasks = { + "python:hello"" = { + exec = ''print("Hello world from Python!")''; + package = config.languages.python.package; + }; + "bash:hello" = { + exec = "echo 'Hello world from bash!'"; + depends = [ "python:hello" ]; + }; + enterShell.depends = [ "bash:hello" ]; + }; +} +``` + +```shell-session +$ devenv shell +• Building shell ... +• Entering shell ... +Hello world from Python! +Hello world from bash! +$ +``` diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix new file mode 100644 index 000000000..5eb840e5b --- /dev/null +++ b/src/modules/tasks.nix @@ -0,0 +1,101 @@ +{ pkgs, lib, config, ... }: +let + types = lib.types; + taskType = types.submodule + ({ name, config, ... }: + let + mkCommand = command: + if builtins.isNull command + then null + else + pkgs.writeScriptBin name '' + #!${pkgs.lib.getBin config.package}/bin/${config.binary} + ${command} + ''; + in + { + options = { + exec = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = "Command to execute the task."; + }; + binary = lib.mkOption { + type = types.str; + description = "Override the binary name if it doesn't match package name"; + default = config.package.pname; + }; + package = lib.mkOption { + type = types.nullOr types.package; + default = null; + description = "Package to install for this task."; + }; + command = lib.mkOption { + type = types.nullOr types.package; + internal = true; + default = mkCommand config.exec; + description = "Path to the script to run."; + }; + status = lib.mkOption { + type = types.nullOr types.str; + default = null; + description = "Check if the command should be ran"; + }; + statusCommand = lib.mkOption { + type = types.nullOr types.package; + internal = true; + default = mkCommand config.exec; + description = "Path to the script to run."; + }; + config = lib.mkOption { + type = types.attrsOf types.anything; + internal = true; + default = { + name = name; + description = config.description; + status = config.statusCommand; + depends = config.depends; + command = config.command; + }; + }; + description = lib.mkOption { + type = types.str; + default = ""; + description = "Description of the task."; + }; + depends = lib.mkOption { + type = types.listOf types.str; + description = "List of tasks to run before this task."; + default = [ ]; + }; + }; + }); +in +{ + options.tasks = lib.mkOption { + type = types.attrsOf taskType; + }; + + options.task.config = lib.mkOption { + type = types.package; + internal = true; + default = (pkgs.formats.json { }).generate "tasks.json" (lib.mapAttrsToList (name: value: { inherit name; } // value) config.tasks); + }; + + config = { + info.infoSections.tasks = + lib.mapAttrsToList + (name: task: "${name}: ${task.description} ${task.command}") + config.tasks; + tasks = { + "devenv:enterShell" = { + description = "Runs when entering the shell"; + }; + "devenv:enterTest" = { + description = "Runs when entering the test environment"; + }; + }; + #enterShell = "devenv tasks run devenv:enterShell"; + #enterTest = "devenv tasks run devenv:enterTest"; + }; +} diff --git a/src/modules/top-level.nix b/src/modules/top-level.nix index fb7aa2272..d0a3f8bb3 100644 --- a/src/modules/top-level.nix +++ b/src/modules/top-level.nix @@ -228,6 +228,7 @@ in ./lib.nix ./tests.nix ./cachix.nix + ./tasks.nix ] ++ (listEntries ./languages) ++ (listEntries ./services) diff --git a/tests/tasks/.gitignore b/tests/tasks/.gitignore new file mode 100644 index 000000000..c75670085 --- /dev/null +++ b/tests/tasks/.gitignore @@ -0,0 +1,2 @@ +shell +test diff --git a/tests/tasks/devenv.nix b/tests/tasks/devenv.nix new file mode 100644 index 000000000..9bbe1a812 --- /dev/null +++ b/tests/tasks/devenv.nix @@ -0,0 +1,19 @@ +{ + tasks = { + shell.exec = "touch shell"; + enterShell.depends = [ "shell" ]; + test.exec = "touch test"; + }; + + enterTest = '' + if [ ! -f shell ]; then + echo "shell does not exist" + exit 1 + fi + devenv tasks run test + if [ ! -f test ]; then + echo "test does not exist" + exit 1 + fi + ''; +} From d7ec351d3d997463c447b7f07d46b046e5d9db25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 30 Aug 2024 13:26:12 +0100 Subject: [PATCH 02/45] Use async and factor out Nix abstraction into it's own impl --- Cargo.lock | 31 +- devenv-run-tests/Cargo.toml | 1 + devenv-run-tests/src/main.rs | 15 +- devenv/Cargo.toml | 2 +- devenv/src/cnix.rs | 726 +++++++++++++++++++++++++++++++++++ devenv/src/command.rs | 454 ---------------------- devenv/src/devenv.rs | 503 ++++++++---------------- devenv/src/lib.rs | 4 +- devenv/src/log.rs | 2 +- devenv/src/main.rs | 38 +- devenv/src/tasks.rs | 133 ++++--- 11 files changed, 1028 insertions(+), 881 deletions(-) create mode 100644 devenv/src/cnix.rs delete mode 100644 devenv/src/command.rs diff --git a/Cargo.lock b/Cargo.lock index a392c0000..f2a83cd4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,12 +83,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "assert_matches" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" - [[package]] name = "autocfg" version = "1.1.0" @@ -416,7 +410,6 @@ name = "devenv" version = "1.1.0" dependencies = [ "ansiterm", - "assert_matches", "clap", "cli-table", "crossterm", @@ -428,6 +421,7 @@ dependencies = [ "miette", "nix", "petgraph", + "pretty_assertions", "regex", "reqwest", "schemars", @@ -454,8 +448,15 @@ dependencies = [ "clap", "devenv", "tempdir", + "tokio", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1216,6 +1217,16 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.78" @@ -2463,3 +2474,9 @@ dependencies = [ "devenv", "miette", ] + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/devenv-run-tests/Cargo.toml b/devenv-run-tests/Cargo.toml index 761473b2a..b13c40344 100644 --- a/devenv-run-tests/Cargo.toml +++ b/devenv-run-tests/Cargo.toml @@ -9,3 +9,4 @@ clap.workspace = true tempdir.workspace = true devenv= { path = "../devenv" } +tokio = "1.39.3" diff --git a/devenv-run-tests/src/main.rs b/devenv-run-tests/src/main.rs index b6e7cb345..2d9433c1b 100644 --- a/devenv-run-tests/src/main.rs +++ b/devenv-run-tests/src/main.rs @@ -32,7 +32,9 @@ struct TestResult { passed: bool, } -fn run_tests_in_directory(args: &Args) -> Result, Box> { +async fn run_tests_in_directory( + args: &Args, +) -> Result, Box> { let logger = Logger::new(Level::Info); logger.info("Running Tests"); @@ -118,11 +120,13 @@ fn run_tests_in_directory(args: &Args) -> Result, Box Result, Box Result<(), Box> { +#[tokio::main] +async fn main() -> Result<(), Box> { let args = Args::parse(); - let test_results = run_tests_in_directory(&args)?; + let test_results = run_tests_in_directory(&args).await?; let num_tests = test_results.len(); let num_failed_tests = test_results.iter().filter(|r| !r.passed).count(); diff --git a/devenv/Cargo.toml b/devenv/Cargo.toml index 209adb2d6..0db421a0b 100644 --- a/devenv/Cargo.toml +++ b/devenv/Cargo.toml @@ -11,7 +11,6 @@ default-run = "devenv" [dependencies] ansiterm.workspace = true -assert_matches = "1.5.0" clap.workspace = true cli-table.workspace = true crossterm = "0.28.1" @@ -23,6 +22,7 @@ indoc.workspace = true miette.workspace = true nix.workspace = true petgraph = "0.6.5" +pretty_assertions = { version = "1.4.0", features = ["unstable"] } regex.workspace = true reqwest.workspace = true schemars.workspace = true diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs new file mode 100644 index 000000000..db5233dc1 --- /dev/null +++ b/devenv/src/cnix.rs @@ -0,0 +1,726 @@ +use crate::{cli, config, log}; +use miette::{bail, IntoDiagnostic, Result, WrapErr}; +use serde::Deserialize; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::os::unix::fs::symlink; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub struct Nix<'a> { + logger: log::Logger, + pub options: Options<'a>, + // TODO: all these shouldn't be here + config: Arc, + global_options: Arc, + cachix_caches: Option, + cachix_trusted_keys: Arc, + devenv_home_gc: Arc, + devenv_dot_gc: Arc, + devenv_root: Arc, +} + +#[derive(Copy, Clone)] +pub struct Options<'a> { + pub replace_shell: bool, + pub logging: bool, + pub logging_stdout: bool, + pub nix_flags: &'a [&'a str], +} + +impl<'a> Nix<'a> { + pub fn new( + logger: log::Logger, + config: Arc, + global_options: Arc, + cachix_trusted_keys: Arc, + devenv_home_gc: Arc, + devenv_dot_gc: Arc, + devenv_root: Arc, + ) -> Self { + Nix { + logger, + cachix_caches: None, + config, + global_options, + options: Options { + replace_shell: false, + logging: true, + logging_stdout: false, + nix_flags: &[ + "--show-trace", + "--extra-experimental-features", + "nix-command", + "--extra-experimental-features", + "flakes", + "--option", + "warn-dirty", + "false", + "--keep-going", + ], + }, + cachix_trusted_keys, + devenv_home_gc, + devenv_dot_gc, + devenv_root, + } + } + + pub async fn develop(&mut self, args: &[&str], replace_shell: bool) -> Result { + let options = Options { + logging_stdout: true, + replace_shell, + ..self.options + }; + let mut full_args = vec!["develop"]; + full_args.extend_from_slice(args); + self.run_nix_with_substituters("nix", &full_args, &options) + .await + } + + pub async fn dev_env(&mut self, json: bool, gc_root: &PathBuf) -> Result> { + let gc_root_str = gc_root.to_str().expect("gc root should be utf-8"); + let mut args: Vec<&str> = vec!["print-dev-env", "--profile", gc_root_str]; + if json { + args.push("--json"); + } + + let options = Options { ..self.options }; + let env = self + .run_nix_with_substituters("nix", &args, &options) + .await?; + + let options = Options { + logging: false, + ..self.options + }; + + let args: Vec<&str> = vec!["-p", gc_root_str, "--delete-generations", "old"]; + self.run_nix("nix-env", &args, &options)?; + let now_ns = get_now_with_nanoseconds(); + let target = format!("{}-shell", now_ns); + symlink_force( + &self.logger, + &fs::canonicalize(&gc_root).expect("to resolve gc_root"), + &self.devenv_home_gc.join(target), + ); + Ok(env.stdout) + } + + pub fn add_gc(&mut self, name: &str, path: &Path) -> Result<()> { + let options = self.options; + self.run_nix( + "nix-store", + &[ + "--add-root", + self.devenv_dot_gc.join(name).to_str().unwrap(), + "-r", + path.to_str().unwrap(), + ], + &options, + )?; + let link_path = self + .devenv_dot_gc + .join(format!("{}-{}", name, get_now_with_nanoseconds())); + symlink_force(&self.logger, path, &link_path); + Ok(()) + } + + pub fn repl(&mut self) -> Result<()> { + let options = self.options; + let mut cmd = self.prepare_command("nix", &["repl", "."], &options)?; + cmd.exec(); + Ok(()) + } + + pub async fn build(&mut self, attributes: &[&str]) -> Result> { + let options = self.options; + let build_attrs: Vec = if attributes.is_empty() { + // construct dotted names of all attributes that we need to build + let build_output = self.eval(&[".#build"]).await?; + serde_json::from_str::(&build_output) + .map_err(|e| miette::miette!("Failed to parse build output: {}", e))? + .as_object() + .ok_or_else(|| miette::miette!("Build output is not an object"))? + .iter() + .flat_map(|(key, value)| { + fn flatten_object(prefix: &str, value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::Object(obj) => obj + .iter() + .flat_map(|(k, v)| flatten_object(&format!("{}.{}", prefix, k), v)) + .collect(), + _ => vec![format!(".#devenv.{}", prefix)], + } + } + flatten_object(key, value) + }) + .collect() + } else { + attributes + .iter() + .map(|attr| format!(".#devenv.{}", attr)) + .collect() + }; + + if !build_attrs.is_empty() { + // TODO: use eval underneath + let mut args = vec!["build", "--no-link", "--print-out-paths"]; + args.extend(attributes); + let output = self + .run_nix_with_substituters("nix", &args, &options) + .await?; + Ok(String::from_utf8_lossy(&output.stdout) + .to_string() + .trim() + .split_whitespace() + .map(|s| PathBuf::from(s.to_string())) + .collect()) + } else { + Ok(Vec::new()) + } + } + + pub async fn eval(&mut self, attributes: &[&str]) -> Result { + let mut args: Vec = vec!["eval", "--json"] + .into_iter() + .map(String::from) + .collect(); + args.extend(attributes.into_iter().map(|attr| format!(".#{}", attr))); + let args = &args.iter().map(|s| s.as_str()).collect::>(); + let options = self.options; + let result = self.run_nix("nix", &args, &options)?; + String::from_utf8(result.stdout) + .map_err(|err| miette::miette!("Failed to parse command output as UTF-8: {}", err)) + } + + pub fn update(&mut self, input_name: &Option) -> Result<()> { + let options = self.options; + match input_name { + Some(input_name) => { + self.run_nix( + "nix", + &["flake", "lock", "--update-input", input_name], + &options, + )?; + } + None => { + self.run_nix("nix", &["flake", "update"], &options)?; + } + } + Ok(()) + } + + pub fn metadata(&mut self) -> Result { + // TODO: use --json + let options = self.options; + let metadata = self.run_nix("nix", &["flake", "metadata"], &options)?; + + let re = regex::Regex::new(r"(Inputs:.+)$").unwrap(); + let metadata_str = String::from_utf8_lossy(&metadata.stdout); + let inputs = match re.captures(&metadata_str) { + Some(captures) => captures.get(1).unwrap().as_str(), + None => "", + }; + + let info_ = self.run_nix("nix", &["eval", "--raw", ".#info"], &options)?; + Ok(format!( + "{}\n{}", + inputs, + &String::from_utf8_lossy(&info_.stdout) + )) + } + + pub async fn search(&mut self, name: &str) -> Result { + let options = self.options; + self.run_nix_with_substituters("nix", &["search", "--json", "nixpkgs", name], &options) + .await + } + + pub fn gc(&mut self, paths: Vec) -> Result<()> { + let options = self.options; + let paths: std::collections::HashSet<&str> = paths + .iter() + .filter_map(|path_buf| path_buf.to_str()) + .collect(); + for path in paths { + self.logger.info(&format!("Deleting {}...", path)); + let args: Vec<&str> = ["store", "delete", path].iter().copied().collect(); + let cmd = self.prepare_command("nix", &args, &options); + // we ignore if this command fails, because root might be in use + let _ = cmd?.output(); + } + Ok(()) + } + + // Run Nix with debugger capability and return the output + pub fn run_nix( + &mut self, + command: &str, + args: &[&str], + options: &Options<'a>, + ) -> Result { + let cmd = self.prepare_command(command, args, options)?; + self.run_nix_command(cmd, options) + } + + pub async fn run_nix_with_substituters( + &mut self, + command: &str, + args: &[&str], + options: &Options<'a>, + ) -> Result { + let cmd = self + .prepare_command_with_substituters(command, args, options) + .await?; + self.run_nix_command(cmd, options) + } + + fn run_nix_command( + &mut self, + mut cmd: std::process::Command, + options: &Options<'a>, + ) -> Result { + let prev_level = self.logger.level.clone(); + if !options.logging { + self.logger.level = log::Level::Error; + } + + if options.replace_shell { + if self.global_options.nix_debugger + && cmd.get_program().to_string_lossy().ends_with("bin/nix") + { + cmd.arg("--debugger"); + } + let error = cmd.exec(); + self.logger.error(&format!( + "Failed to replace shell with `{}`: {error}", + display_command(&cmd), + )); + bail!("Failed to replace shell") + } else { + if options.logging { + cmd.stdin(process::Stdio::inherit()) + .stderr(process::Stdio::inherit()); + if options.logging_stdout { + cmd.stdout(std::process::Stdio::inherit()); + } + } + + let result = cmd + .output() + .into_diagnostic() + .wrap_err_with(|| format!("Failed to run command `{}`", display_command(&cmd)))?; + + if !result.status.success() { + let code = match result.status.code() { + Some(code) => format!("with exit code {}", code), + None => "without exit code".to_string(), + }; + if options.logging { + eprintln!(); + self.logger.error(&format!( + "Command produced the following output:\n{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr), + )); + } + if self.global_options.nix_debugger + && cmd.get_program().to_string_lossy().ends_with("bin/nix") + { + self.logger.info("Starting Nix debugger ..."); + cmd.arg("--debugger").exec(); + } + bail!(format!( + "Command `{}` failed with {code}", + display_command(&cmd) + )) + } else { + self.logger.level = prev_level; + Ok(result) + } + } + } + + // We have a separate function to avoid recursion as this needs to call self.prepare_command + // TODO: doesn't log the substituters + pub async fn prepare_command_with_substituters( + &mut self, + command: &str, + args: &[&str], + options: &Options<'a>, + ) -> Result { + let mut cmd = self.prepare_command(command, args, options)?; + if !self.global_options.offline { + let cachix_caches = self.get_cachix_caches().await; + + match cachix_caches { + Err(e) => { + self.logger + .warn("Failed to get cachix caches due to evaluation error"); + self.logger.debug(&format!("{}", e)); + } + Ok(cachix_caches) => { + // handle cachix.pull + let pull_caches = cachix_caches + .caches + .pull + .iter() + .map(|cache| format!("https://{}.cachix.org", cache)) + .collect::>() + .join(" "); + cmd.arg("--option"); + cmd.arg("extra-substituters"); + cmd.arg(pull_caches); + cmd.arg("--option"); + cmd.arg("extra-trusted-public-keys"); + cmd.arg( + cachix_caches + .known_keys + .values() + .cloned() + .collect::>() + .join(" "), + ); + + // handle cachix.push + if let Some(push_cache) = &cachix_caches.caches.push { + if env::var("CACHIX_AUTH_TOKEN").is_ok() { + let args = cmd + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect::>(); + let envs = cmd.get_envs().collect::>(); + let command_name = cmd.get_program().to_string_lossy(); + let mut newcmd = std::process::Command::new("cachix"); + newcmd + .args(["watch-exec", &push_cache, "--"]) + .arg(command_name.as_ref()) + .args(args); + for (key, value) in envs { + if let Some(value) = value { + newcmd.env(key, value); + } + } + cmd = newcmd; + } else { + self.logger.warn(&format!( + "CACHIX_AUTH_TOKEN is not set, but required to push to {}.", + push_cache + )); + } + } + } + } + } + Ok(cmd) + } + + pub fn prepare_command( + &mut self, + command: &str, + args: &[&str], + options: &Options<'a>, + ) -> Result { + let mut flags = options.nix_flags.to_vec(); + flags.push("--max-jobs"); + let max_jobs = self.global_options.max_jobs.to_string(); + flags.push(&max_jobs); + + flags.push("--option"); + flags.push("eval-cache"); + let eval_cache = self.global_options.eval_cache.to_string(); + flags.push(&eval_cache); + + // handle --nix-option key value + for chunk in self.global_options.nix_option.chunks_exact(2) { + flags.push("--option"); + flags.push(&chunk[0]); + flags.push(&chunk[1]); + } + + flags.extend_from_slice(args); + + let mut cmd = match env::var("DEVENV_NIX") { + Ok(devenv_nix) => std::process::Command::new(format!("{devenv_nix}/bin/{command}")), + Err(_) => { + self.logger.error( + "$DEVENV_NIX is not set, but required as devenv doesn't work without a few Nix patches." + ); + self.logger + .error("Please follow https://devenv.sh/getting-started/ to install devenv."); + bail!("$DEVENV_NIX is not set") + } + }; + + if self.global_options.offline && command == "nix" { + flags.push("--offline"); + } + + if self.global_options.impure || self.config.impure { + // only pass the impure option to the nix command that supports it. + // avoid passing it to the older utilities, e.g. like `nix-store` when creating GC roots. + if command == "nix" + && args + .first() + .map(|arg| arg == &"build" || arg == &"eval" || arg == &"print-dev-env") + .unwrap_or(false) + { + flags.push("--no-pure-eval"); + } + // set a dummy value to overcome https://github.com/NixOS/nix/issues/10247 + cmd.env("NIX_PATH", ":"); + } + cmd.args(flags); + cmd.current_dir(&self.devenv_root.as_path()); + + if self.global_options.verbose { + self.logger + .debug(&format!("Running command: {}", display_command(&cmd))); + } + Ok(cmd) + } + + async fn get_cachix_caches(&mut self) -> Result { + match &self.cachix_caches { + Some(caches) => Ok(caches.clone()), + None => { + let no_logging = Options { + logging: false, + ..self.options + }; + let caches_raw = self.eval(&["devenv.cachix"]).await?; + let cachix = serde_json::from_str(&caches_raw).expect("Failed to parse JSON"); + let known_keys = if let Ok(known_keys) = + std::fs::read_to_string(&self.cachix_trusted_keys.as_path()) + { + serde_json::from_str(&known_keys).expect("Failed to parse JSON") + } else { + HashMap::new() + }; + + let mut caches = CachixCaches { + caches: cachix, + known_keys, + }; + + let mut new_known_keys: HashMap = HashMap::new(); + let client = reqwest::Client::new(); + for name in caches.caches.pull.iter() { + if !caches.known_keys.contains_key(name) { + let mut request = + client.get(&format!("https://cachix.org/api/v1/cache/{}", name)); + if let Ok(ret) = env::var("CACHIX_AUTH_TOKEN") { + request = request.bearer_auth(ret); + } + let resp = request.send().await.expect("Failed to get cache"); + if resp.status().is_client_error() { + self.logger.error(&format!( + "Cache {} does not exist or you don't have a CACHIX_AUTH_TOKEN configured.", + name + )); + self.logger + .error("To create a cache, go to https://app.cachix.org/."); + bail!("Cache does not exist or you don't have a CACHIX_AUTH_TOKEN configured.") + } else { + let resp_json = serde_json::from_slice::( + &resp.bytes().await.unwrap(), + ) + .expect("Failed to parse JSON"); + new_known_keys + .insert(name.clone(), resp_json.public_signing_keys[0].clone()); + } + } + } + + if !caches.caches.pull.is_empty() { + let store = self.run_nix("nix", &["store", "ping", "--json"], &no_logging)?; + let trusted = serde_json::from_slice::(&store.stdout) + .expect("Failed to parse JSON") + .trusted; + if trusted.is_none() { + self.logger + .warn("You're using very old version of Nix, please upgrade and restart nix-daemon."); + } + let restart_command = if cfg!(target_os = "linux") { + "sudo systemctl restart nix-daemon" + } else { + "sudo launchctl kickstart -k system/org.nixos.nix-daemon" + }; + + self.logger + .info(&format!("Using Cachix: {}", caches.caches.pull.join(", "))); + if !new_known_keys.is_empty() { + for (name, pubkey) in new_known_keys.iter() { + self.logger.info(&format!( + "Trusting {}.cachix.org on first use with the public key {}", + name, pubkey + )); + } + caches.known_keys.extend(new_known_keys); + } + + std::fs::write( + &self.cachix_trusted_keys.as_path(), + serde_json::to_string(&caches.known_keys).unwrap(), + ) + .expect("Failed to write cachix caches to file"); + + if trusted == Some(0) { + if !Path::new("/etc/NIXOS").exists() { + self.logger.error(&indoc::formatdoc!( + "You're not a trusted user of the Nix store. You have the following options: + + a) Add yourself to the trusted-users list in /etc/nix/nix.conf for devenv to manage caches for you. + + trusted-users = root {} + + Restart nix-daemon with: + + $ {restart_command} + + b) Add binary caches to /etc/nix/nix.conf yourself: + + extra-substituters = {} + extra-trusted-public-keys = {} + + And disable automatic cache configuration in `devenv.nix`: + + {{ + cachix.enable = false; + }} + ", whoami::username() + , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") + , caches.known_keys.values().cloned().collect::>().join(" ") + )); + } else { + self.logger.error(&indoc::formatdoc!( + "You're not a trusted user of the Nix store. You have the following options: + + a) Add yourself to the trusted-users list in /etc/nix/nix.conf by editing configuration.nix for devenv to manage caches for you. + + {{ + nix.extraOptions = '' + trusted-users = root {} + ''; + }} + + b) Add binary caches to /etc/nix/nix.conf yourself by editing configuration.nix: + {{ + nix.extraOptions = '' + extra-substituters = {}; + extra-trusted-public-keys = {}; + ''; + }} + + Lastly rebuild your system + + $ sudo nixos-rebuild switch + ", whoami::username() + , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") + , caches.known_keys.values().cloned().collect::>().join(" ") + )); + } + bail!("You're not a trusted user of the Nix store.") + } + } + + self.cachix_caches = Some(caches.clone()); + Ok(caches) + } + } + } +} + +fn symlink_force(logger: &log::Logger, link_path: &Path, target: &Path) { + let _lock = dotlock::Dotlock::create(target.with_extension("lock")).unwrap(); + logger.debug(&format!( + "Creating symlink {} -> {}", + link_path.display(), + target.display() + )); + + if target.exists() { + fs::remove_file(target).unwrap_or_else(|_| panic!("Failed to remove {}", target.display())); + } + + symlink(link_path, target).unwrap_or_else(|_| { + panic!( + "Failed to create symlink: {} -> {}", + link_path.display(), + target.display() + ) + }); +} + +fn get_now_with_nanoseconds() -> String { + let now = SystemTime::now(); + let duration = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + let secs = duration.as_secs(); + let nanos = duration.subsec_nanos(); + format!("{}.{}", secs, nanos) +} + +// Display a command as a pretty string. +fn display_command(cmd: &std::process::Command) -> String { + let command = cmd.get_program().to_string_lossy(); + let args = cmd + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect::>() + .join(" "); + format!("{command} {args}") +} + +#[derive(Deserialize, Clone)] +pub struct Cachix { + pull: Vec, + push: Option, +} + +#[derive(Deserialize, Clone)] +pub struct CachixCaches { + caches: Cachix, + known_keys: HashMap, +} + +#[derive(Deserialize, Clone)] +struct CachixResponse { + #[serde(rename = "publicSigningKeys")] + public_signing_keys: Vec, +} + +#[derive(Deserialize, Clone)] +struct StorePing { + trusted: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trusted() { + let store_ping = r#"{"trusted":1,"url":"daemon","version":"2.18.1"}"#; + let store_ping: StorePing = serde_json::from_str(store_ping).unwrap(); + assert_eq!(store_ping.trusted, Some(1)); + } + + #[test] + fn test_no_trusted() { + let store_ping = r#"{"url":"daemon","version":"2.18.1"}"#; + let store_ping: StorePing = serde_json::from_str(store_ping).unwrap(); + assert_eq!(store_ping.trusted, None); + } + + #[test] + fn test_not_trusted() { + let store_ping = r#"{"trusted":0,"url":"daemon","version":"2.18.1"}"#; + let store_ping: StorePing = serde_json::from_str(store_ping).unwrap(); + assert_eq!(store_ping.trusted, Some(0)); + } +} diff --git a/devenv/src/command.rs b/devenv/src/command.rs deleted file mode 100644 index 3680975a6..000000000 --- a/devenv/src/command.rs +++ /dev/null @@ -1,454 +0,0 @@ -use crate::devenv::Devenv; -use miette::{bail, IntoDiagnostic, Result, WrapErr}; -use serde::Deserialize; -use std::collections::HashMap; -use std::env; -use std::os::unix::process::CommandExt; -use std::path::Path; - -const NIX_FLAGS: [&str; 9] = [ - "--show-trace", - "--extra-experimental-features", - "nix-command", - "--extra-experimental-features", - "flakes", - // remove unnecessary warnings - "--option", - "warn-dirty", - "false", - // always build all dependencies and report errors at the end - "--keep-going", -]; - -pub struct Options { - pub replace_shell: bool, - pub logging: bool, -} - -impl Default for Options { - fn default() -> Self { - Options { - replace_shell: false, - logging: true, - } - } -} - -impl Devenv { - pub fn run_nix( - &mut self, - command: &str, - args: &[&str], - options: &Options, - ) -> Result { - let prev_logging = self.logger.clone(); - if !options.logging { - self.logger = crate::log::Logger::new(crate::log::Level::Error); - } - - let mut cmd = self.prepare_command(command, args)?; - - if options.replace_shell { - if self.global_options.nix_debugger && command.ends_with("bin/nix") { - cmd.arg("--debugger"); - } - let error = cmd.exec(); - self.logger.error(&format!( - "Failed to replace shell with `{}`: {error}", - display_command(&cmd), - )); - bail!("Failed to replace shell") - } else { - if options.logging { - cmd.stdin(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()); - } - - let result = cmd - .output() - .into_diagnostic() - .wrap_err_with(|| format!("Failed to run command `{}`", display_command(&cmd)))?; - - if !result.status.success() { - let code = match result.status.code() { - Some(code) => format!("with exit code {}", code), - None => "without exit code".to_string(), - }; - if options.logging { - eprintln!(); - self.logger.error(&format!( - "Command produced the following output:\n{}\n{}", - String::from_utf8_lossy(&result.stdout), - String::from_utf8_lossy(&result.stderr), - )); - } - if self.global_options.nix_debugger && command.ends_with("bin/nix") { - self.logger.info("Starting Nix debugger ..."); - cmd.arg("--debugger").exec(); - } - bail!(format!( - "Command `{}` failed with {code}", - display_command(&cmd) - )) - } else { - self.logger = prev_logging; - Ok(result) - } - } - } - - pub fn prepare_command( - &mut self, - command: &str, - args: &[&str], - ) -> Result { - let mut cmd = if command.starts_with("nix") { - let mut flags = NIX_FLAGS.to_vec(); - flags.push("--max-jobs"); - let max_jobs = self.global_options.max_jobs.to_string(); - flags.push(&max_jobs); - - flags.push("--option"); - flags.push("eval-cache"); - let eval_cache = self.global_options.eval_cache.to_string(); - flags.push(&eval_cache); - - // handle --nix-option key value - for chunk in self.global_options.nix_option.chunks_exact(2) { - flags.push("--option"); - flags.push(&chunk[0]); - flags.push(&chunk[1]); - } - - flags.extend_from_slice(args); - - let mut cmd = match env::var("DEVENV_NIX") { - Ok(devenv_nix) => std::process::Command::new(format!("{devenv_nix}/bin/{command}")), - Err(_) => { - self.logger.error( - "$DEVENV_NIX is not set, but required as devenv doesn't work without a few Nix patches." - ); - self.logger.error( - "Please follow https://devenv.sh/getting-started/ to install devenv.", - ); - bail!("$DEVENV_NIX is not set") - } - }; - - if self.global_options.offline && command == "nix" { - flags.push("--offline"); - } - - if self.global_options.impure || self.config.impure { - // only pass the impure option to the nix command that supports it. - // avoid passing it to the older utilities, e.g. like `nix-store` when creating GC roots. - if command == "nix" - && args - .first() - .map(|arg| arg == &"build" || arg == &"eval" || arg == &"print-dev-env") - .unwrap_or(false) - { - flags.push("--no-pure-eval"); - } - // set a dummy value to overcome https://github.com/NixOS/nix/issues/10247 - cmd.env("NIX_PATH", ":"); - } - cmd.args(flags); - - if args - .first() - .map(|arg| arg == &"build" || arg == &"print-dev-env" || arg == &"search") - .unwrap_or(false) - && !self.global_options.offline - { - let cachix_caches = self.get_cachix_caches(); - - match cachix_caches { - Err(e) => { - self.logger - .warn("Failed to get cachix caches due to evaluation error"); - self.logger.debug(&format!("{}", e)); - } - Ok(cachix_caches) => { - // handle cachix.pull - let pull_caches = cachix_caches - .caches - .pull - .iter() - .map(|cache| format!("https://{}.cachix.org", cache)) - .collect::>() - .join(" "); - cmd.arg("--option"); - cmd.arg("extra-substituters"); - cmd.arg(pull_caches); - cmd.arg("--option"); - cmd.arg("extra-trusted-public-keys"); - cmd.arg( - cachix_caches - .known_keys - .values() - .cloned() - .collect::>() - .join(" "), - ); - - // handle cachix.push - if let Some(push_cache) = &cachix_caches.caches.push { - if env::var("CACHIX_AUTH_TOKEN").is_ok() { - let args = cmd - .get_args() - .map(|arg| arg.to_str().unwrap()) - .collect::>(); - let envs = cmd.get_envs().collect::>(); - let command_name = cmd.get_program().to_string_lossy(); - let mut newcmd = std::process::Command::new("cachix"); - newcmd - .args(["watch-exec", &push_cache, "--"]) - .arg(command_name.as_ref()) - .args(args); - for (key, value) in envs { - if let Some(value) = value { - newcmd.env(key, value); - } - } - cmd = newcmd; - } else { - self.logger.warn(&format!( - "CACHIX_AUTH_TOKEN is not set, but required to push to {}.", - push_cache - )); - } - } - } - } - } - cmd - } else { - let mut cmd = std::process::Command::new(command); - cmd.args(args); - cmd - }; - - cmd.current_dir(self.devenv_root()); - - if self.global_options.verbose { - self.logger - .debug(&format!("Running command: {}", display_command(&cmd))); - } - - Ok(cmd) - } - - fn get_cachix_caches(&mut self) -> Result { - match &self.cachix_caches { - Some(caches) => Ok(caches.clone()), - None => { - let no_logging = Options { - logging: false, - ..Default::default() - }; - - let caches_raw = - self.run_nix("nix", &["eval", ".#devenv.cachix", "--json"], &no_logging)?; - - let cachix = - serde_json::from_slice(&caches_raw.stdout).expect("Failed to parse JSON"); - - let known_keys = - if let Ok(known_keys) = std::fs::read_to_string(&self.cachix_trusted_keys) { - serde_json::from_str(&known_keys).expect("Failed to parse JSON") - } else { - HashMap::new() - }; - - let mut caches = CachixCaches { - caches: cachix, - known_keys, - }; - - let mut new_known_keys: HashMap = HashMap::new(); - let client = reqwest::blocking::Client::new(); - for name in caches.caches.pull.iter() { - if !caches.known_keys.contains_key(name) { - let mut request = - client.get(&format!("https://cachix.org/api/v1/cache/{}", name)); - if let Ok(ret) = env::var("CACHIX_AUTH_TOKEN") { - request = request.bearer_auth(ret); - } - let resp = request.send().expect("Failed to get cache"); - if resp.status().is_client_error() { - self.logger.error(&format!( - "Cache {} does not exist or you don't have a CACHIX_AUTH_TOKEN configured.", - name - )); - self.logger - .error("To create a cache, go to https://app.cachix.org/."); - bail!("Cache does not exist or you don't have a CACHIX_AUTH_TOKEN configured.") - } else { - let resp_json = - serde_json::from_slice::(&resp.bytes().unwrap()) - .expect("Failed to parse JSON"); - new_known_keys - .insert(name.clone(), resp_json.public_signing_keys[0].clone()); - } - } - } - - if !caches.caches.pull.is_empty() { - let store = self.run_nix("nix", &["store", "ping", "--json"], &no_logging)?; - let trusted = serde_json::from_slice::(&store.stdout) - .expect("Failed to parse JSON") - .trusted; - if trusted.is_none() { - self.logger - .warn("You're using very old version of Nix, please upgrade and restart nix-daemon."); - } - let restart_command = if cfg!(target_os = "linux") { - "sudo systemctl restart nix-daemon" - } else { - "sudo launchctl kickstart -k system/org.nixos.nix-daemon" - }; - - self.logger - .info(&format!("Using Cachix: {}", caches.caches.pull.join(", "))); - if !new_known_keys.is_empty() { - for (name, pubkey) in new_known_keys.iter() { - self.logger.info(&format!( - "Trusting {}.cachix.org on first use with the public key {}", - name, pubkey - )); - } - caches.known_keys.extend(new_known_keys); - } - - std::fs::write( - &self.cachix_trusted_keys, - serde_json::to_string(&caches.known_keys).unwrap(), - ) - .expect("Failed to write cachix caches to file"); - - if trusted == Some(0) { - if !Path::new("/etc/NIXOS").exists() { - self.logger.error(&indoc::formatdoc!( - "You're not a trusted user of the Nix store. You have the following options: - - a) Add yourself to the trusted-users list in /etc/nix/nix.conf for devenv to manage caches for you. - - trusted-users = root {} - - Restart nix-daemon with: - - $ {restart_command} - - b) Add binary caches to /etc/nix/nix.conf yourself: - - extra-substituters = {} - extra-trusted-public-keys = {} - - And disable automatic cache configuration in `devenv.nix`: - - {{ - cachix.enable = false; - }} - ", whoami::username() - , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") - , caches.known_keys.values().cloned().collect::>().join(" ") - )); - } else { - self.logger.error(&indoc::formatdoc!( - "You're not a trusted user of the Nix store. You have the following options: - - a) Add yourself to the trusted-users list in /etc/nix/nix.conf by editing configuration.nix for devenv to manage caches for you. - - {{ - nix.extraOptions = '' - trusted-users = root {} - ''; - }} - - b) Add binary caches to /etc/nix/nix.conf yourself by editing configuration.nix: - {{ - nix.extraOptions = '' - extra-substituters = {}; - extra-trusted-public-keys = {}; - ''; - }} - - Lastly rebuild your system - - $ sudo nixos-rebuild switch - ", whoami::username() - , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") - , caches.known_keys.values().cloned().collect::>().join(" ") - )); - } - bail!("You're not a trusted user of the Nix store.") - } - } - - self.cachix_caches = Some(caches.clone()); - Ok(caches) - } - } - } -} - -/// Display a command as a pretty string. -fn display_command(cmd: &std::process::Command) -> String { - let command = cmd.get_program().to_string_lossy(); - let args = cmd - .get_args() - .map(|arg| arg.to_str().unwrap()) - .collect::>() - .join(" "); - format!("{command} {args}") -} - -#[derive(Deserialize, Clone)] -pub struct Cachix { - pull: Vec, - push: Option, -} - -#[derive(Deserialize, Clone)] -pub struct CachixCaches { - caches: Cachix, - known_keys: HashMap, -} - -#[derive(Deserialize, Clone)] -struct CachixResponse { - #[serde(rename = "publicSigningKeys")] - public_signing_keys: Vec, -} - -#[derive(Deserialize, Clone)] -struct StorePing { - trusted: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_trusted() { - let store_ping = r#"{"trusted":1,"url":"daemon","version":"2.18.1"}"#; - let store_ping: StorePing = serde_json::from_str(store_ping).unwrap(); - assert_eq!(store_ping.trusted, Some(1)); - } - - #[test] - fn test_no_trusted() { - let store_ping = r#"{"url":"daemon","version":"2.18.1"}"#; - let store_ping: StorePing = serde_json::from_str(store_ping).unwrap(); - assert_eq!(store_ping.trusted, None); - } - - #[test] - fn test_not_trusted() { - let store_ping = r#"{"trusted":0,"url":"daemon","version":"2.18.1"}"#; - let store_ping: StorePing = serde_json::from_str(store_ping).unwrap(); - assert_eq!(store_ping.trusted, Some(0)); - } -} diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index 3725c88f5..789a971e2 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -1,17 +1,17 @@ -use super::{cli, command, config, log, tasks}; +use super::{cli, cnix, config, log, tasks}; use clap::crate_version; use cli_table::Table; use cli_table::{print_stderr, WithTitle}; use include_dir::{include_dir, Dir}; use miette::{bail, IntoDiagnostic, Result, WrapErr}; +use nix::sys::signal; +use nix::unistd::Pid; use serde::Deserialize; use sha2::Digest; use std::collections::HashMap; use std::io::Write; -use std::os::unix::fs::symlink; use std::os::unix::{fs::PermissionsExt, process::CommandExt}; -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::Arc; use std::{ fs, path::{Path, PathBuf}, @@ -34,26 +34,24 @@ pub struct DevenvOptions { pub devenv_dotfile: Option, } -pub struct Devenv { +pub struct Devenv<'a> { pub(crate) config: config::Config, pub(crate) global_options: cli::GlobalOptions, pub(crate) logger: log::Logger, pub(crate) log_progress: log::LogProgressCreator, + nix: cnix::Nix<'a>, + // All kinds of paths xdg_dirs: xdg::BaseDirectories, - devenv_root: PathBuf, + pub(crate) devenv_root: PathBuf, devenv_dotfile: PathBuf, devenv_dot_gc: PathBuf, devenv_home_gc: PathBuf, devenv_tmp: String, devenv_runtime: PathBuf, - // Caching - pub cachix_caches: Option, - pub cachix_trusted_keys: PathBuf, - pub(crate) assembled: bool, pub(crate) dirs_created: bool, pub(crate) has_processes: Option, @@ -61,10 +59,11 @@ pub struct Devenv { pub(crate) container_name: Option, } -impl Devenv { +impl<'a> Devenv<'a> { pub fn new(options: DevenvOptions) -> Self { let xdg_dirs = xdg::BaseDirectories::with_prefix("devenv").unwrap(); let devenv_home = xdg_dirs.get_data_home(); + let cachix_trusted_keys = devenv_home.join("cachix_trusted_keys.json"); let devenv_home_gc = devenv_home.join("gc"); let devenv_root = options @@ -88,7 +87,6 @@ impl Devenv { }; let devenv_runtime = Path::new(&devenv_tmp).join(format!("devenv-{}", &devenv_state_hash[..7])); - let cachix_trusted_keys = devenv_home.join("cachix_trusted_keys.json"); let global_options = options.global_options.unwrap_or_default(); @@ -105,20 +103,36 @@ impl Devenv { log::LogProgressCreator::Logging }; + let config = Arc::new(options.config); + let global_options = Arc::new(global_options); + let cachix_trusted_keys = Arc::new(cachix_trusted_keys); + let devenv_home_gc = Arc::new(devenv_home_gc); + let devenv_dot_gc = Arc::new(devenv_dot_gc); + let devenv_root = Arc::new(devenv_root); + + let nix = cnix::Nix::new( + logger.clone(), + Arc::clone(&config), + Arc::clone(&global_options), + Arc::clone(&cachix_trusted_keys), + Arc::clone(&devenv_home_gc), + Arc::clone(&devenv_dot_gc), + Arc::clone(&devenv_root), + ); + Self { - config: options.config, - global_options, + config: (*config).clone(), + global_options: (*global_options).clone(), logger, log_progress, xdg_dirs, - devenv_root, + devenv_root: (*devenv_root).clone(), devenv_dotfile, - devenv_dot_gc, - devenv_home_gc, + devenv_dot_gc: (*devenv_dot_gc).clone(), + devenv_home_gc: (*devenv_home_gc).clone(), devenv_tmp, devenv_runtime, - cachix_caches: None, - cachix_trusted_keys, + nix, assembled: false, dirs_created: false, has_processes: None, @@ -126,10 +140,6 @@ impl Devenv { } } - pub fn devenv_root(&self) -> &Path { - self.devenv_root.as_ref() - } - // TODO: refactor test to be able to remove this pub fn update_devenv_dotfile

(&mut self, devenv_dotfile: P) where @@ -207,8 +217,8 @@ impl Devenv { Ok(()) } - pub fn print_dev_env(&mut self, json: bool) -> Result<()> { - let (env, _) = self.get_dev_environment(json, false)?; + pub async fn print_dev_env(&mut self, json: bool) -> Result<()> { + let (env, _) = self.get_dev_environment(json, false).await?; print!( "{}", String::from_utf8(env).expect("Failed to convert env to utf-8") @@ -216,36 +226,32 @@ impl Devenv { Ok(()) } - pub fn shell( + pub async fn shell( &mut self, cmd: &Option, args: &[String], replace_shell: bool, ) -> Result<()> { - let develop_args = self.prepare_shell(cmd, args)?; - - let options = command::Options { - replace_shell, - ..command::Options::default() - }; + let develop_args = self.prepare_develop_args(cmd, args).await?; let develop_args = develop_args .iter() .map(|s| s.as_str()) .collect::>(); - self.run_nix("nix", &develop_args, &options)?; + self.nix.develop(&develop_args, replace_shell).await?; Ok(()) } - pub fn prepare_shell(&mut self, cmd: &Option, args: &[String]) -> Result> { + pub async fn prepare_develop_args( + &mut self, + cmd: &Option, + args: &[String], + ) -> Result> { self.assemble(false)?; - let (_, gc_root) = self.get_dev_environment(false, true)?; + let (_, gc_root) = self.get_dev_environment(false, true).await?; - let mut develop_args = vec![ - "develop", - gc_root.to_str().expect("gc root should be utf-8"), - ]; + let mut develop_args = vec![gc_root.to_str().expect("gc root should be utf-8")]; let default_clean = config::Clean { enabled: false, @@ -294,22 +300,11 @@ impl Devenv { let _logprogress = self.log_progress.with_newline(&msg); self.assemble(false)?; - match input_name { - Some(input_name) => { - self.run_nix( - "nix", - &["flake", "lock", "--update-input", input_name], - &command::Options::default(), - )?; - } - None => { - self.run_nix("nix", &["flake", "update"], &command::Options::default())?; - } - } + self.nix.update(input_name)?; Ok(()) } - pub fn container_build(&mut self, name: &str) -> Result { + pub async fn container_build(&mut self, name: &str) -> Result { if cfg!(target_os = "macos") { bail!("Containers are not supported on macOS yet: https://github.com/cachix/devenv/issues/430"); } @@ -320,52 +315,35 @@ impl Devenv { self.assemble(false)?; - let container_store_path = self.run_nix( - "nix", - &[ - "build", - "--print-out-paths", - "--no-link", - &format!(".#devenv.containers.{name}.derivation"), - ], - &command::Options::default(), - )?; - - let container_store_path_string = String::from_utf8_lossy(&container_store_path.stdout) - .to_string() - .trim() - .to_string(); - println!("{}", container_store_path_string); - Ok(container_store_path_string) + let container_store_path = self + .nix + .build(&[&format!("devenv.containers.{name}.derivation")]) + .await?; + let container_store_path = container_store_path[0] + .to_str() + .expect("Failed to get container store path"); + println!("{}", &container_store_path); + Ok(container_store_path.to_string()) } - pub fn container_copy( + pub async fn container_copy( &mut self, name: &str, copy_args: &[String], registry: Option<&str>, ) -> Result<()> { - let spec = self.container_build(name)?; + let spec = self.container_build(name).await?; let _logprogress = self .log_progress .without_newline(&format!("Copying {name} container")); - let copy_script = self.run_nix( - "nix", - &[ - "build", - "--print-out-paths", - "--no-link", - &format!(".#devenv.containers.{name}.copyScript"), - ], - &command::Options::default(), - )?; - - let copy_script = String::from_utf8_lossy(©_script.stdout) - .to_string() - .trim() - .to_string(); + let copy_script = self + .nix + .build(&[&format!(".#devenv.containers.{name}.copyScript")]) + .await?; + let copy_script = ©_script[0]; + let copy_script_string = ©_script.to_string_lossy(); let copy_args = [ spec, @@ -373,8 +351,10 @@ impl Devenv { copy_args.join(" "), ]; - self.logger - .info(&format!("Running {copy_script} {}", copy_args.join(" "))); + self.logger.info(&format!( + "Running {copy_script_string} {}", + copy_args.join(" ") + )); let status = std::process::Command::new(copy_script) .args(copy_args) @@ -390,7 +370,7 @@ impl Devenv { } } - pub fn container_run( + pub async fn container_run( &mut self, name: &str, copy_args: &[String], @@ -400,29 +380,19 @@ impl Devenv { self.logger .warn("Ignoring --registry flag when running container"); }; - self.container_copy(name, copy_args, Some("docker-daemon:"))?; + self.container_copy(name, copy_args, Some("docker-daemon:")) + .await?; let _logprogress = self .log_progress .without_newline(&format!("Running {name} container")); - let run_script = self.run_nix( - "nix", - &[ - "build", - "--print-out-paths", - "--no-link", - &format!(".#devenv.containers.{name}.dockerRun"), - ], - &command::Options::default(), - )?; - - let run_script = String::from_utf8_lossy(&run_script.stdout) - .to_string() - .trim() - .to_string(); - - let status = std::process::Command::new(run_script) + let run_script = self + .nix + .build(&[&format!("devenv.containers.{name}.dockerRun")]) + .await?; + + let status = std::process::Command::new(&run_script[0]) .status() .expect("Failed to run container script"); @@ -435,10 +405,7 @@ impl Devenv { pub fn repl(&mut self) -> Result<()> { self.assemble(false)?; - - let mut cmd = self.prepare_command("nix", &["repl", "."])?; - cmd.exec(); - Ok(()) + self.nix.repl() } pub fn gc(&mut self) -> Result<()> { @@ -451,9 +418,10 @@ impl Devenv { )); cleanup_symlinks(&self.devenv_home_gc) }; + let to_gc_len = to_gc.len(); self.logger - .info(&format!("Found {} active environments.", to_gc.len())); + .info(&format!("Found {} active environments.", to_gc_len)); self.logger.info(&format!( "Deleted {} dangling environments (most likely due to previous GC).", removed_symlinks.len() @@ -464,17 +432,7 @@ impl Devenv { .log_progress .with_newline("Running garbage collection (this process will take some time) ..."); self.logger.warn("If you'd like this to run faster, leave a thumbs up at https://github.com/NixOS/nix/issues/7239"); - let paths: std::collections::HashSet<&str> = to_gc - .iter() - .filter_map(|path_buf| path_buf.to_str()) - .collect(); - for path in paths { - self.logger.info(&format!("Deleting {}...", path)); - let args: Vec<&str> = ["store", "delete", path].iter().copied().collect(); - let cmd = self.prepare_command("nix", &args); - // we ignore if this command fails, because root might be in use - let _ = cmd?.output(); - } + self.nix.gc(to_gc)?; } let (after_gc, _) = cleanup_symlinks(&self.devenv_home_gc); @@ -483,24 +441,17 @@ impl Devenv { eprintln!(); self.logger.info(&format!( "Done. Successfully removed {} symlinks in {}s.", - to_gc.len() - after_gc.len(), + to_gc_len - after_gc.len(), (end - start).as_secs_f32() )); Ok(()) } - pub fn search(&mut self, name: &str) -> Result<()> { + pub async fn search(&mut self, name: &str) -> Result<()> { self.assemble(false)?; - let options = self.run_nix( - "nix", - &["build", "--no-link", "--print-out-paths", ".#optionsJSON"], - &command::Options::default(), - )?; - - let options_str = std::str::from_utf8(&options.stdout).unwrap().trim(); - let options_path = PathBuf::from_str(options_str) - .expect("options store path should be a utf-8") + let options = self.nix.build(&["optionsJSON"]).await?; + let options_path = options[0] .join("share") .join("doc") .join("nixos") @@ -522,11 +473,7 @@ impl Devenv { .collect::>(); let results_options_count = options_results.len(); - let search = self.run_nix( - "nix", - &["search", "--json", "nixpkgs", name], - &command::Options::default(), - )?; + let search = self.nix.search(name).await?; let search_json: PackageResults = serde_json::from_slice(&search.stdout).expect("Failed to parse search results"); let search_results = search_json @@ -555,14 +502,9 @@ impl Devenv { Ok(()) } - pub fn has_processes(&mut self) -> Result { + pub async fn has_processes(&mut self) -> Result { if self.has_processes.is_none() { - let result = self.run_nix( - "nix", - &["eval", ".#devenv.processes", "--json"], - &command::Options::default(), - )?; - let processes = String::from_utf8_lossy(&result.stdout).to_string(); + let processes = self.nix.eval(&["devenv.processes"]).await?; self.has_processes = Some(processes.trim() != "{}"); } Ok(self.has_processes.unwrap()) @@ -572,25 +514,15 @@ impl Devenv { if roots.is_empty() { bail!("No tasks specified."); } - let config = { - let _logprogress = self.log_progress.with_newline("Evaluating tasks"); - self.run_nix( - "nix", - &[ - "build", - ".#devenv.task.config", - "--no-link", - "--print-out-paths", - ], - &command::Options::default(), - )? + let tasks_json_file = { + let _logprogress = self.log_progress.without_newline("Evaluating tasks"); + self.nix.build(&["devenv.task.config"]).await? }; // parse tasks config - let config_content = - std::fs::read_to_string(String::from_utf8_lossy(&config.stdout).trim()) - .expect("Failed to read config file"); + let tasks_json = + std::fs::read_to_string(&tasks_json_file[0]).expect("Failed to read config file"); let tasks: Vec = - serde_json::from_str(&config_content).expect("Failed to parse tasks config"); + serde_json::from_str(&tasks_json).expect("Failed to parse tasks config"); // run tasks let config = tasks::Config { roots, tasks }; let mut tui = tasks::TasksUi::new(config).await?; @@ -603,51 +535,44 @@ impl Devenv { } } - pub fn test(&mut self) -> Result<()> { + pub async fn test(&mut self) -> Result<()> { self.assemble(true)?; // collect tests let test_script = { let _logprogress = self.log_progress.with_newline("Building tests"); - self.run_nix( - "nix", - &["build", ".#devenv.test", "--no-link", "--print-out-paths"], - &command::Options::default(), - )? + let test_scripts = self.nix.build(&["devenv.test"]).await?; + std::fs::read_to_string(&test_scripts[0]) + .map_err(|e| miette::miette!("Failed to read test script: {}", e))? }; - let test_script_string = String::from_utf8_lossy(&test_script.stdout) - .to_string() - .trim() - .to_string(); - if test_script_string.is_empty() { + if test_script.is_empty() { self.logger.error("No tests found."); bail!("No tests found"); } - if self.has_processes()? { - self.up(None, &true, &false)?; + if self.has_processes().await? { + self.up(None, &true, &false).await?; } let result = { let _logprogress = self.log_progress.with_newline("Running tests"); - self.logger - .debug(&format!("Running command: {test_script_string}")); - - let develop_args = self.prepare_shell(&Some(test_script_string), &[])?; - let develop_args = develop_args - .iter() - .map(|s| s.as_str()) - .collect::>(); - let mut cmd = self.prepare_command("nix", &develop_args)?; - cmd.stdin(std::process::Stdio::inherit()); - cmd.stderr(std::process::Stdio::inherit()); - cmd.stdout(std::process::Stdio::inherit()); - cmd.output().expect("Failed to run tests") + .debug(&format!("Running command: {test_script}")); + let develop_args = self.prepare_develop_args(&Some(test_script), &[]).await?; + // TODO: replace_shell? + self.nix + .develop( + &develop_args + .iter() + .map(|s| s.as_str()) + .collect::>(), + false, + ) + .await? }; - if self.has_processes()? { + if self.has_processes().await? { self.down()?; } @@ -662,91 +587,31 @@ impl Devenv { pub fn info(&mut self) -> Result<()> { self.assemble(false)?; - - // TODO: use --json - let metadata = self.run_nix("nix", &["flake", "metadata"], &command::Options::default())?; - - let re = regex::Regex::new(r"(Inputs:.+)$").unwrap(); - let metadata_str = String::from_utf8_lossy(&metadata.stdout); - let inputs = match re.captures(&metadata_str) { - Some(captures) => captures.get(1).unwrap().as_str(), - None => "", - }; - - let info_ = self.run_nix( - "nix", - &["eval", "--raw", ".#info"], - &command::Options::default(), - )?; - println!("{}\n{}", inputs, &String::from_utf8_lossy(&info_.stdout)); + let output = self.nix.metadata()?; + println!("{}", output); Ok(()) } - pub fn build(&mut self, attributes: &[String]) -> Result<()> { + pub async fn build(&mut self, attributes: &[String]) -> Result<()> { self.assemble(false)?; - - let build_attrs: Vec = if attributes.is_empty() { - // construct dotted names of all attributes that we need to build - let build_output = self.run_nix( - "nix", - &["eval", ".#build", "--json"], - &command::Options::default(), - )?; - serde_json::from_slice::(&build_output.stdout) - .map_err(|e| miette::miette!("Failed to parse build output: {}", e))? - .as_object() - .ok_or_else(|| miette::miette!("Build output is not an object"))? - .iter() - .flat_map(|(key, value)| { - fn flatten_object(prefix: &str, value: &serde_json::Value) -> Vec { - match value { - serde_json::Value::Object(obj) => obj - .iter() - .flat_map(|(k, v)| flatten_object(&format!("{}.{}", prefix, k), v)) - .collect(), - _ => vec![format!(".#devenv.{}", prefix)], - } - } - flatten_object(key, value) - }) - .collect() - } else { - attributes - .iter() - .map(|attr| format!(".#devenv.{}", attr)) - .collect() - }; - - let mut args = vec!["build", "--print-out-paths", "--no-link"]; - if !build_attrs.is_empty() { - args.extend(build_attrs.iter().map(|s| s.as_str())); - let output = self.run_nix("nix", &args, &command::Options::default())?; - println!("{}", String::from_utf8_lossy(&output.stdout)); + let paths = self + .nix + .build(&attributes.iter().map(AsRef::as_ref).collect::>()) + .await?; + for path in paths { + println!("{}", path.to_string_lossy()); } Ok(()) } - pub fn add_gc(&mut self, name: &str, path: &Path) -> Result<()> { - self.run_nix( - "nix-store", - &[ - "--add-root", - self.devenv_dot_gc.join(name).to_str().unwrap(), - "-r", - path.to_str().unwrap(), - ], - &command::Options::default(), - )?; - let link_path = self - .devenv_dot_gc - .join(format!("{}-{}", name, get_now_with_nanoseconds())); - symlink_force(&self.logger, path, &link_path); - Ok(()) - } - - pub fn up(&mut self, process: Option<&str>, detach: &bool, log_to_file: &bool) -> Result<()> { + pub async fn up( + &mut self, + process: Option<&str>, + detach: &bool, + log_to_file: &bool, + ) -> Result<()> { self.assemble(false)?; - if !self.has_processes()? { + if !self.has_processes().await? { self.logger .error("No 'processes' option defined: https://devenv.sh/processes/"); bail!("No processes defined"); @@ -755,25 +620,13 @@ impl Devenv { let proc_script_string: String; { let _logprogress = self.log_progress.with_newline("Building processes"); - - let proc_script = self.run_nix( - "nix", - &[ - "build", - "--no-link", - "--print-out-paths", - ".#procfileScript", - ], - &command::Options::default(), - )?; - - proc_script_string = String::from_utf8_lossy(&proc_script.stdout) - .to_string() - .trim() + let proc_script = self.nix.build(&["procfileScript"]).await?; + proc_script_string = proc_script[0] + .to_str() + .expect("Failed to get proc script path") .to_string(); - self.add_gc("procfilescript", Path::new(&proc_script_string))?; + self.nix.add_gc("procfilescript", &proc_script[0])?; } - { let _logprogress = self.log_progress.with_newline("Starting processes"); @@ -799,10 +652,22 @@ impl Devenv { std::fs::set_permissions(&processes_script, std::fs::Permissions::from_mode(0o755)) .expect("Failed to set permissions"); - let args = - self.prepare_shell(&Some(processes_script.to_str().unwrap().to_string()), &[])?; - let args = args.iter().map(|s| s.as_str()).collect::>(); - let mut cmd = self.prepare_command("nix", &args)?; + let develop_args = self + .prepare_develop_args(&Some(processes_script.to_str().unwrap().to_string()), &[]) + .await?; + + let options = self.nix.options; + let mut cmd = self + .nix + .prepare_command_with_substituters( + "develop", + &develop_args + .iter() + .map(AsRef::as_ref) + .collect::>(), + &options, + ) + .await?; if *detach { let log_file = std::fs::File::create(self.processes_log()) @@ -850,8 +715,8 @@ impl Devenv { self.logger .info(&format!("Stopping process with PID {}", pid)); - let pid = nix::unistd::Pid::from_raw(pid); - match nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM) { + let pid = Pid::from_raw(pid); + match signal::kill(pid, signal::Signal::SIGTERM) { Ok(_) => {} Err(_) => { self.logger @@ -958,7 +823,11 @@ impl Devenv { Ok(()) } - pub fn get_dev_environment(&mut self, json: bool, logging: bool) -> Result<(Vec, PathBuf)> { + pub async fn get_dev_environment( + &mut self, + json: bool, + logging: bool, + ) -> Result<(Vec, PathBuf)> { self.assemble(false)?; let _logprogress = if logging { Some(self.log_progress.with_newline("Building shell")) @@ -966,53 +835,9 @@ impl Devenv { None }; let gc_root = self.devenv_dot_gc.join("shell"); - let gc_root_str = gc_root.to_str().expect("gc root should be utf-8"); - - let mut args: Vec<&str> = vec!["print-dev-env", "--profile", gc_root_str]; - if json { - args.push("--json"); - } - - let env = self.run_nix("nix", &args, &command::Options::default())?; - - let options = command::Options { - logging: false, - ..command::Options::default() - }; - - let args: Vec<&str> = vec!["-p", gc_root_str, "--delete-generations", "old"]; - self.run_nix("nix-env", &args, &options)?; - let now_ns = get_now_with_nanoseconds(); - let target = format!("{}-shell", now_ns); - symlink_force( - &self.logger, - &fs::canonicalize(&gc_root).expect("to resolve gc_root"), - &self.devenv_home_gc.join(target), - ); - - Ok((env.stdout, gc_root)) - } -} - -fn symlink_force(logger: &log::Logger, link_path: &Path, target: &Path) { - let _lock = dotlock::Dotlock::create(target.with_extension("lock")).unwrap(); - logger.debug(&format!( - "Creating symlink {} -> {}", - link_path.display(), - target.display() - )); - - if target.exists() { - fs::remove_file(target).unwrap_or_else(|_| panic!("Failed to remove {}", target.display())); + let env = self.nix.dev_env(json, &gc_root).await?; + Ok((env, gc_root)) } - - symlink(link_path, target).unwrap_or_else(|_| { - panic!( - "Failed to create symlink: {} -> {}", - link_path.display(), - target.display() - ) - }); } #[derive(Deserialize)] @@ -1057,14 +882,6 @@ struct DevenvPackageResult { description: String, } -fn get_now_with_nanoseconds() -> String { - let now = SystemTime::now(); - let duration = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); - let secs = duration.as_secs(); - let nanos = duration.subsec_nanos(); - format!("{}.{}", secs, nanos) -} - fn cleanup_symlinks(root: &Path) -> (Vec, Vec) { let mut to_gc = Vec::new(); let mut removed_symlinks = Vec::new(); diff --git a/devenv/src/lib.rs b/devenv/src/lib.rs index aa2c14302..1c78f0c0c 100644 --- a/devenv/src/lib.rs +++ b/devenv/src/lib.rs @@ -1,7 +1,5 @@ -#[macro_use] -extern crate assert_matches; mod cli; -pub mod command; +pub mod cnix; pub mod config; mod devenv; pub mod log; diff --git a/devenv/src/log.rs b/devenv/src/log.rs index 15e2fc5fd..92108ebd8 100644 --- a/devenv/src/log.rs +++ b/devenv/src/log.rs @@ -75,7 +75,7 @@ pub enum Level { #[derive(Clone)] pub struct Logger { - level: Level, + pub level: Level, } impl Logger { diff --git a/devenv/src/main.rs b/devenv/src/main.rs index 7093bb656..96f4b8107 100644 --- a/devenv/src/main.rs +++ b/devenv/src/main.rs @@ -1,5 +1,5 @@ mod cli; -mod command; +mod cnix; mod config; mod devenv; mod log; @@ -43,11 +43,11 @@ async fn main() -> Result<()> { } match cli.command { - Commands::Shell { cmd, args } => devenv.shell(&cmd, &args, true), + Commands::Shell { cmd, args } => devenv.shell(&cmd, &args, true).await, Commands::Test { dont_override_dotfile, } => { - let tmpdir = tempdir::TempDir::new_in(devenv.devenv_root(), ".devenv") + let tmpdir = tempdir::TempDir::new_in(devenv.devenv_root.as_path(), ".devenv") .expect("Failed to create temporary directory"); if !dont_override_dotfile { logger.info(&format!( @@ -56,7 +56,7 @@ async fn main() -> Result<()> { )); devenv.update_devenv_dotfile(tmpdir.as_ref()); } - devenv.test() + devenv.test().await } Commands::Version {} => Ok(println!( "devenv {} ({})", @@ -78,15 +78,19 @@ async fn main() -> Result<()> { match c { ContainerCommand::Build { name } => { devenv.container_name = Some(name.clone()); - let _ = devenv.container_build(&name)?; + let _ = devenv.container_build(&name).await?; } ContainerCommand::Copy { name } => { devenv.container_name = Some(name.clone()); - devenv.container_copy(&name, ©_args, registry.as_deref())?; + devenv + .container_copy(&name, ©_args, registry.as_deref()) + .await?; } ContainerCommand::Run { name } => { devenv.container_name = Some(name.clone()); - devenv.container_run(&name, ©_args, registry.as_deref())?; + devenv + .container_run(&name, ©_args, registry.as_deref()) + .await?; } } } @@ -97,17 +101,21 @@ async fn main() -> Result<()> { logger.warn( "--copy flag is deprecated, use `devenv container copy` instead", ); - devenv.container_copy(&name, ©_args, registry.as_deref())?; + devenv + .container_copy(&name, ©_args, registry.as_deref()) + .await?; } (_, true) => { logger.warn( "--docker-run flag is deprecated, use `devenv container run` instead", ); - devenv.container_run(&name, ©_args, registry.as_deref())?; + devenv + .container_run(&name, ©_args, registry.as_deref()) + .await?; } _ => { logger.warn("Calling without a subcommand is deprecated, use `devenv container build` instead"); - let _ = devenv.container_build(&name)?; + let _ = devenv.container_build(&name).await?; } }; } @@ -115,16 +123,16 @@ async fn main() -> Result<()> { Ok(()) } Commands::Init { target } => devenv.init(&target), - Commands::Search { name } => devenv.search(&name), + Commands::Search { name } => devenv.search(&name).await, Commands::Gc {} => devenv.gc(), Commands::Info {} => devenv.info(), Commands::Repl {} => devenv.repl(), - Commands::Build { attributes } => devenv.build(&attributes), + Commands::Build { attributes } => devenv.build(&attributes).await, Commands::Update { name } => devenv.update(&name), - Commands::Up { process, detach } => devenv.up(process.as_deref(), &detach, &detach), + Commands::Up { process, detach } => devenv.up(process.as_deref(), &detach, &detach).await, Commands::Processes { command } => match command { ProcessesCommand::Up { process, detach } => { - devenv.up(process.as_deref(), &detach, &detach) + devenv.up(process.as_deref(), &detach, &detach).await } ProcessesCommand::Down {} => devenv.down(), }, @@ -137,7 +145,7 @@ async fn main() -> Result<()> { // hidden Commands::Assemble => devenv.assemble(false), - Commands::PrintDevEnv { json } => devenv.print_dev_env(json), + Commands::PrintDevEnv { json } => devenv.print_dev_env(json).await, Commands::GenerateJSONSchema => { config::write_json_schema(); Ok(()) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 82a351385..cc093cda2 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -1,4 +1,3 @@ -use assert_matches::assert_matches; use crossterm::{ cursor, execute, style::{self, Stylize}, @@ -8,8 +7,11 @@ use miette::Diagnostic; use petgraph::algo::toposort; use petgraph::graph::{DiGraph, NodeIndex}; use petgraph::visit::{Dfs, EdgeRef}; +#[cfg(test)] +use pretty_assertions::assert_matches; use serde::Deserialize; -use serde_json::{json, Value}; +#[cfg(test)] +use serde_json::json; use std::io::{self, Write}; use std::process::Stdio; use std::sync::Arc; @@ -33,6 +35,7 @@ pub enum Error { #[error(transparent)] IoError(#[from] std::io::Error), TaskNotFound(String), + MissingCommand(String), TasksNotFound(Vec<(String, String)>), InvalidTaskName(String), // TODO: be more precies where the cycle happens @@ -54,6 +57,11 @@ impl Display for Error { ), Error::TaskNotFound(task) => write!(f, "Task does not exist: {}", task), Error::CycleDetected(task) => write!(f, "Cycle detected at task: {}", task), + Error::MissingCommand(task) => write!( + f, + "Task {} defined a status, but is missing a command", + task + ), Error::InvalidTaskName(task) => write!( f, "Invalid task name: {}, expected [a-zA-Z-_]:[a-zA-Z-_]", @@ -130,20 +138,16 @@ impl TaskState { async fn run(&mut self) -> TaskCompleted { let now = Instant::now(); self.status = TaskStatus::Running(now); - if let Some(status) = &self.task.status { - let mut child = Command::new(status) + if let Some(cmd) = &self.task.status { + // TODO: stdout/stderr should be piped for debugging + let status = Command::new(cmd) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn() + .status() + .await .expect("Failed to execute status"); - - match child.wait().await { - Err(_) => {} - Ok(status) => { - if status.success() { - return TaskCompleted::Skipped; - } - } + if status.success() { + return TaskCompleted::Skipped; } } if let Some(cmd) = &self.task.command { @@ -164,14 +168,14 @@ impl TaskState { result = stdout_reader.next_line() => { match result { Ok(Some(line)) => info!(stdout = %line), - Ok(None) => break, + Ok(None) => {}, Err(e) => error!("Error reading stdout: {}", e), } } result = stderr_reader.next_line() => { match result { Ok(Some(line)) => error!(stderr = %line), - Ok(None) => break, + Ok(None) => {}, Err(e) => error!("Error reading stderr: {}", e), } } @@ -192,8 +196,9 @@ impl TaskState { } } } + } else { + return TaskCompleted::Skipped; } - return TaskCompleted::Skipped; } } @@ -223,6 +228,9 @@ impl Tasks { { return Err(Error::InvalidTaskName(name)); } + if task.status.is_some() && task.command.is_none() { + return Err(Error::MissingCommand(name)); + } let index = graph.add_node(Arc::new(RwLock::new(TaskState::new(task)))); task_indices.insert(name, index); } @@ -312,7 +320,7 @@ impl Tasks { match toposort(&self.graph, None) { Ok(indexes) => { - self.tasks_order = indexes; + self.tasks_order = indexes.into_iter().rev().collect(); Ok(()) } Err(cycle) => Err(Error::CycleDetected( @@ -322,7 +330,7 @@ impl Tasks { } #[instrument(skip(self))] - async fn run(&mut self) -> Result<(), Error> { + async fn run(&mut self) { let mut running_tasks = JoinSet::new(); for index in &self.tasks_order { @@ -332,7 +340,7 @@ impl Tasks { let mut dependencies_completed = true; for dep_index in self .graph - .neighbors_directed(*index, petgraph::Direction::Outgoing) + .neighbors_directed(*index, petgraph::Direction::Incoming) { match &self.graph[dep_index].read().await.status { TaskStatus::Completed(completed) => { @@ -358,7 +366,7 @@ impl Tasks { break; } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } let task_state_clone = Arc::clone(task_state); @@ -373,11 +381,9 @@ impl Tasks { while let Some(res) = running_tasks.join_next().await { match res { Ok(_) => (), - Err(e) => eprintln!("Task failed: {:?}", e), + Err(e) => eprintln!("Task crashed: {}", e), } } - - Ok(()) } } @@ -475,9 +481,7 @@ impl TasksUi { let tasks_clone = Arc::clone(&self.tasks); let handle = tokio::spawn(async move { let mut tasks = tasks_clone.lock().await; - if let Err(e) = tasks.run().await { - eprintln!("Error running tasks: {:?}", e); - } + tasks.run().await }); // start TUI @@ -639,33 +643,35 @@ async fn test_basic_tasks() -> Result<(), Error> { .unwrap(), ) .await?; - tasks.run().await?; + tasks.run().await; // Assert the order is 1, 3, 4 and they all succeed - assert_eq!(tasks.tasks_order.len(), 3); - assert_matches!( - tasks.graph[tasks.tasks_order[0]].read().await.status, - TaskStatus::Completed(TaskCompleted::Success(_)) - ); - assert_eq!( - tasks.graph[tasks.tasks_order[0]].read().await.task.name, - "myapp:task_1" - ); - assert_matches!( - tasks.graph[tasks.tasks_order[1]].read().await.status, - TaskStatus::Completed(TaskCompleted::Success(_)) - ); - assert_eq!( - tasks.graph[tasks.tasks_order[1]].read().await.task.name, - "myapp:task_3" - ); + let task_1_name = String::from("myapp:task_1"); + let task_3_name = String::from("myapp:task_3"); + let task_4_name = String::from("myapp:task_4"); + let tasks_order_len = tasks.tasks_order.len(); + let task_0 = tasks.graph[tasks.tasks_order[0]].read().await; + let task_1 = tasks.graph[tasks.tasks_order[1]].read().await; + let task_2 = tasks.graph[tasks.tasks_order[2]].read().await; assert_matches!( - tasks.graph[tasks.tasks_order[2]].read().await.status, - TaskStatus::Completed(TaskCompleted::Success(_)) - ); - assert_eq!( - tasks.graph[tasks.tasks_order[2]].read().await.task.name, - "myapp:task_4" + ( + tasks_order_len, + &task_0.status, + &task_0.task.name, + &task_1.status, + &task_1.task.name, + &task_2.status, + &task_2.task.name + ), + ( + 3, + TaskStatus::Completed(TaskCompleted::Success(_)), + task_1_name, + TaskStatus::Completed(TaskCompleted::Success(_)), + task_3_name, + TaskStatus::Completed(TaskCompleted::Success(_)), + task_4_name + ) ); Ok(()) } @@ -730,14 +736,14 @@ async fn test_status() -> Result<(), Error> { }; let (mut tasks, _) = run_task("myapp:task_1").await.unwrap(); - tasks.run().await?; + tasks.run().await; assert_matches!( tasks.graph[tasks.tasks_order[0]].read().await.status, TaskStatus::Completed(TaskCompleted::Skipped) ); let (mut tasks, _) = run_task("myapp:task_2").await.unwrap(); - tasks.run().await?; + tasks.run().await; assert_matches!( tasks.graph[tasks.tasks_order[0]].read().await.status, TaskStatus::Completed(TaskCompleted::Success(_)) @@ -746,6 +752,29 @@ async fn test_status() -> Result<(), Error> { Ok(()) } +#[test(tokio::test)] +async fn test_status_without_command() -> Result<(), Error> { + let status_script = create_script("#!/bin/sh\nexit 0")?; + + let result = Tasks::new( + Config::try_from(json!({ + "roots": ["myapp:task_1"], + "tasks": [ + { + "name": "myapp:task_1", + "status": status_script.to_str().unwrap() + } + ] + })) + .unwrap(), + ) + .await; + + assert!(matches!(result, Err(Error::MissingCommand(_)))); + Ok(()) +} + +#[cfg(test)] fn create_script(script: &str) -> std::io::Result { let mut temp_file = tempfile::Builder::new() .prefix(&format!("script")) From 656d8953b05fd06789da9e53f738edb293586fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 30 Aug 2024 18:04:39 +0100 Subject: [PATCH 03/45] tests for DependencyFailed --- devenv/src/tasks.rs | 453 ++++++++++++++++++++++++++++++-------------- 1 file changed, 316 insertions(+), 137 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index cc093cda2..2fd5aaffb 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -96,11 +96,20 @@ impl TryFrom for Config { } } -#[derive(Debug)] +type Output = Vec<(std::time::Instant, String)>; + +#[derive(Debug, Clone)] +struct TaskFailure { + stdout: Output, + stderr: Output, + error: String, +} + +#[derive(Debug, Clone)] enum TaskCompleted { Success(Duration), Skipped, - Failed(Duration), + Failed(Duration, TaskFailure), DependencyFailed, } @@ -108,12 +117,12 @@ impl TaskCompleted { fn has_failed(&self) -> bool { matches!( self, - TaskCompleted::Failed(_) | TaskCompleted::DependencyFailed + TaskCompleted::Failed(_, _) | TaskCompleted::DependencyFailed ) } } -#[derive(Debug)] +#[derive(Debug, Clone)] enum TaskStatus { Pending, Running(Instant), @@ -134,49 +143,112 @@ impl TaskState { } } - #[instrument] + #[instrument(ret)] async fn run(&mut self) -> TaskCompleted { let now = Instant::now(); self.status = TaskStatus::Running(now); if let Some(cmd) = &self.task.status { - // TODO: stdout/stderr should be piped for debugging - let status = Command::new(cmd) + let result = Command::new(cmd) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .status() - .await - .expect("Failed to execute status"); - if status.success() { - return TaskCompleted::Skipped; + .await; + match result { + Ok(status) => { + if status.success() { + return TaskCompleted::Skipped; + } + } + Err(e) => { + // TODO: stdout, stderr + return TaskCompleted::Failed( + now.elapsed(), + TaskFailure { + stdout: Vec::new(), + stderr: Vec::new(), + error: e.to_string(), + }, + ); + } } } if let Some(cmd) = &self.task.command { - let mut child = Command::new(cmd) + let result = Command::new(cmd) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn() - .expect("Failed to execute command"); + .spawn(); + + let mut child = match result { + Ok(c) => c, + Err(e) => { + return TaskCompleted::Failed( + now.elapsed(), + TaskFailure { + stdout: Vec::new(), + stderr: Vec::new(), + error: e.to_string(), + }, + ); + } + }; - let stdout = child.stdout.take().expect("Failed to open stdout"); - let stderr = child.stderr.take().expect("Failed to open stderr"); + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + return TaskCompleted::Failed( + now.elapsed(), + TaskFailure { + stdout: Vec::new(), + stderr: Vec::new(), + error: "Failed to capture stdout".to_string(), + }, + ) + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + return TaskCompleted::Failed( + now.elapsed(), + TaskFailure { + stdout: Vec::new(), + stderr: Vec::new(), + error: "Failed to capture stderr".to_string(), + }, + ) + } + }; let mut stderr_reader = BufReader::new(stderr).lines(); let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stdout_lines = Vec::new(); + let mut stderr_lines = Vec::new(); + loop { tokio::select! { result = stdout_reader.next_line() => { match result { - Ok(Some(line)) => info!(stdout = %line), + Ok(Some(line)) => { + info!(stdout = %line); + stdout_lines.push((std::time::Instant::now(), line)); + }, Ok(None) => {}, - Err(e) => error!("Error reading stdout: {}", e), + Err(e) => { + error!("Error reading stdout: {}", e); + stderr_lines.push((std::time::Instant::now(), e.to_string())); + }, } } result = stderr_reader.next_line() => { match result { - Ok(Some(line)) => error!(stderr = %line), + Ok(Some(line)) => { + stderr_lines.push((std::time::Instant::now(), line)); + }, Ok(None) => {}, - Err(e) => error!("Error reading stderr: {}", e), + Err(e) => { + stderr_lines.push((std::time::Instant::now(), e.to_string())); + }, } } result = child.wait() => { @@ -185,12 +257,26 @@ impl TaskState { if status.success() { return TaskCompleted::Success(now.elapsed()); } else { - return TaskCompleted::Failed(now.elapsed()); + return TaskCompleted::Failed( + now.elapsed(), + TaskFailure { + stdout: stdout_lines, + stderr: stderr_lines, + error: format!("Task exited with status: {}", status), + }, + ); } }, Err(e) => { error!("Error waiting for command: {}", e); - return TaskCompleted::Failed(now.elapsed()); + return TaskCompleted::Failed( + now.elapsed(), + TaskFailure { + stdout: stdout_lines, + stderr: stderr_lines, + error: format!("Error waiting for command: {}", e), + }, + ); } } } @@ -249,7 +335,7 @@ impl Tasks { tasks_order: vec![], }; tasks.resolve_dependencies(task_indices).await?; - tasks.schedule().await?; + tasks.tasks_order = tasks.schedule().await?; Ok((tasks, receiver_rx)) } @@ -283,46 +369,49 @@ impl Tasks { } } - #[instrument(skip(self))] - async fn schedule(&mut self) -> Result<(), Error> { - // TODO: we traverse the graph twice, see https://github.com/petgraph/petgraph/issues/661 + #[instrument(skip(self), fields(graph, subgraph), ret)] + async fn schedule(&mut self) -> Result, Error> { let mut subgraph = DiGraph::new(); - - // Map to track which nodes in the original graph correspond to which nodes in the new subgraph let mut node_map = HashMap::new(); let mut visited = HashSet::new(); + let mut to_visit = Vec::new(); - // Traverse the graph starting from the root nodes - for root_index in &self.roots { - let mut dfs = Dfs::new(&self.graph, *root_index); - - while let Some(node) = dfs.next(&self.graph) { - if visited.insert(node) { - // Add the node to the new subgraph and map it - let new_node = subgraph.add_node(self.graph[node].clone()); - node_map.insert(node, new_node); - - // Copy edges to the new subgraph - for edge in self.graph.edges(node) { - let target = edge.target(); - if visited.contains(&target) { - // Both nodes must already be added to subgraph - let new_source = node_map[&node]; - let new_target = node_map[&target]; - subgraph.add_edge(new_source, new_target, ()); - } - } + // Start with root nodes + for &root_index in &self.roots { + to_visit.push(root_index); + } + + // Depth-first search including dependencies + while let Some(node) = to_visit.pop() { + if visited.insert(node) { + let new_node = subgraph.add_node(self.graph[node].clone()); + node_map.insert(node, new_node); + + // Add dependencies to visit + for neighbor in self + .graph + .neighbors_directed(node, petgraph::Direction::Incoming) + { + to_visit.push(neighbor); + } + } + } + + // Add edges to subgraph + for (&old_node, &new_node) in &node_map { + for edge in self.graph.edges(old_node) { + let target = edge.target(); + if let Some(&new_target) = node_map.get(&target) { + subgraph.add_edge(new_node, new_target, ()); } } } self.graph = subgraph; + // Run topological sort on the subgraph match toposort(&self.graph, None) { - Ok(indexes) => { - self.tasks_order = indexes.into_iter().rev().collect(); - Ok(()) - } + Ok(indexes) => Ok(indexes), Err(cycle) => Err(Error::CycleDetected( self.graph[cycle.node_id()].read().await.task.name.clone(), )), @@ -336,7 +425,9 @@ impl Tasks { for index in &self.tasks_order { let task_state = &self.graph[*index]; - loop { + let mut dependency_failed = false; + + 'dependency_check: loop { let mut dependencies_completed = true; for dep_index in self .graph @@ -345,10 +436,8 @@ impl Tasks { match &self.graph[dep_index].read().await.status { TaskStatus::Completed(completed) => { if completed.has_failed() { - let mut task_state = self.graph[dep_index].write().await; - task_state.status = - TaskStatus::Completed(TaskCompleted::DependencyFailed); - continue; + dependency_failed = true; + break 'dependency_check; } } TaskStatus::Pending => { @@ -369,13 +458,18 @@ impl Tasks { tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - let task_state_clone = Arc::clone(task_state); + if dependency_failed { + let mut task_state = task_state.write().await; + task_state.status = TaskStatus::Completed(TaskCompleted::DependencyFailed); + } else { + let task_state_clone = Arc::clone(task_state); - running_tasks.spawn(async move { - let mut task_state = task_state_clone.write().await; - let completed = task_state.run().await; - task_state.status = TaskStatus::Completed(completed); - }); + running_tasks.spawn(async move { + let mut task_state = task_state_clone.write().await; + let completed = task_state.run().await; + task_state.status = TaskStatus::Completed(completed); + }); + } } while let Some(res) = running_tasks.join_next().await { @@ -453,7 +547,7 @@ impl TasksUi { tasks_status.succeeded += 1; ("Succeeded".green().bold(), Some(*duration)) } - TaskStatus::Completed(TaskCompleted::Failed(duration)) => { + TaskStatus::Completed(TaskCompleted::Failed(duration, _)) => { tasks_status.failed += 1; ("Failed".red().bold(), Some(*duration)) } @@ -608,12 +702,15 @@ async fn test_task_name() -> Result<(), Error> { #[test(tokio::test)] async fn test_basic_tasks() -> Result<(), Error> { - let script1 = - create_script("#!/bin/sh\necho 'Task 1 is running' && sleep 2 && echo 'Task 1 completed'")?; - let script2 = - create_script("#!/bin/sh\necho 'Task 2 is running' && sleep 3 && echo 'Task 2 completed'")?; - let script3 = - create_script("#!/bin/sh\necho 'Task 3 is running' && sleep 1 && echo 'Task 3 completed'")?; + let script1 = create_script( + "#!/bin/sh\necho 'Task 1 is running' && sleep 0.5 && echo 'Task 1 completed'", + )?; + let script2 = create_script( + "#!/bin/sh\necho 'Task 2 is running' && sleep 0.5 && echo 'Task 2 completed'", + )?; + let script3 = create_script( + "#!/bin/sh\necho 'Task 3 is running' && sleep 0.5 && echo 'Task 3 completed'", + )?; let script4 = create_script("#!/bin/sh\necho 'Task 4 is running' && echo 'Task 4 completed'")?; let (mut tasks, _) = Tasks::new( @@ -645,88 +742,76 @@ async fn test_basic_tasks() -> Result<(), Error> { .await?; tasks.run().await; - // Assert the order is 1, 3, 4 and they all succeed - let task_1_name = String::from("myapp:task_1"); - let task_3_name = String::from("myapp:task_3"); - let task_4_name = String::from("myapp:task_4"); - let tasks_order_len = tasks.tasks_order.len(); - let task_0 = tasks.graph[tasks.tasks_order[0]].read().await; - let task_1 = tasks.graph[tasks.tasks_order[1]].read().await; - let task_2 = tasks.graph[tasks.tasks_order[2]].read().await; + let task_statuses = inspect_tasks(&tasks).await; + let task_statuses = task_statuses.as_slice(); assert_matches!( - ( - tasks_order_len, - &task_0.status, - &task_0.task.name, - &task_1.status, - &task_1.task.name, - &task_2.status, - &task_2.task.name - ), - ( - 3, - TaskStatus::Completed(TaskCompleted::Success(_)), - task_1_name, - TaskStatus::Completed(TaskCompleted::Success(_)), - task_3_name, - TaskStatus::Completed(TaskCompleted::Success(_)), - task_4_name - ) + task_statuses, + [ + (name1, TaskStatus::Completed(TaskCompleted::Success(_))), + (name2, TaskStatus::Completed(TaskCompleted::Success(_))), + (name3, TaskStatus::Completed(TaskCompleted::Success(_))) + ] if name1 == "myapp:task_1" && name2 == "myapp:task_3" && name3 == "myapp:task_4" ); Ok(()) } -// #[test(tokio::test)] -// async fn test_tasks_cycle() -> Result<(), Error> { -// let (mut tasks, _) = Tasks::new( -// Config::try_from(json!({ -// "roots": ["myapp:task_1"], -// "tasks": [ -// { -// "name": "myapp:task_1", -// "depends": ["myapp:task_2"], -// "command": "echo 'Task 1 is running' && sleep 2 && echo 'Task 1 completed'" -// }, -// { -// "name": "myapp:task_2", -// "depends": ["myapp:task_1"], -// "command": "echo 'Task 2 is running' && sleep 3 && echo 'Task 2 completed'" -// } -// ] -// })) -// .unwrap(), -// ) -// .await?; - -// let err = "myapp_task_2".to_string(); - -// assert!(matches!(tasks.run().await, Err(Error::CycleDetected(err)))); -// Ok(()) -// } +#[test(tokio::test)] +async fn test_tasks_cycle() -> Result<(), Error> { + let result = Tasks::new( + Config::try_from(json!({ + "roots": ["myapp:task_1"], + "tasks": [ + { + "name": "myapp:task_1", + "depends": ["myapp:task_2"], + "command": "echo 'Task 1 is running' && echo 'Task 1 completed'" + }, + { + "name": "myapp:task_2", + "depends": ["myapp:task_1"], + "command": "echo 'Task 2 is running' && echo 'Task 2 completed'" + } + ] + })) + .unwrap(), + ) + .await; + if let Err(Error::CycleDetected(task)) = result { + assert_eq!(task, "myapp:task_2".to_string()); + } else { + panic!("Expected Error::CycleDetected, got {:?}", result); + } + Ok(()) +} #[test(tokio::test)] async fn test_status() -> Result<(), Error> { - let run_task = |root: &'static str| async move { - let command_script1 = - create_script("#!/bin/sh\necho 'Task 1 is running' && echo 'Task 1 completed'")?; - let status_script1 = create_script("#!/bin/sh\nexit 0")?; - let command_script2 = - create_script("#!/bin/sh\necho 'Task 2 is running' && echo 'Task 2 completed'")?; - let status_script2 = create_script("#!/bin/sh\nexit 1")?; - + let command_script1 = + create_script("#!/bin/sh\necho 'Task 1 is running' && echo 'Task 1 completed'")?; + let status_script1 = create_script("#!/bin/sh\nexit 0")?; + let command_script2 = + create_script("#!/bin/sh\necho 'Task 2 is running' && echo 'Task 2 completed'")?; + let status_script2 = create_script("#!/bin/sh\nexit 1")?; + + let command1 = command_script1.to_str().unwrap(); + let status1 = status_script1.to_str().unwrap(); + let command2 = command_script2.to_str().unwrap(); + let status2 = status_script2.to_str().unwrap(); + + let create_tasks = |root: &'static str| async move { Tasks::new( Config::try_from(json!({ "roots": [root], "tasks": [ { "name": "myapp:task_1", - "command": command_script1.to_str().unwrap(), - "status": status_script1.to_str().unwrap() + "command": command1, + "status": status1 }, { "name": "myapp:task_2", - "command": command_script2.to_str().unwrap(), - "status": status_script2.to_str().unwrap() + "command": command2, + "status": status2 } ] })) @@ -735,15 +820,17 @@ async fn test_status() -> Result<(), Error> { .await }; - let (mut tasks, _) = run_task("myapp:task_1").await.unwrap(); + let (mut tasks, _) = create_tasks("myapp:task_1").await.unwrap(); tasks.run().await; + assert_eq!(tasks.tasks_order.len(), 1); assert_matches!( tasks.graph[tasks.tasks_order[0]].read().await.status, TaskStatus::Completed(TaskCompleted::Skipped) ); - let (mut tasks, _) = run_task("myapp:task_2").await.unwrap(); + let (mut tasks, _) = create_tasks("myapp:task_2").await.unwrap(); tasks.run().await; + assert_eq!(tasks.tasks_order.len(), 1); assert_matches!( tasks.graph[tasks.tasks_order[0]].read().await.status, TaskStatus::Completed(TaskCompleted::Success(_)) @@ -752,6 +839,46 @@ async fn test_status() -> Result<(), Error> { Ok(()) } +#[test(tokio::test)] +async fn test_nonexistent_script() -> Result<(), Error> { + let (tasks, _) = Tasks::new( + Config::try_from(json!({ + "roots": ["myapp:task_1"], + "tasks": [ + { + "name": "myapp:task_1", + "command": "/path/to/nonexistent/script.sh" + } + ] + })) + .unwrap(), + ) + .await?; + + let mut tasks = tasks; + tasks.run().await; + + let task_statuses = inspect_tasks(&tasks).await; + let task_statuses = task_statuses.as_slice(); + let task_1 = String::from("myapp:task_1"); + assert_matches!( + &task_statuses, + [( + task_1, + TaskStatus::Completed(TaskCompleted::Failed( + _, + TaskFailure { + stdout: _, + stderr: _, + error + } + )) + )] if error == "No such file or directory (os error 2)" + ); + + Ok(()) +} + #[test(tokio::test)] async fn test_status_without_command() -> Result<(), Error> { let status_script = create_script("#!/bin/sh\nexit 0")?; @@ -774,6 +901,58 @@ async fn test_status_without_command() -> Result<(), Error> { Ok(()) } +#[test(tokio::test)] +async fn test_dependency_failure() -> Result<(), Error> { + let failing_script = create_script("#!/bin/sh\necho 'Failing task' && exit 1")?; + let dependent_script = create_script("#!/bin/sh\necho 'Dependent task' && exit 0")?; + + let (mut tasks, _) = Tasks::new( + Config::try_from(json!({ + "roots": ["myapp:task_2"], + "tasks": [ + { + "name": "myapp:task_1", + "command": failing_script.to_str().unwrap() + }, + { + "name": "myapp:task_2", + "depends": ["myapp:task_1"], + "command": dependent_script.to_str().unwrap() + } + ] + })) + .unwrap(), + ) + .await?; + + tasks.run().await; + + let task_statuses = inspect_tasks(&tasks).await; + let task_statuses_slice = &task_statuses.as_slice(); + assert_matches!( + *task_statuses_slice, + [ + (task_1, TaskStatus::Completed(TaskCompleted::Failed(_, _))), + ( + task_2, + TaskStatus::Completed(TaskCompleted::DependencyFailed) + ) + ] if task_1 == "myapp:task_1" && task_2 == "myapp:task_2" + ); + + Ok(()) +} + +#[cfg(test)] +async fn inspect_tasks(tasks: &Tasks) -> Vec<(String, TaskStatus)> { + let mut result = Vec::new(); + for index in &tasks.tasks_order { + let task_state = tasks.graph[*index].read().await; + result.push((task_state.task.name.clone(), task_state.status.clone())); + } + result +} + #[cfg(test)] fn create_script(script: &str) -> std::io::Result { let mut temp_file = tempfile::Builder::new() From 1a286b95ebcf3cafef8264d555db0ef6bcdc55a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 30 Aug 2024 18:48:56 +0100 Subject: [PATCH 04/45] Fix a bunch of errors for tasks --- devenv.nix | 4 ++-- devenv/src/devenv.rs | 2 +- docs/tasks.md | 2 +- src/modules/tasks.nix | 8 +++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/devenv.nix b/devenv.nix index 666e91b80..330340632 100644 --- a/devenv.nix +++ b/devenv.nix @@ -211,8 +211,8 @@ EOF }; - tasks.sleep.exec = "sleep 10"; - tasks.sleep.depends = [ "enterShell" ]; + tasks."devenv:sleep".exec = "sleep 1"; + tasks."devenv:enterShell".depends = [ "devenv:sleep" ]; pre-commit.hooks = { nixpkgs-fmt.enable = true; diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index 789a971e2..931e2395b 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -340,7 +340,7 @@ impl<'a> Devenv<'a> { let copy_script = self .nix - .build(&[&format!(".#devenv.containers.{name}.copyScript")]) + .build(&[&format!("devenv.containers.{name}.copyScript")]) .await?; let copy_script = ©_script[0]; let copy_script_string = ©_script.to_string_lossy(); diff --git a/docs/tasks.md b/docs/tasks.md index cf97a7746..7da5d5c35 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -38,7 +38,7 @@ Tasks can also reference scripts and depend on other tasks, for example when ent exec = "echo 'Hello world from bash!'"; depends = [ "python:hello" ]; }; - enterShell.depends = [ "bash:hello" ]; + "devenv:enterShell".depends = [ "bash:hello" ]; }; } ``` diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index 5eb840e5b..cffd51e19 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -26,8 +26,8 @@ let default = config.package.pname; }; package = lib.mkOption { - type = types.nullOr types.package; - default = null; + type = types.package; + default = pkgs.bash; description = "Package to install for this task."; }; command = lib.mkOption { @@ -79,7 +79,6 @@ in options.task.config = lib.mkOption { type = types.package; internal = true; - default = (pkgs.formats.json { }).generate "tasks.json" (lib.mapAttrsToList (name: value: { inherit name; } // value) config.tasks); }; config = { @@ -87,6 +86,9 @@ in lib.mapAttrsToList (name: task: "${name}: ${task.description} ${task.command}") config.tasks; + + task.config = (pkgs.formats.json { }).generate "tasks.json" + (lib.mapAttrsToList (name: value: { inherit name; } // value.config) config.tasks); tasks = { "devenv:enterShell" = { description = "Runs when entering the shell"; From 7f23705a954f4064f401312f255f1af4f4aa73ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 30 Aug 2024 19:15:50 +0100 Subject: [PATCH 05/45] tasks: handle stdout/stderr and errors from tasks --- devenv/src/tasks.rs | 59 ++++++++++++++++++++++++++++++++++++++------- docs/tasks.md | 6 +++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 2fd5aaffb..b95bf74d2 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -537,23 +537,29 @@ impl TasksUi { } TaskStatus::Running(started) => { tasks_status.running += 1; - ("Running".blue().bold(), Some(started.elapsed())) + ( + format!("{:17}", "Running").blue().bold(), + Some(started.elapsed()), + ) } TaskStatus::Completed(TaskCompleted::Skipped) => { tasks_status.skipped += 1; - ("Skipped".blue().bold(), None) + (format!("{:17}", "Skipped").blue().bold(), None) } TaskStatus::Completed(TaskCompleted::Success(duration)) => { tasks_status.succeeded += 1; - ("Succeeded".green().bold(), Some(*duration)) + ( + format!("{:17}", "Succeeded").green().bold(), + Some(*duration), + ) } TaskStatus::Completed(TaskCompleted::Failed(duration, _)) => { tasks_status.failed += 1; - ("Failed".red().bold(), Some(*duration)) + (format!("{:17}", "Failed").red().bold(), Some(*duration)) } TaskStatus::Completed(TaskCompleted::DependencyFailed) => { tasks_status.dependency_failed += 1; - ("Dependency failed".magenta().bold(), None) + (format!("{:17}", "Dependency failed").magenta().bold(), None) } }; @@ -579,20 +585,20 @@ impl TasksUi { }); // start TUI - let mut stdout = io::stdout(); + let mut stderr = io::stderr(); let mut last_list_height: u16 = 0; loop { let tasks_status = self.get_tasks_status().await; execute!( - stdout, + stderr, // Clear the screen from the cursor down cursor::MoveUp(last_list_height), Clear(ClearType::FromCursorDown), style::PrintStyledContent( format!( - "{}\nTasks: {}\n", + "{}\n\nTasks: {}\n", tasks_status.lines.join("\n"), [ if tasks_status.pending > 0 { @@ -636,7 +642,7 @@ impl TasksUi { .join(", ") ) .stylize() - ) + ), )?; last_list_height = tasks_status.lines.len() as u16 + 1; @@ -649,6 +655,41 @@ impl TasksUi { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } + let errors = { + let tasks = self.tasks.lock().await; + + let mut errors = String::new(); + for index in &tasks.tasks_order { + let task_state = tasks.graph[*index].read().await; + if let TaskStatus::Completed(TaskCompleted::Failed(_, failure)) = &task_state.status + { + errors.push_str(&format!( + "\n--- {} failed with error: {}\n", + task_state.task.name, failure.error + )); + errors.push_str(&format!("--- {} stdout:\n", task_state.task.name)); + for (time, line) in &failure.stdout { + errors.push_str(&format!( + "{:07.2}: {}\n", + time.elapsed().as_secs_f32(), + line + )); + } + errors.push_str(&format!("--- {} stderr:\n", task_state.task.name)); + for (time, line) in &failure.stderr { + errors.push_str(&format!( + "{:07.2}: {}\n", + time.elapsed().as_secs_f32(), + line + )); + } + errors.push_str("---\n") + } + } + errors.stylize() + }; + execute!(stderr, style::PrintStyledContent(errors)); + let tasks_status = self.get_tasks_status().await; Ok(tasks_status) } diff --git a/docs/tasks.md b/docs/tasks.md index 7da5d5c35..18f26bf92 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -47,7 +47,9 @@ Tasks can also reference scripts and depend on other tasks, for example when ent $ devenv shell • Building shell ... • Entering shell ... -Hello world from Python! -Hello world from bash! +... $ ``` + + +`status` From e61339a7069609ab8ca24598fa5a4beaa5996c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 30 Aug 2024 22:17:34 +0100 Subject: [PATCH 06/45] tasks: fix remaining bugs to happy path --- devenv/src/devenv.rs | 6 ++++++ devenv/src/tasks.rs | 6 +++--- src/modules/tasks.nix | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index 931e2395b..e4725ceee 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -525,6 +525,12 @@ impl<'a> Devenv<'a> { serde_json::from_str(&tasks_json).expect("Failed to parse tasks config"); // run tasks let config = tasks::Config { roots, tasks }; + if self.global_options.verbose { + println!( + "Tasks config: {}", + serde_json::to_string_pretty(&config).unwrap() + ); + } let mut tui = tasks::TasksUi::new(config).await?; let tasks_status = tui.run().await?; diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index b95bf74d2..235897d37 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -9,7 +9,7 @@ use petgraph::graph::{DiGraph, NodeIndex}; use petgraph::visit::{Dfs, EdgeRef}; #[cfg(test)] use pretty_assertions::assert_matches; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[cfg(test)] use serde_json::json; use std::io::{self, Write}; @@ -71,7 +71,7 @@ impl Display for Error { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct TaskConfig { name: String, #[serde(default)] @@ -82,7 +82,7 @@ pub struct TaskConfig { status: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct Config { pub tasks: Vec, pub roots: Vec, diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index cffd51e19..77432915c 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -8,7 +8,7 @@ let if builtins.isNull command then null else - pkgs.writeScriptBin name '' + pkgs.writeScript name '' #!${pkgs.lib.getBin config.package}/bin/${config.binary} ${command} ''; @@ -44,7 +44,7 @@ let statusCommand = lib.mkOption { type = types.nullOr types.package; internal = true; - default = mkCommand config.exec; + default = mkCommand config.status; description = "Path to the script to run."; }; config = lib.mkOption { From d5710f5f7728881ec63ba32955ff93baeb16753c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 2 Sep 2024 08:42:21 +0100 Subject: [PATCH 07/45] tasks: allow multiple : --- devenv/src/tasks.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 235897d37..9e40e3b9d 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -64,7 +64,7 @@ impl Display for Error { ), Error::InvalidTaskName(task) => write!( f, - "Invalid task name: {}, expected [a-zA-Z-_]:[a-zA-Z-_]", + "Invalid task name: {}, expected [a-zA-Z-_]+:[a-zA-Z-_]+", task ), } @@ -304,7 +304,7 @@ impl Tasks { for task in config.tasks { let name = task.name.clone(); if !task.name.contains(':') - || task.name.split(':').count() != 2 + || task.name.split(':').count() < 2 || task.name.starts_with(':') || task.name.ends_with(':') || !task @@ -723,6 +723,7 @@ async fn test_task_name() -> Result<(), Error> { "devenv:enterShell", "devenv:enter-shell", "devenv:enter_shell", + "devenv:python:virtualenv", ]; for task in valid_names { assert_matches!( From e15c2890607b2ad6ed704876fd4084278dc7032f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 2 Sep 2024 12:56:46 +0100 Subject: [PATCH 08/45] tasks: improve output --- devenv.nix | 4 +- devenv/src/tasks.rs | 103 ++++++++++++++++++++++++-------------------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/devenv.nix b/devenv.nix index 330340632..ae82a7a7d 100644 --- a/devenv.nix +++ b/devenv.nix @@ -211,7 +211,9 @@ EOF }; - tasks."devenv:sleep".exec = "sleep 1"; + tasks."devenv:sleep3".exec = "sleep 3"; + tasks."devenv:sleep".exec = "sleep 5"; + tasks."devenv:sleep".depends = [ "devenv:sleep3" ]; tasks."devenv:enterShell".depends = [ "devenv:sleep" ]; pre-commit.hooks = { diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 9e40e3b9d..d300ea736 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -144,9 +144,7 @@ impl TaskState { } #[instrument(ret)] - async fn run(&mut self) -> TaskCompleted { - let now = Instant::now(); - self.status = TaskStatus::Running(now); + async fn run(&mut self, now: Instant) -> TaskCompleted { if let Some(cmd) = &self.task.status { let result = Command::new(cmd) .stdout(Stdio::piped()) @@ -291,18 +289,21 @@ impl TaskState { #[derive(Debug)] struct Tasks { roots: Vec, - sender_tx: Sender, + // Stored for reporting + root_names: Vec, + longest_task_name: usize, graph: DiGraph>, ()>, tasks_order: Vec, } impl Tasks { - async fn new(config: Config) -> Result<(Self, Receiver), Error> { - let (sender_tx, receiver_rx) = channel(1000); + async fn new(config: Config) -> Result { let mut graph = DiGraph::new(); let mut task_indices = HashMap::new(); + let mut longest_task_name = 0; for task in config.tasks { let name = task.name.clone(); + longest_task_name = longest_task_name.max(name.len()); if !task.name.contains(':') || task.name.split(':').count() < 2 || task.name.starts_with(':') @@ -321,7 +322,7 @@ impl Tasks { task_indices.insert(name, index); } let mut roots = Vec::new(); - for name in config.roots { + for name in config.roots.clone() { if let Some(index) = task_indices.get(&name) { roots.push(*index); } else { @@ -330,13 +331,14 @@ impl Tasks { } let mut tasks = Self { roots, - sender_tx, + root_names: config.roots, + longest_task_name, graph, tasks_order: vec![], }; tasks.resolve_dependencies(task_indices).await?; tasks.tasks_order = tasks.schedule().await?; - Ok((tasks, receiver_rx)) + Ok(tasks) } async fn resolve_dependencies( @@ -419,7 +421,7 @@ impl Tasks { } #[instrument(skip(self))] - async fn run(&mut self) { + async fn run(&self) { let mut running_tasks = JoinSet::new(); for index in &self.tasks_order { @@ -462,11 +464,18 @@ impl Tasks { let mut task_state = task_state.write().await; task_state.status = TaskStatus::Completed(TaskCompleted::DependencyFailed); } else { - let task_state_clone = Arc::clone(task_state); + let now = Instant::now(); + + // hold write lock only to update the status + { + let mut task_state = task_state.write().await; + task_state.status = TaskStatus::Running(now); + } + let task_state_clone = Arc::clone(task_state); running_tasks.spawn(async move { let mut task_state = task_state_clone.write().await; - let completed = task_state.run().await; + let completed = task_state.run(now).await; task_state.status = TaskStatus::Completed(completed); }); } @@ -511,25 +520,22 @@ impl TasksStatus { } pub struct TasksUi { - tasks: Arc>, - receiver_rx: Receiver, + tasks: Arc, } impl TasksUi { pub async fn new(config: Config) -> Result { - let (tasks, receiver_rx) = Tasks::new(config).await?; + let tasks = Tasks::new(config).await?; Ok(Self { - tasks: Arc::new(Mutex::new(tasks)), - receiver_rx, + tasks: Arc::new(tasks), }) } async fn get_tasks_status(&self) -> TasksStatus { let mut tasks_status = TasksStatus::new(); - let tasks = self.tasks.lock().await; - for index in &tasks.tasks_order { - let task_state = tasks.graph[*index].read().await; + for index in &self.tasks.tasks_order { + let task_state = self.tasks.graph[*index].read().await; let (status_text, duration) = match &task_state.status { TaskStatus::Pending => { tasks_status.pending += 1; @@ -569,7 +575,14 @@ impl TasksUi { }; tasks_status.lines.push(format!( "{} {} {}", - status_text, &task_state.task.name, duration + status_text, + format!( + "{:width$}", + task_state.task.name.clone(), + width = self.tasks.longest_task_name + ) + .bold(), + duration, )); } @@ -577,60 +590,61 @@ impl TasksUi { } pub async fn run(&mut self) -> Result { + let mut stdout = io::stdout(); + let names = self.tasks.root_names.join(", ").bold(); + // start processing tasks let tasks_clone = Arc::clone(&self.tasks); - let handle = tokio::spawn(async move { - let mut tasks = tasks_clone.lock().await; - tasks.run().await - }); + let handle = tokio::spawn(async move { tasks_clone.run().await }); // start TUI - let mut stderr = io::stderr(); + let mut last_list_height: u16 = 0; loop { let tasks_status = self.get_tasks_status().await; execute!( - stderr, + stdout, // Clear the screen from the cursor down cursor::MoveUp(last_list_height), Clear(ClearType::FromCursorDown), style::PrintStyledContent( format!( - "{}\n\nTasks: {}\n", + "{}\nRunning {}: {}\n", tasks_status.lines.join("\n"), + names.clone(), [ if tasks_status.pending > 0 { - format!("{} {}", "Pending".blue().bold(), tasks_status.pending) + format!("{} {}", tasks_status.pending, "Pending".blue().bold()) } else { String::new() }, if tasks_status.running > 0 { - format!("{} {}", "Running".blue().bold(), tasks_status.running) + format!("{} {}", tasks_status.running, "Running".blue().bold()) } else { String::new() }, if tasks_status.skipped > 0 { - format!("{} {}", "Skipped".blue().bold(), tasks_status.skipped) + format!("{} {}", tasks_status.skipped, "Skipped".blue().bold()) } else { String::new() }, if tasks_status.succeeded > 0 { - format!("{} {}", "Succeeded".green().bold(), tasks_status.succeeded) + format!("{} {}", tasks_status.succeeded, "Succeeded".green().bold()) } else { String::new() }, if tasks_status.failed > 0 { - format!("{} {}", "Failed".red().bold(), tasks_status.failed) + format!("{} {}", tasks_status.failed, "Failed".red().bold()) } else { String::new() }, if tasks_status.dependency_failed > 0 { format!( "{} {}", - "Dependency Failed".red().bold(), - tasks_status.dependency_failed + tasks_status.dependency_failed, + "Dependency Failed".red().bold() ) } else { String::new() @@ -656,11 +670,9 @@ impl TasksUi { } let errors = { - let tasks = self.tasks.lock().await; - let mut errors = String::new(); - for index in &tasks.tasks_order { - let task_state = tasks.graph[*index].read().await; + for index in &self.tasks.tasks_order { + let task_state = self.tasks.graph[*index].read().await; if let TaskStatus::Completed(TaskCompleted::Failed(_, failure)) = &task_state.status { errors.push_str(&format!( @@ -688,7 +700,7 @@ impl TasksUi { } errors.stylize() }; - execute!(stderr, style::PrintStyledContent(errors)); + execute!(stdout, style::PrintStyledContent(errors))?; let tasks_status = self.get_tasks_status().await; Ok(tasks_status) @@ -755,7 +767,7 @@ async fn test_basic_tasks() -> Result<(), Error> { )?; let script4 = create_script("#!/bin/sh\necho 'Task 4 is running' && echo 'Task 4 completed'")?; - let (mut tasks, _) = Tasks::new( + let tasks = Tasks::new( Config::try_from(json!({ "roots": ["myapp:task_1", "myapp:task_4"], "tasks": [ @@ -862,7 +874,7 @@ async fn test_status() -> Result<(), Error> { .await }; - let (mut tasks, _) = create_tasks("myapp:task_1").await.unwrap(); + let tasks = create_tasks("myapp:task_1").await.unwrap(); tasks.run().await; assert_eq!(tasks.tasks_order.len(), 1); assert_matches!( @@ -870,7 +882,7 @@ async fn test_status() -> Result<(), Error> { TaskStatus::Completed(TaskCompleted::Skipped) ); - let (mut tasks, _) = create_tasks("myapp:task_2").await.unwrap(); + let tasks = create_tasks("myapp:task_2").await.unwrap(); tasks.run().await; assert_eq!(tasks.tasks_order.len(), 1); assert_matches!( @@ -883,7 +895,7 @@ async fn test_status() -> Result<(), Error> { #[test(tokio::test)] async fn test_nonexistent_script() -> Result<(), Error> { - let (tasks, _) = Tasks::new( + let tasks = Tasks::new( Config::try_from(json!({ "roots": ["myapp:task_1"], "tasks": [ @@ -897,7 +909,6 @@ async fn test_nonexistent_script() -> Result<(), Error> { ) .await?; - let mut tasks = tasks; tasks.run().await; let task_statuses = inspect_tasks(&tasks).await; @@ -948,7 +959,7 @@ async fn test_dependency_failure() -> Result<(), Error> { let failing_script = create_script("#!/bin/sh\necho 'Failing task' && exit 1")?; let dependent_script = create_script("#!/bin/sh\necho 'Dependent task' && exit 0")?; - let (mut tasks, _) = Tasks::new( + let tasks = Tasks::new( Config::try_from(json!({ "roots": ["myapp:task_2"], "tasks": [ From 8c271d370100bd8eb701289c0fb0d48caebd2ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 2 Sep 2024 13:06:02 +0100 Subject: [PATCH 09/45] tasks: garden --- devenv/src/tasks.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index d300ea736..4b192ac97 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -490,11 +490,6 @@ impl Tasks { } } -struct TaskUpdate { - name: String, - status: TaskStatus, -} - pub struct TasksStatus { lines: Vec, pub pending: usize, @@ -913,7 +908,6 @@ async fn test_nonexistent_script() -> Result<(), Error> { let task_statuses = inspect_tasks(&tasks).await; let task_statuses = task_statuses.as_slice(); - let task_1 = String::from("myapp:task_1"); assert_matches!( &task_statuses, [( @@ -926,7 +920,7 @@ async fn test_nonexistent_script() -> Result<(), Error> { error } )) - )] if error == "No such file or directory (os error 2)" + )] if error == "No such file or directory (os error 2)" && task_1 == "myapp:task_1" ); Ok(()) From 1b21d992a96b5613c2b274174ede8f3c1060f3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 2 Sep 2024 14:44:48 +0100 Subject: [PATCH 10/45] tasks: reduce locking for TUI to seamlessy update --- devenv/src/tasks.rs | 53 ++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 4b192ac97..5174c1eba 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -144,7 +144,7 @@ impl TaskState { } #[instrument(ret)] - async fn run(&mut self, now: Instant) -> TaskCompleted { + async fn run(&self, now: Instant) -> TaskCompleted { if let Some(cmd) = &self.task.status { let result = Command::new(cmd) .stdout(Stdio::piped()) @@ -474,9 +474,11 @@ impl Tasks { let task_state_clone = Arc::clone(task_state); running_tasks.spawn(async move { - let mut task_state = task_state_clone.write().await; - let completed = task_state.run(now).await; - task_state.status = TaskStatus::Completed(completed); + let completed = task_state_clone.read().await.run(now).await; + { + let mut task_state = task_state_clone.write().await; + task_state.status = TaskStatus::Completed(completed); + } }); } } @@ -530,8 +532,11 @@ impl TasksUi { let mut tasks_status = TasksStatus::new(); for index in &self.tasks.tasks_order { - let task_state = self.tasks.graph[*index].read().await; - let (status_text, duration) = match &task_state.status { + let (task_status, task_name) = { + let task_state = self.tasks.graph[*index].read().await; + (task_state.status.clone(), task_state.task.name.clone()) + }; + let (status_text, duration) = match task_status { TaskStatus::Pending => { tasks_status.pending += 1; continue; @@ -549,14 +554,11 @@ impl TasksUi { } TaskStatus::Completed(TaskCompleted::Success(duration)) => { tasks_status.succeeded += 1; - ( - format!("{:17}", "Succeeded").green().bold(), - Some(*duration), - ) + (format!("{:17}", "Succeeded").green().bold(), Some(duration)) } TaskStatus::Completed(TaskCompleted::Failed(duration, _)) => { tasks_status.failed += 1; - (format!("{:17}", "Failed").red().bold(), Some(*duration)) + (format!("{:17}", "Failed").red().bold(), Some(duration)) } TaskStatus::Completed(TaskCompleted::DependencyFailed) => { tasks_status.dependency_failed += 1; @@ -571,12 +573,7 @@ impl TasksUi { tasks_status.lines.push(format!( "{} {} {}", status_text, - format!( - "{:width$}", - task_state.task.name.clone(), - width = self.tasks.longest_task_name - ) - .bold(), + format!("{:width$}", task_name, width = self.tasks.longest_task_name).bold(), duration, )); } @@ -588,6 +585,8 @@ impl TasksUi { let mut stdout = io::stdout(); let names = self.tasks.root_names.join(", ").bold(); + let started = std::time::Instant::now(); + // start processing tasks let tasks_clone = Arc::clone(&self.tasks); let handle = tokio::spawn(async move { tasks_clone.run().await }); @@ -597,6 +596,11 @@ impl TasksUi { let mut last_list_height: u16 = 0; loop { + let mut finished = false; + if handle.is_finished() { + finished = true; + } + let tasks_status = self.get_tasks_status().await; execute!( @@ -606,8 +610,13 @@ impl TasksUi { Clear(ClearType::FromCursorDown), style::PrintStyledContent( format!( - "{}\nRunning {}: {}\n", + "{}\n{} {}: {}\n", tasks_status.lines.join("\n"), + if finished { + format!("Finished in {:.2?}", started.elapsed()) + } else { + format!("Running for {:.2?}", started.elapsed()) + }, names.clone(), [ if tasks_status.pending > 0 { @@ -654,14 +663,14 @@ impl TasksUi { ), )?; - last_list_height = tasks_status.lines.len() as u16 + 1; - - if handle.is_finished() { + if finished { break; } + last_list_height = tasks_status.lines.len() as u16 + 1; + // Sleep briefly to avoid excessive redraws - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } let errors = { From 444868d41d4574420680ce4588392b0c14d1bd42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 2 Sep 2024 14:56:29 +0100 Subject: [PATCH 11/45] tasks: garden --- devenv/src/tasks.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 5174c1eba..a56915cf4 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -6,26 +6,28 @@ use crossterm::{ use miette::Diagnostic; use petgraph::algo::toposort; use petgraph::graph::{DiGraph, NodeIndex}; -use petgraph::visit::{Dfs, EdgeRef}; +use petgraph::visit::EdgeRef; #[cfg(test)] use pretty_assertions::assert_matches; use serde::{Deserialize, Serialize}; #[cfg(test)] use serde_json::json; -use std::io::{self, Write}; +use std::collections::{HashMap, HashSet}; +use std::fmt::Display; +#[cfg(test)] +use std::fs; +use std::io; +#[cfg(test)] +use std::io::Write; +#[cfg(test)] +use std::os::unix::fs::PermissionsExt; use std::process::Stdio; use std::sync::Arc; -use std::{ - collections::{HashMap, HashSet}, - fs, -}; -use std::{fmt::Display, os::unix::fs::PermissionsExt}; use test_log::test; use thiserror::Error; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; -use tokio::sync::mpsc::{channel, Receiver, Sender}; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::RwLock; use tokio::task::JoinSet; use tokio::time::{Duration, Instant}; use tracing::{error, info, instrument}; From 8c6cc5355286edbf104165ad403fece5fcabcc24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 3 Sep 2024 10:19:05 +0100 Subject: [PATCH 12/45] tasks: convert some of the enterShell into tasks --- devenv.nix | 6 -- devenv/init/devenv.nix | 6 ++ devenv/src/devenv.rs | 10 ++- docs/tasks.md | 81 ++++++++++++++++++++----- src/modules/integrations/pre-commit.nix | 11 ++-- src/modules/tasks.nix | 4 +- 6 files changed, 89 insertions(+), 29 deletions(-) diff --git a/devenv.nix b/devenv.nix index ae82a7a7d..31e49014b 100644 --- a/devenv.nix +++ b/devenv.nix @@ -210,12 +210,6 @@ EOF ''; }; - - tasks."devenv:sleep3".exec = "sleep 3"; - tasks."devenv:sleep".exec = "sleep 5"; - tasks."devenv:sleep".depends = [ "devenv:sleep3" ]; - tasks."devenv:enterShell".depends = [ "devenv:sleep" ]; - pre-commit.hooks = { nixpkgs-fmt.enable = true; #shellcheck.enable = true; diff --git a/devenv/init/devenv.nix b/devenv/init/devenv.nix index 5a693e8b7..4220a2db5 100644 --- a/devenv/init/devenv.nix +++ b/devenv/init/devenv.nix @@ -26,6 +26,12 @@ git --version ''; + # https://devenv.sh/tasks/ + # tasks = { + # "myproj:setup".exec = "mytool build"; + # "devenv:enterShell".depends = ["myproj:setup"]; + # }; + # https://devenv.sh/tests/ enterTest = '' echo "Running tests" diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index e4725ceee..5ecf9cba5 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -251,7 +251,10 @@ impl<'a> Devenv<'a> { self.assemble(false)?; let (_, gc_root) = self.get_dev_environment(false, true).await?; - let mut develop_args = vec![gc_root.to_str().expect("gc root should be utf-8")]; + let mut develop_args = vec![ + "develop", + gc_root.to_str().expect("gc root should be utf-8"), + ]; let default_clean = config::Clean { enabled: false, @@ -666,7 +669,7 @@ impl<'a> Devenv<'a> { let mut cmd = self .nix .prepare_command_with_substituters( - "develop", + "nix", &develop_args .iter() .map(AsRef::as_ref) @@ -701,7 +704,8 @@ impl<'a> Devenv<'a> { } self.logger.info("Stop: $ devenv processes stop"); } else { - cmd.exec(); + let err = cmd.exec(); + bail!(err); } Ok(()) } diff --git a/docs/tasks.md b/docs/tasks.md index 18f26bf92..715bcf59e 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -8,19 +8,32 @@ Tasks allow you to form dependencies between commands, executed in parallel. { tasks."myapp:hello" = { exec = ''echo "Hello, world!"''; - desc = "hello world in bash"; }; } ``` ```shell-session $ devenv tasks run hello -• Building shell ... -• Entering shell ... -Hello, world! +Hello, world $ ``` +## enterShell / enterTest + +If you'd like the tasks to run as part of the `enterShell` or `enterTest`: + +```nix title="devenv.nix" +{ pkgs, lib, config, ... }: + +{ + tasks = { + "bash:hello".exec = "echo 'Hello world from bash!'"; + "devenv:enterShell".depends = [ "bash:hello" ]; + "devenv:enterTest".depends = [ "bash:hello" ]; + }; +} +``` + ## Using your favourite language Tasks can also reference scripts and depend on other tasks, for example when entering the shell: @@ -31,25 +44,65 @@ Tasks can also reference scripts and depend on other tasks, for example when ent { tasks = { "python:hello"" = { - exec = ''print("Hello world from Python!")''; + exec = '' + print("Hello world from Python!") + ''; package = config.languages.python.package; }; - "bash:hello" = { - exec = "echo 'Hello world from bash!'"; - depends = [ "python:hello" ]; - }; - "devenv:enterShell".depends = [ "bash:hello" ]; }; } ``` ```shell-session $ devenv shell -• Building shell ... -• Entering shell ... ... -$ ``` -`status` +## Avoiding running expensive `exec` via `status` check + +If you define a `status` command, it will be executed first and if it returns `0`, `exec` will be skipped. + +```nix title="devenv.nix" +{ pkgs, lib, config, ... }: + +{ + tasks = { + "myapp:migrations" = { + exec = "db-migrate"; + status = "db-needs-migrations"; + }; + }; +} +``` + +## Inputs / Outputs + +Tasks support passing inputs and produce outputs, both as JSON objects: + +- `$DEVENV_TASK_INPUTS`: JSON object serializing `tasks."myapp:mytask".inputs`. +- `$DEVENV_TASK_OUTPUTS`: a writable file with tasks' outputs in JSON. +- `$DEVENV_TASKS_OUTPUTS`: JSON object with dependent tasks as keys and their outputs as values. + +```nix title="devenv.nix" +{ pkgs, lib, config, ... }: + +{ + tasks = { + "myapp:mytask" = { + exec = '' + echo $DEVENV_TASK_INPUTS > $DEVENV_ROOT/inputs.json + echo '{ "output" = 1; }' > $DEVENV_TASK_OUTPUTS + echo $DEVENV_TASKS_OUTPUTS > $DEVENV_ROOT/outputs.json + ''; + inputs = { + value = 1; + }; + }; + }; +} +``` + +## SDK + +See [xxx](xxx) for a proposal how defining tasks in your favorite language would look like. diff --git a/src/modules/integrations/pre-commit.nix b/src/modules/integrations/pre-commit.nix index 5e27432ec..90397841a 100644 --- a/src/modules/integrations/pre-commit.nix +++ b/src/modules/integrations/pre-commit.nix @@ -20,11 +20,14 @@ config = lib.mkIf ((lib.filterAttrs (id: value: value.enable) config.pre-commit.hooks) != { }) { ci = [ config.pre-commit.run ]; - enterTest = '' - pre-commit run -a - ''; # Add the packages for any enabled hooks at the end to avoid overriding the language-defined packages. packages = lib.mkAfter ([ config.pre-commit.package ] ++ (config.pre-commit.enabledPackages or [ ])); - enterShell = config.pre-commit.installationScript; + tasks = { + # TODO: split installation script into status + exec + "devenv:pre-commit:install".exec = config.pre-commit.installationScript; + "devenv:pre-commit:run".exec = "pre-commit run -a"; + "devenv:enterShell".depends = [ "devenv:pre-commit:install" ]; + "devenv:enterTest".depends = [ "devenv:pre-commit:run" ]; + }; }; } diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index 77432915c..a5a6b1524 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -97,7 +97,7 @@ in description = "Runs when entering the test environment"; }; }; - #enterShell = "devenv tasks run devenv:enterShell"; - #enterTest = "devenv tasks run devenv:enterTest"; + enterShell = "devenv tasks run devenv:enterShell"; + enterTest = "devenv tasks run devenv:enterTest"; }; } From a127b2387702613c1e298c84edd5efd26a5bd713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 9 Sep 2024 19:58:29 +0100 Subject: [PATCH 13/45] outputs --- devenv/src/cnix.rs | 41 +++++++++------------------------------ devenv/src/devenv.rs | 26 ++++++++++++++++++++++++- devenv/src/flake.tmpl.nix | 1 + docs/.pages | 7 ++++--- src/modules/top-level.nix | 1 + 5 files changed, 40 insertions(+), 36 deletions(-) diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs index db5233dc1..9f915801e 100644 --- a/devenv/src/cnix.rs +++ b/devenv/src/cnix.rs @@ -139,40 +139,17 @@ impl<'a> Nix<'a> { pub async fn build(&mut self, attributes: &[&str]) -> Result> { let options = self.options; - let build_attrs: Vec = if attributes.is_empty() { - // construct dotted names of all attributes that we need to build - let build_output = self.eval(&[".#build"]).await?; - serde_json::from_str::(&build_output) - .map_err(|e| miette::miette!("Failed to parse build output: {}", e))? - .as_object() - .ok_or_else(|| miette::miette!("Build output is not an object"))? - .iter() - .flat_map(|(key, value)| { - fn flatten_object(prefix: &str, value: &serde_json::Value) -> Vec { - match value { - serde_json::Value::Object(obj) => obj - .iter() - .flat_map(|(k, v)| flatten_object(&format!("{}.{}", prefix, k), v)) - .collect(), - _ => vec![format!(".#devenv.{}", prefix)], - } - } - flatten_object(key, value) - }) - .collect() - } else { - attributes - .iter() - .map(|attr| format!(".#devenv.{}", attr)) - .collect() - }; - - if !build_attrs.is_empty() { + if !attributes.is_empty() { // TODO: use eval underneath - let mut args = vec!["build", "--no-link", "--print-out-paths"]; - args.extend(attributes); + let mut args: Vec = vec![ + "build".to_string(), + "--no-link".to_string(), + "--print-out-paths".to_string(), + ]; + args.extend(attributes.iter().map(|attr| format!(".#{}", attr))); + let args_str: Vec<&str> = args.iter().map(AsRef::as_ref).collect(); let output = self - .run_nix_with_substituters("nix", &args, &options) + .run_nix_with_substituters("nix", &args_str, &options) .await?; Ok(String::from_utf8_lossy(&output.stdout) .to_string() diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index 5ecf9cba5..9ddadd3c3 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -603,12 +603,36 @@ impl<'a> Devenv<'a> { pub async fn build(&mut self, attributes: &[String]) -> Result<()> { self.assemble(false)?; + let attributes: Vec = if attributes.is_empty() { + // construct dotted names of all attributes that we need to build + let build_output = self.nix.eval(&["build"]).await?; + serde_json::from_str::(&build_output) + .map_err(|e| miette::miette!("Failed to parse build output: {}", e))? + .as_object() + .ok_or_else(|| miette::miette!("Build output is not an object"))? + .iter() + .flat_map(|(key, value)| { + fn flatten_object(prefix: &str, value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::Object(obj) => obj + .iter() + .flat_map(|(k, v)| flatten_object(&format!("{}.{}", prefix, k), v)) + .collect(), + _ => vec![format!("devenv.{}", prefix)], + } + } + flatten_object(key, value) + }) + .collect() + } else { + attributes.to_vec() + }; let paths = self .nix .build(&attributes.iter().map(AsRef::as_ref).collect::>()) .await?; for path in paths { - println!("{}", path.to_string_lossy()); + println!("{}", path.display()); } Ok(()) } diff --git a/devenv/src/flake.tmpl.nix b/devenv/src/flake.tmpl.nix index 6f6b051ca..3da44bc29 100644 --- a/devenv/src/flake.tmpl.nix +++ b/devenv/src/flake.tmpl.nix @@ -100,6 +100,7 @@ v ); }; + build = options: config: lib.concatMapAttrs (name: option: diff --git a/docs/.pages b/docs/.pages index 2deffd37f..131be05d3 100644 --- a/docs/.pages +++ b/docs/.pages @@ -8,13 +8,13 @@ nav: - Packages: packages.md - Scripts: scripts.md - Tasks: tasks.md - - Languages: + - Languages: - Overview: languages.md - Supported Languages: supported-languages - - Processes: + - Processes: - Overview: processes.md - Supported Process Managers: supported-process-managers - - Services: + - Services: - Overview: services.md - Supported Services: supported-services - Containers: containers.md @@ -22,6 +22,7 @@ nav: - Pre-Commit Hooks: pre-commit-hooks.md - Outputs: outputs.md - Tests: tests.md + - Outputs: outputs.md - Common Patterns: common-patterns.md - Writing devenv.yaml: - Inputs: inputs.md diff --git a/src/modules/top-level.nix b/src/modules/top-level.nix index d0a3f8bb3..9f9d254e3 100644 --- a/src/modules/top-level.nix +++ b/src/modules/top-level.nix @@ -221,6 +221,7 @@ in ./info.nix ./outputs.nix ./processes.nix + ./outputs.nix ./scripts.nix ./update-check.nix ./containers.nix From bddd6fef8781db3af2b11bd6672ef3343e253da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Thu, 12 Sep 2024 12:15:16 +0100 Subject: [PATCH 14/45] tasks: add Skipped reason --- devenv/src/tasks.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index a56915cf4..89d6b456a 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -107,10 +107,16 @@ struct TaskFailure { error: String, } +#[derive(Debug, Clone)] +enum Skipped { + Cached, + NotImplemented, +} + #[derive(Debug, Clone)] enum TaskCompleted { Success(Duration), - Skipped, + Skipped(Skipped), Failed(Duration, TaskFailure), DependencyFailed, } @@ -156,7 +162,7 @@ impl TaskState { match result { Ok(status) => { if status.success() { - return TaskCompleted::Skipped; + return TaskCompleted::Skipped(Skipped::Cached); } } Err(e) => { @@ -283,7 +289,7 @@ impl TaskState { } } } else { - return TaskCompleted::Skipped; + return TaskCompleted::Skipped(Skipped::NotImplemented); } } } @@ -550,9 +556,13 @@ impl TasksUi { Some(started.elapsed()), ) } - TaskStatus::Completed(TaskCompleted::Skipped) => { + TaskStatus::Completed(TaskCompleted::Skipped(skipped)) => { tasks_status.skipped += 1; - (format!("{:17}", "Skipped").blue().bold(), None) + let status = match skipped { + Skipped::Cached => "Cached", + Skipped::NotImplemented => "Not implemented", + }; + (format!("{:17}", status).blue().bold(), None) } TaskStatus::Completed(TaskCompleted::Success(duration)) => { tasks_status.succeeded += 1; @@ -885,7 +895,7 @@ async fn test_status() -> Result<(), Error> { assert_eq!(tasks.tasks_order.len(), 1); assert_matches!( tasks.graph[tasks.tasks_order[0]].read().await.status, - TaskStatus::Completed(TaskCompleted::Skipped) + TaskStatus::Completed(TaskCompleted::Skipped(Skipped::Cached)) ); let tasks = create_tasks("myapp:task_2").await.unwrap(); From 169642c7bf6339f019731ed3ebefc64fa7243dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Thu, 12 Sep 2024 15:35:39 +0100 Subject: [PATCH 15/45] tasks: implement task input/output --- devenv/src/devenv.rs | 16 ++-- devenv/src/tasks.rs | 184 ++++++++++++++++++++++++++++++++++++------- docs/tasks.md | 8 +- 3 files changed, 167 insertions(+), 41 deletions(-) diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index 9ddadd3c3..aa64e0f7e 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -528,18 +528,20 @@ impl<'a> Devenv<'a> { serde_json::from_str(&tasks_json).expect("Failed to parse tasks config"); // run tasks let config = tasks::Config { roots, tasks }; - if self.global_options.verbose { - println!( - "Tasks config: {}", - serde_json::to_string_pretty(&config).unwrap() - ); - } + self.logger.debug(&format!( + "Tasks config: {}", + serde_json::to_string_pretty(&config).unwrap() + )); let mut tui = tasks::TasksUi::new(config).await?; - let tasks_status = tui.run().await?; + let (tasks_status, outputs) = tui.run().await?; if tasks_status.failed > 0 || tasks_status.dependency_failed > 0 { Err(miette::bail!("Some tasks failed")) } else { + println!( + "{}", + serde_json::to_string(&outputs).expect("poarsing of outputs failed") + ); Ok(()) } } diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 89d6b456a..f3835a02f 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -25,11 +25,14 @@ use std::process::Stdio; use std::sync::Arc; use test_log::test; use thiserror::Error; -use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use tokio::sync::RwLock; use tokio::task::JoinSet; use tokio::time::{Duration, Instant}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + sync::Mutex, +}; use tracing::{error, info, instrument}; #[derive(Error, Diagnostic, Debug)] @@ -82,6 +85,8 @@ pub struct TaskConfig { command: Option, #[serde(default)] status: Option, + #[serde(default)] + inputs: Option, } #[derive(Deserialize, Serialize)] @@ -90,6 +95,11 @@ pub struct Config { pub roots: Vec, } +#[derive(Serialize)] +pub struct Outputs(HashMap); +#[derive(Debug, Clone)] +pub struct Output(Option); + impl TryFrom for Config { type Error = serde_json::Error; @@ -98,24 +108,32 @@ impl TryFrom for Config { } } -type Output = Vec<(std::time::Instant, String)>; +type LinesOutput = Vec<(std::time::Instant, String)>; + +impl std::ops::Deref for Outputs { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} #[derive(Debug, Clone)] struct TaskFailure { - stdout: Output, - stderr: Output, + stdout: LinesOutput, + stderr: LinesOutput, error: String, } #[derive(Debug, Clone)] enum Skipped { - Cached, + Cached(Output), NotImplemented, } #[derive(Debug, Clone)] enum TaskCompleted { - Success(Duration), + Success(Duration, Output), Skipped(Skipped), Failed(Duration, TaskFailure), DependencyFailed, @@ -151,18 +169,42 @@ impl TaskState { } } + fn prepare_command(&self, cmd: &str) -> (Command, tempfile::NamedTempFile) { + let mut command = Command::new(&cmd); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + + // Set DEVENV_TASK_INPUTS environment variable + if let Some(inputs) = &self.task.inputs { + command.env("DEVENV_TASK_INPUT", serde_json::to_string(inputs).unwrap()); + } + + // Create a temporary file for DEVENV_TASK_OUTPUTS + let outputs_file = tempfile::NamedTempFile::new().unwrap(); + command.env("DEVENV_TASK_OUTPUT", outputs_file.path()); + + (command, outputs_file) + } + + fn get_outputs(outputs_file: &tempfile::NamedTempFile) -> Output { + let output = match std::fs::File::open(outputs_file.path()) { + // TODO: report JSON parsing errors + Ok(file) => serde_json::from_reader(file).ok(), + Err(_) => None, + }; + Output(output) + } + #[instrument(ret)] async fn run(&self, now: Instant) -> TaskCompleted { if let Some(cmd) = &self.task.status { - let result = Command::new(cmd) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .status() - .await; + let (mut command, outputs_file) = self.prepare_command(cmd); + let result = command.status().await; match result { Ok(status) => { if status.success() { - return TaskCompleted::Skipped(Skipped::Cached); + return TaskCompleted::Skipped(Skipped::Cached(Self::get_outputs( + &outputs_file, + ))); } } Err(e) => { @@ -179,10 +221,9 @@ impl TaskState { } } if let Some(cmd) = &self.task.command { - let result = Command::new(cmd) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn(); + let (mut command, outputs_file) = self.prepare_command(cmd); + + let result = command.spawn(); let mut child = match result { Ok(c) => c, @@ -261,7 +302,7 @@ impl TaskState { match result { Ok(status) => { if status.success() { - return TaskCompleted::Success(now.elapsed()); + return TaskCompleted::Success(now.elapsed(), Self::get_outputs(&outputs_file)); } else { return TaskCompleted::Failed( now.elapsed(), @@ -429,8 +470,9 @@ impl Tasks { } #[instrument(skip(self))] - async fn run(&self) { + async fn run(&self) -> Outputs { let mut running_tasks = JoinSet::new(); + let outputs = Arc::new(Mutex::new(HashMap::new())); for index in &self.tasks_order { let task_state = &self.graph[*index]; @@ -481,10 +523,26 @@ impl Tasks { } let task_state_clone = Arc::clone(task_state); + let outputs_clone = Arc::clone(&outputs); running_tasks.spawn(async move { let completed = task_state_clone.read().await.run(now).await; { let mut task_state = task_state_clone.write().await; + match &completed { + TaskCompleted::Success(_, Output(Some(output))) => { + outputs_clone + .lock() + .await + .insert(task_state.task.name.clone(), output.clone()); + } + TaskCompleted::Skipped(Skipped::Cached(Output(Some(output)))) => { + outputs_clone + .lock() + .await + .insert(task_state.task.name.clone(), output.clone()); + } + _ => {} + } task_state.status = TaskStatus::Completed(completed); } }); @@ -497,6 +555,8 @@ impl Tasks { Err(e) => eprintln!("Task crashed: {}", e), } } + + Outputs(Arc::try_unwrap(outputs).unwrap().into_inner()) } } @@ -559,12 +619,12 @@ impl TasksUi { TaskStatus::Completed(TaskCompleted::Skipped(skipped)) => { tasks_status.skipped += 1; let status = match skipped { - Skipped::Cached => "Cached", + Skipped::Cached(_) => "Cached", Skipped::NotImplemented => "Not implemented", }; (format!("{:17}", status).blue().bold(), None) } - TaskStatus::Completed(TaskCompleted::Success(duration)) => { + TaskStatus::Completed(TaskCompleted::Success(duration, _)) => { tasks_status.succeeded += 1; (format!("{:17}", "Succeeded").green().bold(), Some(duration)) } @@ -593,8 +653,8 @@ impl TasksUi { tasks_status } - pub async fn run(&mut self) -> Result { - let mut stdout = io::stdout(); + pub async fn run(&mut self) -> Result<(TasksStatus, Outputs), Error> { + let mut stderr = io::stderr(); let names = self.tasks.root_names.join(", ").bold(); let started = std::time::Instant::now(); @@ -616,7 +676,7 @@ impl TasksUi { let tasks_status = self.get_tasks_status().await; execute!( - stdout, + stderr, // Clear the screen from the cursor down cursor::MoveUp(last_list_height), Clear(ClearType::FromCursorDown), @@ -716,10 +776,10 @@ impl TasksUi { } errors.stylize() }; - execute!(stdout, style::PrintStyledContent(errors))?; + execute!(stderr, style::PrintStyledContent(errors))?; let tasks_status = self.get_tasks_status().await; - Ok(tasks_status) + Ok((tasks_status, handle.await.unwrap())) } } @@ -817,9 +877,9 @@ async fn test_basic_tasks() -> Result<(), Error> { assert_matches!( task_statuses, [ - (name1, TaskStatus::Completed(TaskCompleted::Success(_))), - (name2, TaskStatus::Completed(TaskCompleted::Success(_))), - (name3, TaskStatus::Completed(TaskCompleted::Success(_))) + (name1, TaskStatus::Completed(TaskCompleted::Success(_, _))), + (name2, TaskStatus::Completed(TaskCompleted::Success(_, _))), + (name3, TaskStatus::Completed(TaskCompleted::Success(_, _))) ] if name1 == "myapp:task_1" && name2 == "myapp:task_3" && name3 == "myapp:task_4" ); Ok(()) @@ -895,7 +955,7 @@ async fn test_status() -> Result<(), Error> { assert_eq!(tasks.tasks_order.len(), 1); assert_matches!( tasks.graph[tasks.tasks_order[0]].read().await.status, - TaskStatus::Completed(TaskCompleted::Skipped(Skipped::Cached)) + TaskStatus::Completed(TaskCompleted::Skipped(Skipped::Cached(_))) ); let tasks = create_tasks("myapp:task_2").await.unwrap(); @@ -903,7 +963,7 @@ async fn test_status() -> Result<(), Error> { assert_eq!(tasks.tasks_order.len(), 1); assert_matches!( tasks.graph[tasks.tasks_order[0]].read().await.status, - TaskStatus::Completed(TaskCompleted::Success(_)) + TaskStatus::Completed(TaskCompleted::Success(_, _)) ); Ok(()) @@ -1011,6 +1071,70 @@ async fn test_dependency_failure() -> Result<(), Error> { Ok(()) } +#[test(tokio::test)] +async fn test_inputs_outputs() -> Result<(), Error> { + let input_script = create_script( + r#"#!/bin/sh +echo "{\"key\": \"value\"}" > $DEVENV_TASK_OUTPUT +if [ "$DEVENV_TASK_INPUT" != '{"test":"input"}' ]; then + echo "Error: Input does not match expected value" >&2 + echo "Expected: $expected" >&2 + echo "Actual: $input" >&2 + exit 1 +fi +"#, + )?; + + let output_script = create_script( + r#"#!/bin/sh +echo "Output from previous task: $DEVENV_TASK_INPUT" +echo "{\"result\": \"success\"}" > $DEVENV_TASK_OUTPUT +"#, + )?; + + let tasks = Tasks::new( + Config::try_from(json!({ + "roots": ["myapp:task_1", "myapp:task_2"], + "tasks": [ + { + "name": "myapp:task_1", + "command": input_script.to_str().unwrap(), + "inputs": {"test": "input"} + }, + { + "name": "myapp:task_2", + "command": output_script.to_str().unwrap(), + "depends": ["myapp:task_1"] + } + ] + })) + .unwrap(), + ) + .await?; + + let outputs = tasks.run().await; + let task_statuses = inspect_tasks(&tasks).await; + let task_statuses = task_statuses.as_slice(); + assert_matches!( + task_statuses, + [ + (name1, TaskStatus::Completed(TaskCompleted::Success(_, _))), + (name2, TaskStatus::Completed(TaskCompleted::Success(_, _))) + ] if name1 == "myapp:task_1" && name2 == "myapp:task_2" + ); + + assert_eq!( + outputs.get("myapp:task_1").unwrap(), + &json!({"key": "value"}) + ); + assert_eq!( + outputs.get("myapp:task_2").unwrap(), + &json!({"result": "success"}) + ); + + Ok(()) +} + #[cfg(test)] async fn inspect_tasks(tasks: &Tasks) -> Vec<(String, TaskStatus)> { let mut result = Vec::new(); diff --git a/docs/tasks.md b/docs/tasks.md index 715bcf59e..80fcb93d8 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -80,8 +80,8 @@ If you define a `status` command, it will be executed first and if it returns `0 Tasks support passing inputs and produce outputs, both as JSON objects: -- `$DEVENV_TASK_INPUTS`: JSON object serializing `tasks."myapp:mytask".inputs`. -- `$DEVENV_TASK_OUTPUTS`: a writable file with tasks' outputs in JSON. +- `$DEVENV_TASK_INPUT`: JSON object serializing `tasks."myapp:mytask".inputs`. +- `$DEVENV_TASK_OUTPUT`: a writable file with tasks' outputs in JSON. - `$DEVENV_TASKS_OUTPUTS`: JSON object with dependent tasks as keys and their outputs as values. ```nix title="devenv.nix" @@ -91,8 +91,8 @@ Tasks support passing inputs and produce outputs, both as JSON objects: tasks = { "myapp:mytask" = { exec = '' - echo $DEVENV_TASK_INPUTS > $DEVENV_ROOT/inputs.json - echo '{ "output" = 1; }' > $DEVENV_TASK_OUTPUTS + echo $DEVENV_TASK_INPUTS> $DEVENV_ROOT/input.json + echo '{ "output" = 1; }' > $DEVENV_TASK_OUTPUT echo $DEVENV_TASKS_OUTPUTS > $DEVENV_ROOT/outputs.json ''; inputs = { From 147794aec95d153f2363ab54ca7aa376a17f1189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 13 Sep 2024 14:31:34 +0100 Subject: [PATCH 16/45] tasks: implement --- devenv/src/tasks.rs | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index f3835a02f..9a2c727f8 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -169,11 +169,15 @@ impl TaskState { } } - fn prepare_command(&self, cmd: &str) -> (Command, tempfile::NamedTempFile) { + fn prepare_command( + &self, + cmd: &str, + outputs: &HashMap, + ) -> (Command, tempfile::NamedTempFile) { let mut command = Command::new(&cmd); command.stdout(Stdio::piped()).stderr(Stdio::piped()); - // Set DEVENV_TASK_INPUTS environment variable + // Set DEVENV_TASK_INPUTS if let Some(inputs) = &self.task.inputs { command.env("DEVENV_TASK_INPUT", serde_json::to_string(inputs).unwrap()); } @@ -182,6 +186,10 @@ impl TaskState { let outputs_file = tempfile::NamedTempFile::new().unwrap(); command.env("DEVENV_TASK_OUTPUT", outputs_file.path()); + // Set DEVENV_TASKS_OUTPUTS + let outputs_json = serde_json::to_string(outputs).unwrap(); + command.env("DEVENV_TASKS_OUTPUTS", outputs_json); + (command, outputs_file) } @@ -195,9 +203,14 @@ impl TaskState { } #[instrument(ret)] - async fn run(&self, now: Instant) -> TaskCompleted { + async fn run( + &self, + now: Instant, + outputs: &HashMap, + ) -> TaskCompleted { if let Some(cmd) = &self.task.status { - let (mut command, outputs_file) = self.prepare_command(cmd); + let (mut command, outputs_file) = self.prepare_command(cmd, outputs); + let result = command.status().await; match result { Ok(status) => { @@ -221,7 +234,7 @@ impl TaskState { } } if let Some(cmd) = &self.task.command { - let (mut command, outputs_file) = self.prepare_command(cmd); + let (mut command, outputs_file) = self.prepare_command(cmd, outputs); let result = command.spawn(); @@ -525,7 +538,10 @@ impl Tasks { let task_state_clone = Arc::clone(task_state); let outputs_clone = Arc::clone(&outputs); running_tasks.spawn(async move { - let completed = task_state_clone.read().await.run(now).await; + let completed = { + let outputs = outputs_clone.lock().await.clone(); + task_state_clone.read().await.run(now, &outputs).await + }; { let mut task_state = task_state_clone.write().await; match &completed { @@ -1087,8 +1103,13 @@ fi let output_script = create_script( r#"#!/bin/sh -echo "Output from previous task: $DEVENV_TASK_INPUT" -echo "{\"result\": \"success\"}" > $DEVENV_TASK_OUTPUT + if [ "$DEVENV_TASKS_OUTPUTS" != '{"myapp:task_1":{"key":"value"}}' ]; then + echo "Error: Outputs do not match expected value" >&2 + echo "Expected: {\"myapp:task_1\":{\"key\":\"value\"}}" >&2 + echo "Actual: $DEVENV_TASKS_OUTPUTS" >&2 + exit 1 + fi + echo "{\"result\": \"success\"}" > $DEVENV_TASK_OUTPUT "#, )?; From 12d88c1baad66a88b689a0cc4f7038bbb8e503c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 13 Sep 2024 16:45:59 +0100 Subject: [PATCH 17/45] tasks: implement sourcing of variables for enterShell --- docs/tasks.md | 2 +- src/modules/tasks.nix | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/tasks.md b/docs/tasks.md index 80fcb93d8..8af6222a9 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -95,7 +95,7 @@ Tasks support passing inputs and produce outputs, both as JSON objects: echo '{ "output" = 1; }' > $DEVENV_TASK_OUTPUT echo $DEVENV_TASKS_OUTPUTS > $DEVENV_ROOT/outputs.json ''; - inputs = { + input = { value = 1; }; }; diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index a5a6b1524..36e324819 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -56,6 +56,7 @@ let status = config.statusCommand; depends = config.depends; command = config.command; + input = config.input; }; }; description = lib.mkOption { @@ -68,6 +69,11 @@ let description = "List of tasks to run before this task."; default = [ ]; }; + input = lib.mkOption { + type = types.attrsOf types.anything; + default = { }; + description = "Input values for the task, encoded as JSON."; + }; }; }); in @@ -89,15 +95,29 @@ in task.config = (pkgs.formats.json { }).generate "tasks.json" (lib.mapAttrsToList (name: value: { inherit name; } // value.config) config.tasks); + tasks = { "devenv:enterShell" = { description = "Runs when entering the shell"; + exec = '' + ENTER_SHELL_COMMANDS=$(echo "''${DEVENV_TASKS_OUTPUTS:-{}}" | jq -r 'to_entries[] | select(.value.devenv.enterShell != null) | .value.devenv.enterShell' | tr '\n' ';') + eval "$ENTER_SHELL_COMMANDS" + + mkdir -p "$DEVENV_STATE" + export -p | sed 's/^declare -x /export /' > "$DEVENV_STATE/load-env" + chmod +x "$DEVENV_STATE/load-env" + ''; }; "devenv:enterTest" = { description = "Runs when entering the test environment"; }; }; - enterShell = "devenv tasks run devenv:enterShell"; - enterTest = "devenv tasks run devenv:enterTest"; + enterShell = '' + devenv tasks run devenv:enterShell >/dev/null + source "$DEVENV_STATE/load-env" + ''; + enterTest = '' + devenv tasks run devenv:enterTest >/dev/null + ''; }; } From 4edb3cd11096fcf21fde42b4c8fb0424425e9e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 17 Sep 2024 12:26:29 +0100 Subject: [PATCH 18/45] tasks: run as a separate binary --- Cargo.lock | 10 ++++++++++ Cargo.toml | 15 ++++++++------- package.nix | 7 ++++--- src/modules/tasks.nix | 13 ++++++++----- tasks/Cargo.toml | 11 +++++++++++ tasks/src/main.rs | 28 ++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 tasks/Cargo.toml create mode 100644 tasks/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index f2a83cd4d..6d23a51af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1812,6 +1812,16 @@ dependencies = [ "libc", ] +[[package]] +name = "tasks" +version = "0.1.0" +dependencies = [ + "clap", + "devenv", + "serde_json", + "tokio", +] + [[package]] name = "tempdir" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index e793c48ac..053ba5d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,6 @@ [workspace] resolver = "2" -members = [ - "devenv", - "devenv-run-tests", - "xtask", -] +members = ["devenv", "devenv-run-tests", "xtask", "tasks"] [workspace.package] edition = "2021" @@ -25,7 +21,12 @@ miette = { version = "7.1.0", features = ["fancy"] } nix = { version = "0.28.0", features = ["signal"] } regex = "1.10.3" reqwest = "0.11.26" -schematic = { version = "0.14.3", features = ["schema", "yaml", "renderer_template", "renderer_json_schema"] } +schematic = { version = "0.14.3", features = [ + "schema", + "yaml", + "renderer_template", + "renderer_json_schema", +] } serde = "1.0.197" serde_json = "1.0.114" serde_yaml = "0.9.32" @@ -35,5 +36,5 @@ tracing = "0.1.40" which = "6.0.0" whoami = "1.5.1" xdg = "2.5.2" - +tokio = "1.39.3" schemars = "0.8.16" diff --git a/package.nix b/package.nix index ec4116a7e..e3a17069c 100644 --- a/package.nix +++ b/package.nix @@ -1,4 +1,4 @@ -{ pkgs, inputs }: +{ pkgs, inputs, build_tasks ? false }: pkgs.rustPlatform.buildRustPackage { pname = "devenv"; @@ -9,11 +9,12 @@ pkgs.rustPlatform.buildRustPackage { "Cargo\.toml" "Cargo\.lock" "devenv(/.*)?" + "tasks(/.*)?" "devenv-run-tests(/.*)?" "xtask(/.*)?" ]; - cargoBuildFlags = [ "-p devenv -p devenv-run-tests" ]; + cargoBuildFlags = if build_tasks then [ "-p tasks" ] else [ "-p devenv -p devenv-run-tests" ]; cargoLock = { lockFile = ./Cargo.lock; @@ -29,7 +30,7 @@ pkgs.rustPlatform.buildRustPackage { pkgs.darwin.apple_sdk.frameworks.SystemConfiguration ]; - postInstall = '' + postInstall = pkgs.lib.optionalString (!build_tasks) '' wrapProgram $out/bin/devenv \ --set DEVENV_NIX ${inputs.nix.packages.${pkgs.stdenv.system}.nix} \ --prefix PATH ":" "$out/bin:${inputs.cachix.packages.${pkgs.stdenv.system}.cachix}/bin" diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index 36e324819..804b5666d 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -1,6 +1,7 @@ -{ pkgs, lib, config, ... }: +{ pkgs, lib, config, ... }@inputs: let types = lib.types; + devenv = import ./../../package.nix { inherit pkgs inputs; build_tasks = true; }; taskType = types.submodule ({ name, config, ... }: let @@ -76,6 +77,7 @@ let }; }; }); + tasksJSON = (lib.mapAttrsToList (name: value: { inherit name; } // value.config) config.tasks); in { options.tasks = lib.mkOption { @@ -88,13 +90,14 @@ in }; config = { + env.DEVENV_TASKS = builtins.toJSON tasksJSON; + info.infoSections.tasks = lib.mapAttrsToList (name: task: "${name}: ${task.description} ${task.command}") config.tasks; - task.config = (pkgs.formats.json { }).generate "tasks.json" - (lib.mapAttrsToList (name: value: { inherit name; } // value.config) config.tasks); + task.config = (pkgs.formats.json { }).generate "tasks.json" tasksJSON; tasks = { "devenv:enterShell" = { @@ -113,11 +116,11 @@ in }; }; enterShell = '' - devenv tasks run devenv:enterShell >/dev/null + ${devenv}/bin/tasks devenv:enterShell source "$DEVENV_STATE/load-env" ''; enterTest = '' - devenv tasks run devenv:enterTest >/dev/null + ${devenv}/bin/tasks devenv:enterTest ''; }; } diff --git a/tasks/Cargo.toml b/tasks/Cargo.toml new file mode 100644 index 000000000..69472260a --- /dev/null +++ b/tasks/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tasks" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +clap.workspace = true +devenv = { path = "../devenv" } +serde_json.workspace = true +tokio.workspace = true diff --git a/tasks/src/main.rs b/tasks/src/main.rs new file mode 100644 index 000000000..081ca3fd9 --- /dev/null +++ b/tasks/src/main.rs @@ -0,0 +1,28 @@ +use clap::Parser; +use devenv::tasks::{Config, TaskConfig, TasksUi}; +use std::env; + +#[derive(Parser)] +#[clap(author, version, about)] +struct Args { + #[clap(help = "Root directories to search for tasks")] + roots: Vec, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + + let tasks_json = env::var("DEVENV_TASKS")?; + let tasks: Vec = serde_json::from_str(&tasks_json)?; + + let config = Config { + tasks, + roots: args.roots, + }; + + let mut tasks_ui = TasksUi::new(config).await?; + tasks_ui.run().await?; + + Ok(()) +} From 9dbde1b360b59b66a7a650ecbec4c70acc3dc2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 17 Sep 2024 12:45:42 +0100 Subject: [PATCH 19/45] bump mkdocs-rss-plugin --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2ade804fa..58f44d96b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ mkdocs mkdocs-material[imaging] # https://github.com/Guts/mkdocs-rss-plugin/issues/257#issuecomment-2170940396 -mkdocs-rss-plugin==1.13.1 +mkdocs-rss-plugin==1.15.0 mkdocs-include-markdown-plugin mkdocs-markdownextradata-plugin mkdocs-awesome-pages-plugin From 7dce134ae6b7ec1c2aa1a37d5b9d41d665e35e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 17 Sep 2024 16:45:09 +0100 Subject: [PATCH 20/45] cnix: fix develop call --- devenv/src/cnix.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs index 9f915801e..138ac4f2f 100644 --- a/devenv/src/cnix.rs +++ b/devenv/src/cnix.rs @@ -76,10 +76,7 @@ impl<'a> Nix<'a> { replace_shell, ..self.options }; - let mut full_args = vec!["develop"]; - full_args.extend_from_slice(args); - self.run_nix_with_substituters("nix", &full_args, &options) - .await + self.run_nix_with_substituters("nix", &args, &options).await } pub async fn dev_env(&mut self, json: bool, gc_root: &PathBuf) -> Result> { From e0e249101fb083c00e229c2139287d0c4c52fe1f Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:53:25 +0000 Subject: [PATCH 21/45] Auto generate docs/reference/options.md --- docs/reference/options.md | 163 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/docs/reference/options.md b/docs/reference/options.md index ec05f8bec..8505f5166 100644 --- a/docs/reference/options.md +++ b/docs/reference/options.md @@ -41484,6 +41484,169 @@ package +## tasks + + + +This option has no description. + + + +*Type:* +attribute set of (submodule) + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.package + + + +Package to install for this task. + + + +*Type:* +package + + + +*Default:* +` ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.binary + + + +Override the binary name if it doesn’t match package name + + + +*Type:* +string + + + +*Default:* +` "bash" ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.depends + + + +List of tasks to run before this task. + + + +*Type:* +list of string + + + +*Default:* +` [ ] ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.description + + + +Description of the task. + + + +*Type:* +string + + + +*Default:* +` "" ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.exec + + + +Command to execute the task. + + + +*Type:* +null or string + + + +*Default:* +` null ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.input + + + +Input values for the task, encoded as JSON. + + + +*Type:* +attribute set of anything + + + +*Default:* +` { } ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + +## tasks.\.status + + + +Check if the command should be ran + + + +*Type:* +null or string + + + +*Default:* +` null ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + ## unsetEnvVars From 85cb802d253eca0ae8223cc4e6f1d3488f55eecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 17 Sep 2024 18:58:39 +0100 Subject: [PATCH 22/45] store load-env into DEVENV_DOTFILE --- src/modules/tasks.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index 804b5666d..4e57b249f 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -107,8 +107,8 @@ in eval "$ENTER_SHELL_COMMANDS" mkdir -p "$DEVENV_STATE" - export -p | sed 's/^declare -x /export /' > "$DEVENV_STATE/load-env" - chmod +x "$DEVENV_STATE/load-env" + export -p | sed 's/^declare -x /export /' > "$DEVENV_DOTFILE/load-env" + chmod +x "$DEVENV_DOTFILE/load-env" ''; }; "devenv:enterTest" = { @@ -117,7 +117,7 @@ in }; enterShell = '' ${devenv}/bin/tasks devenv:enterShell - source "$DEVENV_STATE/load-env" + source "$DEVENV_DOTFILE/load-env" ''; enterTest = '' ${devenv}/bin/tasks devenv:enterTest From 22653ab292032812a1f7414a2c5897fe3b3edf61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 17 Sep 2024 17:58:00 +0100 Subject: [PATCH 23/45] fix tests --- devenv/src/devenv.rs | 15 ++++++--------- tests/tasks/devenv.nix | 11 +++++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index aa64e0f7e..5f1a26553 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -552,15 +552,9 @@ impl<'a> Devenv<'a> { // collect tests let test_script = { let _logprogress = self.log_progress.with_newline("Building tests"); - let test_scripts = self.nix.build(&["devenv.test"]).await?; - std::fs::read_to_string(&test_scripts[0]) - .map_err(|e| miette::miette!("Failed to read test script: {}", e))? + self.nix.build(&["devenv.test"]).await? }; - - if test_script.is_empty() { - self.logger.error("No tests found."); - bail!("No tests found"); - } + let test_script = test_script[0].to_string_lossy().to_string(); if self.has_processes().await? { self.up(None, &true, &false).await?; @@ -627,7 +621,10 @@ impl<'a> Devenv<'a> { }) .collect() } else { - attributes.to_vec() + attributes + .iter() + .map(|attr| format!("devenv.{}", attr)) + .collect() }; let paths = self .nix diff --git a/tests/tasks/devenv.nix b/tests/tasks/devenv.nix index 9bbe1a812..e4df981af 100644 --- a/tests/tasks/devenv.nix +++ b/tests/tasks/devenv.nix @@ -1,8 +1,9 @@ { tasks = { - shell.exec = "touch shell"; - enterShell.depends = [ "shell" ]; - test.exec = "touch test"; + "myapp:shell".exec = "touch shell"; + "devenv:enterShell".depends = [ "myapp:shell" ]; + "myapp:test".exec = "touch test"; + "devenv:enterTest".depends = [ "myapp:test" ]; }; enterTest = '' @@ -10,7 +11,9 @@ echo "shell does not exist" exit 1 fi - devenv tasks run test + rm -f shell + rm -f test + devenv tasks run myapp:test >/dev/null if [ ! -f test ]; then echo "test does not exist" exit 1 From b96078fd54ce906c2f1c11ba037f8f2848c49d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 18 Sep 2024 15:44:51 +0100 Subject: [PATCH 24/45] substituters: pass all options with prepanding instead of recreating cmd --- devenv/src/cnix.rs | 64 ++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs index 138ac4f2f..9b46b86e1 100644 --- a/devenv/src/cnix.rs +++ b/devenv/src/cnix.rs @@ -321,14 +321,18 @@ impl<'a> Nix<'a> { } // We have a separate function to avoid recursion as this needs to call self.prepare_command - // TODO: doesn't log the substituters pub async fn prepare_command_with_substituters( &mut self, command: &str, args: &[&str], options: &Options<'a>, ) -> Result { - let mut cmd = self.prepare_command(command, args, options)?; + let mut final_args = Vec::new(); + let mut final_command = command.to_string(); + let mut push_args = Vec::new(); + let known_keys; + let pull_caches; + if !self.global_options.offline { let cachix_caches = self.get_cachix_caches().await; @@ -340,47 +344,37 @@ impl<'a> Nix<'a> { } Ok(cachix_caches) => { // handle cachix.pull - let pull_caches = cachix_caches + pull_caches = cachix_caches .caches .pull .iter() .map(|cache| format!("https://{}.cachix.org", cache)) .collect::>() .join(" "); - cmd.arg("--option"); - cmd.arg("extra-substituters"); - cmd.arg(pull_caches); - cmd.arg("--option"); - cmd.arg("extra-trusted-public-keys"); - cmd.arg( - cachix_caches - .known_keys - .values() - .cloned() - .collect::>() - .join(" "), - ); + final_args.extend_from_slice(&["--option", "extra-substituters", &pull_caches]); + known_keys = cachix_caches + .known_keys + .values() + .cloned() + .collect::>() + .join(" "); + final_args.extend_from_slice(&[ + "--option", + "extra-trusted-public-keys", + &known_keys, + ]); // handle cachix.push if let Some(push_cache) = &cachix_caches.caches.push { if env::var("CACHIX_AUTH_TOKEN").is_ok() { - let args = cmd - .get_args() - .map(|arg| arg.to_str().unwrap()) - .collect::>(); - let envs = cmd.get_envs().collect::>(); - let command_name = cmd.get_program().to_string_lossy(); - let mut newcmd = std::process::Command::new("cachix"); - newcmd - .args(["watch-exec", &push_cache, "--"]) - .arg(command_name.as_ref()) - .args(args); - for (key, value) in envs { - if let Some(value) = value { - newcmd.env(key, value); - } - } - cmd = newcmd; + final_command = "cachix".to_string(); + push_args = vec![ + "watch-exec".to_string(), + push_cache.clone(), + "--".to_string(), + command.to_string(), + ]; + final_args = push_args.iter().map(|s| s.as_str()).collect(); } else { self.logger.warn(&format!( "CACHIX_AUTH_TOKEN is not set, but required to push to {}.", @@ -391,7 +385,9 @@ impl<'a> Nix<'a> { } } } - Ok(cmd) + + final_args.extend(args.iter().map(|&s| s)); + self.prepare_command(&final_command, &final_args, options) } pub fn prepare_command( From 7ab79df30331ab1694d4f68066e64ee797f0f2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 18 Sep 2024 19:13:39 +0100 Subject: [PATCH 25/45] devenv-run-test: insert $PATH pointing to the current executable --- devenv-run-tests/src/main.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/devenv-run-tests/src/main.rs b/devenv-run-tests/src/main.rs index 2d9433c1b..2e81ba677 100644 --- a/devenv-run-tests/src/main.rs +++ b/devenv-run-tests/src/main.rs @@ -143,6 +143,17 @@ async fn run_tests_in_directory( async fn main() -> Result<(), Box> { let args = Args::parse(); + let executable_path = std::env::current_exe()?; + let executable_dir = executable_path.parent().unwrap(); + std::env::set_var( + "PATH", + format!( + "{}:{}", + executable_dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ); + let test_results = run_tests_in_directory(&args).await?; let num_tests = test_results.len(); let num_failed_tests = test_results.iter().filter(|r| !r.passed).count(); From fbeb18cdfc7328034911b8ea4cd4c5990544d213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 18 Sep 2024 19:26:03 +0100 Subject: [PATCH 26/45] fix devenv info --- src/modules/languages/nix.nix | 5 ++--- src/modules/scripts.nix | 5 +---- src/modules/tasks.nix | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/modules/languages/nix.nix b/src/modules/languages/nix.nix index e547914a1..204904197 100644 --- a/src/modules/languages/nix.nix +++ b/src/modules/languages/nix.nix @@ -2,7 +2,7 @@ let cfg = config.languages.nix; - cachix = "${lib.getBin config.cachix.package}"; + cachix = lib.getBin config.cachix.package; # a bit of indirection to prevent mkShell from overriding the installed Nix vulnix = pkgs.buildEnv { @@ -27,8 +27,7 @@ in statix deadnix cfg.lsp.package - ] ++ (lib.optional config.cachix.enable cachix) ++ [ vulnix - ]; + ] ++ (lib.optional config.cachix.enable cachix); }; } diff --git a/src/modules/scripts.nix b/src/modules/scripts.nix index c74ece408..de69288ec 100644 --- a/src/modules/scripts.nix +++ b/src/modules/scripts.nix @@ -46,10 +46,7 @@ let ); renderInfoSection = name: script: - '' - ${name}${lib.optionalString (script.description != "") ": ${script.description}"} - ${script.scriptPackage} - ''; + "${name}${lib.optionalString (script.description != "") ": ${script.description}"} ${script.scriptPackage}"; in { options = { diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index 4e57b249f..d8dbb0a51 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -92,9 +92,9 @@ in config = { env.DEVENV_TASKS = builtins.toJSON tasksJSON; - info.infoSections.tasks = + infoSections."tasks" = lib.mapAttrsToList - (name: task: "${name}: ${task.description} ${task.command}") + (name: task: "${name}: ${task.description} (${if task.command == null then "no command" else task.command})") config.tasks; task.config = (pkgs.formats.json { }).generate "tasks.json" tasksJSON; From 90679fd09da7644287bc3dbd0c590621f9f145ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 18 Sep 2024 19:30:02 +0100 Subject: [PATCH 27/45] fix mongodb test --- examples/mongodb/devenv.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/mongodb/devenv.yaml b/examples/mongodb/devenv.yaml index 89a8475be..09bce897b 100644 --- a/examples/mongodb/devenv.yaml +++ b/examples/mongodb/devenv.yaml @@ -1,4 +1 @@ allowUnfree: true -inputs: - nixpkgs: - url: github:NixOS/nixpkgs/nixpkgs-unstable From d0e34ad9e1dabd8dbd29f0ee0a1328917cae4dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 18 Sep 2024 19:55:28 +0100 Subject: [PATCH 28/45] fix cachix push and impure --- devenv/src/cnix.rs | 60 +++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs index 9b46b86e1..8ce9ff401 100644 --- a/devenv/src/cnix.rs +++ b/devenv/src/cnix.rs @@ -328,10 +328,9 @@ impl<'a> Nix<'a> { options: &Options<'a>, ) -> Result { let mut final_args = Vec::new(); - let mut final_command = command.to_string(); - let mut push_args = Vec::new(); let known_keys; let pull_caches; + let mut push_cache = None; if !self.global_options.offline { let cachix_caches = self.get_cachix_caches().await; @@ -343,6 +342,7 @@ impl<'a> Nix<'a> { self.logger.debug(&format!("{}", e)); } Ok(cachix_caches) => { + push_cache = cachix_caches.caches.push.clone(); // handle cachix.pull pull_caches = cachix_caches .caches @@ -363,31 +363,42 @@ impl<'a> Nix<'a> { "extra-trusted-public-keys", &known_keys, ]); - - // handle cachix.push - if let Some(push_cache) = &cachix_caches.caches.push { - if env::var("CACHIX_AUTH_TOKEN").is_ok() { - final_command = "cachix".to_string(); - push_args = vec![ - "watch-exec".to_string(), - push_cache.clone(), - "--".to_string(), - command.to_string(), - ]; - final_args = push_args.iter().map(|s| s.as_str()).collect(); - } else { - self.logger.warn(&format!( - "CACHIX_AUTH_TOKEN is not set, but required to push to {}.", - push_cache - )); - } - } } } } final_args.extend(args.iter().map(|&s| s)); - self.prepare_command(&final_command, &final_args, options) + let cmd = self.prepare_command(&command.to_string(), &final_args, options)?; + + // handle cachix.push + if let Some(push_cache) = push_cache { + if env::var("CACHIX_AUTH_TOKEN").is_ok() { + let original_command = cmd.get_program().to_string_lossy().to_string(); + let mut new_cmd = std::process::Command::new("cachix"); + let push_args = vec![ + "watch-exec".to_string(), + push_cache.clone(), + "--".to_string(), + original_command, + ]; + new_cmd.args(&push_args); + new_cmd.args(cmd.get_args()); + // make sure to copy all env vars + for (key, value) in cmd.get_envs() { + if let Some(value) = value { + new_cmd.env(key, value); + } + } + new_cmd.current_dir(cmd.get_current_dir().unwrap_or_else(|| Path::new("."))); + return Ok(new_cmd); + } else { + self.logger.warn(&format!( + "CACHIX_AUTH_TOKEN is not set, but required to push to {}.", + push_cache + )); + } + } + Ok(cmd) } pub fn prepare_command( @@ -436,9 +447,8 @@ impl<'a> Nix<'a> { // avoid passing it to the older utilities, e.g. like `nix-store` when creating GC roots. if command == "nix" && args - .first() - .map(|arg| arg == &"build" || arg == &"eval" || arg == &"print-dev-env") - .unwrap_or(false) + .iter() + .any(|&arg| arg == "build" || arg == "eval" || arg == "print-dev-env") { flags.push("--no-pure-eval"); } From cd65c25bf86cd1a6fdf325e068e7485b095a9971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Thu, 19 Sep 2024 12:44:41 +0100 Subject: [PATCH 29/45] tasks: refresh docs --- docs/overrides/home.html | 49 +++++++++++++++++++++++++--------------- docs/tasks.md | 27 ++++++++++++++-------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 15c96b52e..526f961ab 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -33,7 +33,8 @@

Fast, Decla Develop natively • Deploy containers • 100.000+ packages - • Write scripts + • Write scripts and + tasks • 50+ supported languages • Define processes • Reuse services @@ -113,7 +114,7 @@

Fast, Decla
{ pkgs, config, ... }: {
   env.GREET = "determinism";
 
-  packages = [ 
+  packages = [
     pkgs.ncdu
   ];
 
@@ -154,9 +155,16 @@ 

Fast, Decla
{ pkgs, ... }: {
-  scripts.build.exec = "parcel build";
+  packages = [ pkgs.yarn ];
 
-  # Runs on git commit and CI
+  scripts.build.exec = "yarn build";
+
+  tasks = {
+    "myapp:build".exec = "build";
+    "devenv:enterShell".depends = ["myapp:build"];
+  };
+
+  # Runs on `git commit` and `devenv test`
   pre-commit.hooks = {
     black.enable = true;
     # Your custom hooks
@@ -172,8 +180,13 @@ 

Fast, Decla
-
devenv shell build
+
devenv shell
 ...
+Succeeded         devenv:pre-commit:install 15ms
+Succeeded         myapp:build               23ms
+Succeeded         devenv:enterShell         23ms
+Finished in 50.14ms devenv:enterShell: 3 Succeeded
+$
 
 
@@ -182,9 +195,9 @@

Fast, Decla
-

Scripts and Git hooks

+

Scripts and Tasks

- Define scripts and + Define scripts, tasks and git hooks to automate your development workflow.

@@ -199,20 +212,20 @@

Fast, Decla

- git hooks. + Tasks.
- Pick from builtin and language specific linters and formatters using git-hooks.nix. + Form dependencies between automation code, executed in parallel and written in your favorite language. +
- Invoke commands inside the environment. + Git hooks.
- Particularly useful in CI/CD and scripting. -
+ Pick from builtin and language specific linters and formatters using git-hooks.nix.
@@ -291,7 +304,7 @@

Fast, Decla Examples
- Checkout examples collection + Checkout examples collection to get started. @@ -363,7 +376,7 @@

Fast, Decla
{ pkgs, ... }: {
-  packages = [ 
+  packages = [
     pkgs.mkdocs
     pkgs.watchexec
   ];
@@ -516,7 +529,7 @@ 

Fast, Decla
{ pkgs, ... }: {
-  packages = [ 
+  packages = [
     pkgs.mkdocs
     pkgs.curl
   ];
@@ -624,7 +637,7 @@ 

Fast, Decla
{ pkgs, ... }: {
-  packages = [ 
+  packages = [
     pkgs.mkdocs
     pkgs.curl
   ];
diff --git a/docs/tasks.md b/docs/tasks.md
index 8af6222a9..00e061956 100644
--- a/docs/tasks.md
+++ b/docs/tasks.md
@@ -1,4 +1,8 @@
-Tasks allow you to form dependencies between commands, executed in parallel.
+# Tasks
+
+!!! info "New in version 1.2"
+
+Tasks allow you to form dependencies between code, executed in parallel.
 
 ## Defining tasks
 
@@ -13,9 +17,9 @@ Tasks allow you to form dependencies between commands, executed in parallel.
 ```
 
 ```shell-session
-$ devenv tasks run hello
-Hello, world
-$
+$ devenv tasks run myapp:hello
+Succeeded         myapp:hello         9ms
+Finished in 50.14ms myapp:hello: 1 Succeeded
 ```
 
 ## enterShell / enterTest
@@ -34,6 +38,15 @@ If you'd like the tasks to run as part of the `enterShell` or `enterTest`:
 }
 ```
 
+```shell-session
+$ devenv shell
+...
+Succeeded         devenv:pre-commit:install 25ms
+Succeeded         bash:hello                 9ms
+Succeeded         devenv:enterShell         23ms
+Finished in 103.14ms devenv:enterShell: 3 Succeeded
+```
+
 ## Using your favourite language
 
 Tasks can also reference scripts and depend on other tasks, for example when entering the shell:
@@ -53,12 +66,6 @@ Tasks can also reference scripts and depend on other tasks, for example when ent
 }
 ```
 
-```shell-session
-$ devenv shell
-...
-```
-
-
 ## Avoiding running expensive `exec` via `status` check
 
 If you define a `status` command, it will be executed first and if it returns `0`, `exec` will be skipped.

From d4c579b342ce1c74f4ed8dc7a2d1d4f4e5d3bc4f Mon Sep 17 00:00:00 2001
From: Sander 
Date: Thu, 19 Sep 2024 11:51:57 +0000
Subject: [PATCH 30/45] move task tests to a test module

---
 devenv/src/tasks.rs | 677 ++++++++++++++++++++++----------------------
 1 file changed, 339 insertions(+), 338 deletions(-)

diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs
index 9a2c727f8..930e4867a 100644
--- a/devenv/src/tasks.rs
+++ b/devenv/src/tasks.rs
@@ -7,23 +7,12 @@ use miette::Diagnostic;
 use petgraph::algo::toposort;
 use petgraph::graph::{DiGraph, NodeIndex};
 use petgraph::visit::EdgeRef;
-#[cfg(test)]
-use pretty_assertions::assert_matches;
 use serde::{Deserialize, Serialize};
-#[cfg(test)]
-use serde_json::json;
 use std::collections::{HashMap, HashSet};
 use std::fmt::Display;
-#[cfg(test)]
-use std::fs;
 use std::io;
-#[cfg(test)]
-use std::io::Write;
-#[cfg(test)]
-use std::os::unix::fs::PermissionsExt;
 use std::process::Stdio;
 use std::sync::Arc;
-use test_log::test;
 use thiserror::Error;
 use tokio::process::Command;
 use tokio::sync::RwLock;
@@ -799,298 +788,309 @@ impl TasksUi {
     }
 }
 
-#[test(tokio::test)]
-async fn test_task_name() -> Result<(), Error> {
-    let invalid_names = vec![
-        "invalid:name!",
-        "invalid name",
-        "invalid@name",
-        ":invalid",
-        "invalid:",
-        "invalid",
-    ];
-    for task in invalid_names {
-        assert_matches!(
-            Config::try_from(json!({
-                "roots": [],
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    use pretty_assertions::assert_matches;
+    use serde_json::json;
+    use std::fs;
+    use std::io::Write;
+    use std::os::unix::fs::PermissionsExt;
+
+    #[tokio::test]
+    async fn test_task_name() -> Result<(), Error> {
+        let invalid_names = vec![
+            "invalid:name!",
+            "invalid name",
+            "invalid@name",
+            ":invalid",
+            "invalid:",
+            "invalid",
+        ];
+        for task in invalid_names {
+            assert_matches!(
+                Config::try_from(json!({
+                    "roots": [],
+                        "tasks": [{
+                            "name": task.to_string()
+                        }]
+                }))
+                .map(Tasks::new)
+                .unwrap()
+                .await,
+                Err(Error::InvalidTaskName(_))
+            );
+        }
+        let valid_names = vec![
+            "devenv:enterShell",
+            "devenv:enter-shell",
+            "devenv:enter_shell",
+            "devenv:python:virtualenv",
+        ];
+        for task in valid_names {
+            assert_matches!(
+                Config::try_from(serde_json::json!({
+                    "roots": [],
                     "tasks": [{
                         "name": task.to_string()
                     }]
+                }))
+                .map(Tasks::new)
+                .unwrap()
+                .await,
+                Ok(_)
+            );
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_basic_tasks() -> Result<(), Error> {
+        let script1 = create_script(
+            "#!/bin/sh\necho 'Task 1 is running' && sleep 0.5 && echo 'Task 1 completed'",
+        )?;
+        let script2 = create_script(
+            "#!/bin/sh\necho 'Task 2 is running' && sleep 0.5 && echo 'Task 2 completed'",
+        )?;
+        let script3 = create_script(
+            "#!/bin/sh\necho 'Task 3 is running' && sleep 0.5 && echo 'Task 3 completed'",
+        )?;
+        let script4 =
+            create_script("#!/bin/sh\necho 'Task 4 is running' && echo 'Task 4 completed'")?;
+
+        let tasks = Tasks::new(
+            Config::try_from(json!({
+                "roots": ["myapp:task_1", "myapp:task_4"],
+                "tasks": [
+                    {
+                        "name": "myapp:task_1",
+                        "command": script1.to_str().unwrap()
+                    },
+                    {
+                        "name": "myapp:task_2",
+                        "command": script2.to_str().unwrap()
+                    },
+                    {
+                        "name": "myapp:task_3",
+                        "depends": ["myapp:task_1"],
+                        "command": script3.to_str().unwrap()
+                    },
+                    {
+                        "name": "myapp:task_4",
+                        "depends": ["myapp:task_3"],
+                        "command": script4.to_str().unwrap()
+                    }
+                ]
             }))
-            .map(Tasks::new)
-            .unwrap()
-            .await,
-            Err(Error::InvalidTaskName(_))
+            .unwrap(),
+        )
+        .await?;
+        tasks.run().await;
+
+        let task_statuses = inspect_tasks(&tasks).await;
+        let task_statuses = task_statuses.as_slice();
+        assert_matches!(
+            task_statuses,
+            [
+                (name1, TaskStatus::Completed(TaskCompleted::Success(_, _))),
+                (name2, TaskStatus::Completed(TaskCompleted::Success(_, _))),
+                (name3, TaskStatus::Completed(TaskCompleted::Success(_, _)))
+            ] if name1 == "myapp:task_1" && name2 == "myapp:task_3" && name3 == "myapp:task_4"
         );
+        Ok(())
     }
-    let valid_names = vec![
-        "devenv:enterShell",
-        "devenv:enter-shell",
-        "devenv:enter_shell",
-        "devenv:python:virtualenv",
-    ];
-    for task in valid_names {
+
+    #[tokio::test]
+    async fn test_tasks_cycle() -> Result<(), Error> {
+        let result = Tasks::new(
+            Config::try_from(json!({
+                "roots": ["myapp:task_1"],
+                "tasks": [
+                    {
+                        "name": "myapp:task_1",
+                        "depends": ["myapp:task_2"],
+                        "command": "echo 'Task 1 is running' && echo 'Task 1 completed'"
+                    },
+                    {
+                        "name": "myapp:task_2",
+                        "depends": ["myapp:task_1"],
+                        "command": "echo 'Task 2 is running' && echo 'Task 2 completed'"
+                    }
+                ]
+            }))
+            .unwrap(),
+        )
+        .await;
+        if let Err(Error::CycleDetected(task)) = result {
+            assert_eq!(task, "myapp:task_2".to_string());
+        } else {
+            panic!("Expected Error::CycleDetected, got {:?}", result);
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_status() -> Result<(), Error> {
+        let command_script1 =
+            create_script("#!/bin/sh\necho 'Task 1 is running' && echo 'Task 1 completed'")?;
+        let status_script1 = create_script("#!/bin/sh\nexit 0")?;
+        let command_script2 =
+            create_script("#!/bin/sh\necho 'Task 2 is running' && echo 'Task 2 completed'")?;
+        let status_script2 = create_script("#!/bin/sh\nexit 1")?;
+
+        let command1 = command_script1.to_str().unwrap();
+        let status1 = status_script1.to_str().unwrap();
+        let command2 = command_script2.to_str().unwrap();
+        let status2 = status_script2.to_str().unwrap();
+
+        let create_tasks = |root: &'static str| async move {
+            Tasks::new(
+                Config::try_from(json!({
+                    "roots": [root],
+                    "tasks": [
+                        {
+                            "name": "myapp:task_1",
+                            "command": command1,
+                            "status": status1
+                        },
+                        {
+                            "name": "myapp:task_2",
+                            "command": command2,
+                            "status": status2
+                        }
+                    ]
+                }))
+                .unwrap(),
+            )
+            .await
+        };
+
+        let tasks = create_tasks("myapp:task_1").await.unwrap();
+        tasks.run().await;
+        assert_eq!(tasks.tasks_order.len(), 1);
+        assert_matches!(
+            tasks.graph[tasks.tasks_order[0]].read().await.status,
+            TaskStatus::Completed(TaskCompleted::Skipped(Skipped::Cached(_)))
+        );
+
+        let tasks = create_tasks("myapp:task_2").await.unwrap();
+        tasks.run().await;
+        assert_eq!(tasks.tasks_order.len(), 1);
         assert_matches!(
-            Config::try_from(serde_json::json!({
-                "roots": [],
-                "tasks": [{
-                    "name": task.to_string()
-                }]
+            tasks.graph[tasks.tasks_order[0]].read().await.status,
+            TaskStatus::Completed(TaskCompleted::Success(_, _))
+        );
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_nonexistent_script() -> Result<(), Error> {
+        let tasks = Tasks::new(
+            Config::try_from(json!({
+                "roots": ["myapp:task_1"],
+                "tasks": [
+                    {
+                        "name": "myapp:task_1",
+                        "command": "/path/to/nonexistent/script.sh"
+                    }
+                ]
             }))
-            .map(Tasks::new)
-            .unwrap()
-            .await,
-            Ok(_)
+            .unwrap(),
+        )
+        .await?;
+
+        tasks.run().await;
+
+        let task_statuses = inspect_tasks(&tasks).await;
+        let task_statuses = task_statuses.as_slice();
+        assert_matches!(
+            &task_statuses,
+            [(
+                task_1,
+                TaskStatus::Completed(TaskCompleted::Failed(
+                    _,
+                    TaskFailure {
+                        stdout: _,
+                        stderr: _,
+                        error
+                    }
+                ))
+            )] if error == "No such file or directory (os error 2)" && task_1 == "myapp:task_1"
         );
+
+        Ok(())
     }
-    Ok(())
-}
 
-#[test(tokio::test)]
-async fn test_basic_tasks() -> Result<(), Error> {
-    let script1 = create_script(
-        "#!/bin/sh\necho 'Task 1 is running' && sleep 0.5 && echo 'Task 1 completed'",
-    )?;
-    let script2 = create_script(
-        "#!/bin/sh\necho 'Task 2 is running' && sleep 0.5 && echo 'Task 2 completed'",
-    )?;
-    let script3 = create_script(
-        "#!/bin/sh\necho 'Task 3 is running' && sleep 0.5 && echo 'Task 3 completed'",
-    )?;
-    let script4 = create_script("#!/bin/sh\necho 'Task 4 is running' && echo 'Task 4 completed'")?;
-
-    let tasks = Tasks::new(
-        Config::try_from(json!({
-            "roots": ["myapp:task_1", "myapp:task_4"],
-            "tasks": [
-                {
-                    "name": "myapp:task_1",
-                    "command": script1.to_str().unwrap()
-                },
-                {
-                    "name": "myapp:task_2",
-                    "command": script2.to_str().unwrap()
-                },
-                {
-                    "name": "myapp:task_3",
-                    "depends": ["myapp:task_1"],
-                    "command": script3.to_str().unwrap()
-                },
-                {
-                    "name": "myapp:task_4",
-                    "depends": ["myapp:task_3"],
-                    "command": script4.to_str().unwrap()
-                }
-            ]
-        }))
-        .unwrap(),
-    )
-    .await?;
-    tasks.run().await;
-
-    let task_statuses = inspect_tasks(&tasks).await;
-    let task_statuses = task_statuses.as_slice();
-    assert_matches!(
-        task_statuses,
-        [
-            (name1, TaskStatus::Completed(TaskCompleted::Success(_, _))),
-            (name2, TaskStatus::Completed(TaskCompleted::Success(_, _))),
-            (name3, TaskStatus::Completed(TaskCompleted::Success(_, _)))
-        ] if name1 == "myapp:task_1" && name2 == "myapp:task_3" && name3 == "myapp:task_4"
-    );
-    Ok(())
-}
+    #[tokio::test]
+    async fn test_status_without_command() -> Result<(), Error> {
+        let status_script = create_script("#!/bin/sh\nexit 0")?;
 
-#[test(tokio::test)]
-async fn test_tasks_cycle() -> Result<(), Error> {
-    let result = Tasks::new(
-        Config::try_from(json!({
-            "roots": ["myapp:task_1"],
-            "tasks": [
-                {
-                    "name": "myapp:task_1",
-                    "depends": ["myapp:task_2"],
-                    "command": "echo 'Task 1 is running' && echo 'Task 1 completed'"
-                },
-                {
-                    "name": "myapp:task_2",
-                    "depends": ["myapp:task_1"],
-                    "command": "echo 'Task 2 is running' && echo 'Task 2 completed'"
-                }
-            ]
-        }))
-        .unwrap(),
-    )
-    .await;
-    if let Err(Error::CycleDetected(task)) = result {
-        assert_eq!(task, "myapp:task_2".to_string());
-    } else {
-        panic!("Expected Error::CycleDetected, got {:?}", result);
+        let result = Tasks::new(
+            Config::try_from(json!({
+                "roots": ["myapp:task_1"],
+                "tasks": [
+                    {
+                        "name": "myapp:task_1",
+                        "status": status_script.to_str().unwrap()
+                    }
+                ]
+            }))
+            .unwrap(),
+        )
+        .await;
+
+        assert!(matches!(result, Err(Error::MissingCommand(_))));
+        Ok(())
     }
-    Ok(())
-}
 
-#[test(tokio::test)]
-async fn test_status() -> Result<(), Error> {
-    let command_script1 =
-        create_script("#!/bin/sh\necho 'Task 1 is running' && echo 'Task 1 completed'")?;
-    let status_script1 = create_script("#!/bin/sh\nexit 0")?;
-    let command_script2 =
-        create_script("#!/bin/sh\necho 'Task 2 is running' && echo 'Task 2 completed'")?;
-    let status_script2 = create_script("#!/bin/sh\nexit 1")?;
-
-    let command1 = command_script1.to_str().unwrap();
-    let status1 = status_script1.to_str().unwrap();
-    let command2 = command_script2.to_str().unwrap();
-    let status2 = status_script2.to_str().unwrap();
-
-    let create_tasks = |root: &'static str| async move {
-        Tasks::new(
+    #[tokio::test]
+    async fn test_dependency_failure() -> Result<(), Error> {
+        let failing_script = create_script("#!/bin/sh\necho 'Failing task' && exit 1")?;
+        let dependent_script = create_script("#!/bin/sh\necho 'Dependent task' && exit 0")?;
+
+        let tasks = Tasks::new(
             Config::try_from(json!({
-                "roots": [root],
+                "roots": ["myapp:task_2"],
                 "tasks": [
                     {
                         "name": "myapp:task_1",
-                        "command": command1,
-                        "status": status1
+                        "command": failing_script.to_str().unwrap()
                     },
                     {
                         "name": "myapp:task_2",
-                        "command": command2,
-                        "status": status2
+                        "depends": ["myapp:task_1"],
+                        "command": dependent_script.to_str().unwrap()
                     }
                 ]
             }))
             .unwrap(),
         )
-        .await
-    };
-
-    let tasks = create_tasks("myapp:task_1").await.unwrap();
-    tasks.run().await;
-    assert_eq!(tasks.tasks_order.len(), 1);
-    assert_matches!(
-        tasks.graph[tasks.tasks_order[0]].read().await.status,
-        TaskStatus::Completed(TaskCompleted::Skipped(Skipped::Cached(_)))
-    );
-
-    let tasks = create_tasks("myapp:task_2").await.unwrap();
-    tasks.run().await;
-    assert_eq!(tasks.tasks_order.len(), 1);
-    assert_matches!(
-        tasks.graph[tasks.tasks_order[0]].read().await.status,
-        TaskStatus::Completed(TaskCompleted::Success(_, _))
-    );
-
-    Ok(())
-}
-
-#[test(tokio::test)]
-async fn test_nonexistent_script() -> Result<(), Error> {
-    let tasks = Tasks::new(
-        Config::try_from(json!({
-            "roots": ["myapp:task_1"],
-            "tasks": [
-                {
-                    "name": "myapp:task_1",
-                    "command": "/path/to/nonexistent/script.sh"
-                }
-            ]
-        }))
-        .unwrap(),
-    )
-    .await?;
-
-    tasks.run().await;
-
-    let task_statuses = inspect_tasks(&tasks).await;
-    let task_statuses = task_statuses.as_slice();
-    assert_matches!(
-        &task_statuses,
-        [(
-            task_1,
-            TaskStatus::Completed(TaskCompleted::Failed(
-                _,
-                TaskFailure {
-                    stdout: _,
-                    stderr: _,
-                    error
-                }
-            ))
-        )] if error == "No such file or directory (os error 2)" && task_1 == "myapp:task_1"
-    );
+        .await?;
 
-    Ok(())
-}
-
-#[test(tokio::test)]
-async fn test_status_without_command() -> Result<(), Error> {
-    let status_script = create_script("#!/bin/sh\nexit 0")?;
+        tasks.run().await;
 
-    let result = Tasks::new(
-        Config::try_from(json!({
-            "roots": ["myapp:task_1"],
-            "tasks": [
-                {
-                    "name": "myapp:task_1",
-                    "status": status_script.to_str().unwrap()
-                }
-            ]
-        }))
-        .unwrap(),
-    )
-    .await;
-
-    assert!(matches!(result, Err(Error::MissingCommand(_))));
-    Ok(())
-}
-
-#[test(tokio::test)]
-async fn test_dependency_failure() -> Result<(), Error> {
-    let failing_script = create_script("#!/bin/sh\necho 'Failing task' && exit 1")?;
-    let dependent_script = create_script("#!/bin/sh\necho 'Dependent task' && exit 0")?;
-
-    let tasks = Tasks::new(
-        Config::try_from(json!({
-            "roots": ["myapp:task_2"],
-            "tasks": [
-                {
-                    "name": "myapp:task_1",
-                    "command": failing_script.to_str().unwrap()
-                },
-                {
-                    "name": "myapp:task_2",
-                    "depends": ["myapp:task_1"],
-                    "command": dependent_script.to_str().unwrap()
-                }
-            ]
-        }))
-        .unwrap(),
-    )
-    .await?;
-
-    tasks.run().await;
-
-    let task_statuses = inspect_tasks(&tasks).await;
-    let task_statuses_slice = &task_statuses.as_slice();
-    assert_matches!(
-        *task_statuses_slice,
-        [
-            (task_1, TaskStatus::Completed(TaskCompleted::Failed(_, _))),
-            (
-                task_2,
-                TaskStatus::Completed(TaskCompleted::DependencyFailed)
-            )
-        ] if task_1 == "myapp:task_1" && task_2 == "myapp:task_2"
-    );
+        let task_statuses = inspect_tasks(&tasks).await;
+        let task_statuses_slice = &task_statuses.as_slice();
+        assert_matches!(
+            *task_statuses_slice,
+            [
+                (task_1, TaskStatus::Completed(TaskCompleted::Failed(_, _))),
+                (
+                    task_2,
+                    TaskStatus::Completed(TaskCompleted::DependencyFailed)
+                )
+            ] if task_1 == "myapp:task_1" && task_2 == "myapp:task_2"
+        );
 
-    Ok(())
-}
+        Ok(())
+    }
 
-#[test(tokio::test)]
-async fn test_inputs_outputs() -> Result<(), Error> {
-    let input_script = create_script(
-        r#"#!/bin/sh
+    #[tokio::test]
+    async fn test_inputs_outputs() -> Result<(), Error> {
+        let input_script = create_script(
+            r#"#!/bin/sh
 echo "{\"key\": \"value\"}" > $DEVENV_TASK_OUTPUT
 if [ "$DEVENV_TASK_INPUT" != '{"test":"input"}' ]; then
     echo "Error: Input does not match expected value" >&2
@@ -1099,10 +1099,10 @@ if [ "$DEVENV_TASK_INPUT" != '{"test":"input"}' ]; then
     exit 1
 fi
 "#,
-    )?;
+        )?;
 
-    let output_script = create_script(
-        r#"#!/bin/sh
+        let output_script = create_script(
+            r#"#!/bin/sh
         if [ "$DEVENV_TASKS_OUTPUTS" != '{"myapp:task_1":{"key":"value"}}' ]; then
             echo "Error: Outputs do not match expected value" >&2
             echo "Expected: {\"myapp:task_1\":{\"key\":\"value\"}}" >&2
@@ -1111,70 +1111,71 @@ fi
         fi
         echo "{\"result\": \"success\"}" > $DEVENV_TASK_OUTPUT
 "#,
-    )?;
+        )?;
 
-    let tasks = Tasks::new(
-        Config::try_from(json!({
-            "roots": ["myapp:task_1", "myapp:task_2"],
-            "tasks": [
-                {
-                    "name": "myapp:task_1",
-                    "command": input_script.to_str().unwrap(),
-                    "inputs": {"test": "input"}
-                },
-                {
-                    "name": "myapp:task_2",
-                    "command": output_script.to_str().unwrap(),
-                    "depends": ["myapp:task_1"]
-                }
-            ]
-        }))
-        .unwrap(),
-    )
-    .await?;
-
-    let outputs = tasks.run().await;
-    let task_statuses = inspect_tasks(&tasks).await;
-    let task_statuses = task_statuses.as_slice();
-    assert_matches!(
-        task_statuses,
-        [
-            (name1, TaskStatus::Completed(TaskCompleted::Success(_, _))),
-            (name2, TaskStatus::Completed(TaskCompleted::Success(_, _)))
-        ] if name1 == "myapp:task_1" && name2 == "myapp:task_2"
-    );
-
-    assert_eq!(
-        outputs.get("myapp:task_1").unwrap(),
-        &json!({"key": "value"})
-    );
-    assert_eq!(
-        outputs.get("myapp:task_2").unwrap(),
-        &json!({"result": "success"})
-    );
-
-    Ok(())
-}
+        let tasks = Tasks::new(
+            Config::try_from(json!({
+                "roots": ["myapp:task_1", "myapp:task_2"],
+                "tasks": [
+                    {
+                        "name": "myapp:task_1",
+                        "command": input_script.to_str().unwrap(),
+                        "inputs": {"test": "input"}
+                    },
+                    {
+                        "name": "myapp:task_2",
+                        "command": output_script.to_str().unwrap(),
+                        "depends": ["myapp:task_1"]
+                    }
+                ]
+            }))
+            .unwrap(),
+        )
+        .await?;
 
-#[cfg(test)]
-async fn inspect_tasks(tasks: &Tasks) -> Vec<(String, TaskStatus)> {
-    let mut result = Vec::new();
-    for index in &tasks.tasks_order {
-        let task_state = tasks.graph[*index].read().await;
-        result.push((task_state.task.name.clone(), task_state.status.clone()));
+        let outputs = tasks.run().await;
+        let task_statuses = inspect_tasks(&tasks).await;
+        let task_statuses = task_statuses.as_slice();
+        assert_matches!(
+            task_statuses,
+            [
+                (name1, TaskStatus::Completed(TaskCompleted::Success(_, _))),
+                (name2, TaskStatus::Completed(TaskCompleted::Success(_, _)))
+            ] if name1 == "myapp:task_1" && name2 == "myapp:task_2"
+        );
+
+        assert_eq!(
+            outputs.get("myapp:task_1").unwrap(),
+            &json!({"key": "value"})
+        );
+        assert_eq!(
+            outputs.get("myapp:task_2").unwrap(),
+            &json!({"result": "success"})
+        );
+
+        Ok(())
     }
-    result
-}
 
-#[cfg(test)]
-fn create_script(script: &str) -> std::io::Result {
-    let mut temp_file = tempfile::Builder::new()
-        .prefix(&format!("script"))
-        .suffix(".sh")
-        .tempfile()?;
-    temp_file.write_all(script.as_bytes())?;
-    temp_file
-        .as_file_mut()
-        .set_permissions(fs::Permissions::from_mode(0o755))?;
-    Ok(temp_file.into_temp_path())
+    #[cfg(test)]
+    async fn inspect_tasks(tasks: &Tasks) -> Vec<(String, TaskStatus)> {
+        let mut result = Vec::new();
+        for index in &tasks.tasks_order {
+            let task_state = tasks.graph[*index].read().await;
+            result.push((task_state.task.name.clone(), task_state.status.clone()));
+        }
+        result
+    }
+
+    #[cfg(test)]
+    fn create_script(script: &str) -> std::io::Result {
+        let mut temp_file = tempfile::Builder::new()
+            .prefix("script")
+            .suffix(".sh")
+            .tempfile()?;
+        temp_file.write_all(script.as_bytes())?;
+        temp_file
+            .as_file_mut()
+            .set_permissions(fs::Permissions::from_mode(0o755))?;
+        Ok(temp_file.into_temp_path())
+    }
 }

From e7d6c769e1d5b19eaa373e5847708127c69f7226 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Domen=20Ko=C5=BEar?= 
Date: Thu, 19 Sep 2024 13:23:09 +0100
Subject: [PATCH 31/45] docs: add note about TSP

---
 docs/tasks.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/docs/tasks.md b/docs/tasks.md
index 00e061956..6faec992e 100644
--- a/docs/tasks.md
+++ b/docs/tasks.md
@@ -110,6 +110,6 @@ Tasks support passing inputs and produce outputs, both as JSON objects:
 }
 ```
 
-## SDK
+## SDK using Task Server Protocol
 
-See [xxx](xxx) for a proposal how defining tasks in your favorite language would look like.
+See [Task Server Protocol](https://github.com/cachix/devenv/issues/1457) for a proposal how defining tasks in your favorite language would look like.

From 137c48a2100a89b52c954103fe4a5d343c5090a8 Mon Sep 17 00:00:00 2001
From: Sander 
Date: Thu, 19 Sep 2024 11:58:58 +0000
Subject: [PATCH 32/45] fix clippy warnings

---
 devenv/src/cnix.rs   | 21 ++++++++++-----------
 devenv/src/devenv.rs | 14 +++++++-------
 devenv/src/tasks.rs  |  2 +-
 3 files changed, 18 insertions(+), 19 deletions(-)

diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs
index 8ce9ff401..3f93dbcb2 100644
--- a/devenv/src/cnix.rs
+++ b/devenv/src/cnix.rs
@@ -76,7 +76,7 @@ impl<'a> Nix<'a> {
             replace_shell,
             ..self.options
         };
-        self.run_nix_with_substituters("nix", &args, &options).await
+        self.run_nix_with_substituters("nix", args, &options).await
     }
 
     pub async fn dev_env(&mut self, json: bool, gc_root: &PathBuf) -> Result> {
@@ -102,7 +102,7 @@ impl<'a> Nix<'a> {
         let target = format!("{}-shell", now_ns);
         symlink_force(
             &self.logger,
-            &fs::canonicalize(&gc_root).expect("to resolve gc_root"),
+            &fs::canonicalize(gc_root).expect("to resolve gc_root"),
             &self.devenv_home_gc.join(target),
         );
         Ok(env.stdout)
@@ -150,7 +150,6 @@ impl<'a> Nix<'a> {
                 .await?;
             Ok(String::from_utf8_lossy(&output.stdout)
                 .to_string()
-                .trim()
                 .split_whitespace()
                 .map(|s| PathBuf::from(s.to_string()))
                 .collect())
@@ -164,10 +163,10 @@ impl<'a> Nix<'a> {
             .into_iter()
             .map(String::from)
             .collect();
-        args.extend(attributes.into_iter().map(|attr| format!(".#{}", attr)));
+        args.extend(attributes.iter().map(|attr| format!(".#{}", attr)));
         let args = &args.iter().map(|s| s.as_str()).collect::>();
         let options = self.options;
-        let result = self.run_nix("nix", &args, &options)?;
+        let result = self.run_nix("nix", args, &options)?;
         String::from_utf8(result.stdout)
             .map_err(|err| miette::miette!("Failed to parse command output as UTF-8: {}", err))
     }
@@ -223,7 +222,7 @@ impl<'a> Nix<'a> {
             .collect();
         for path in paths {
             self.logger.info(&format!("Deleting {}...", path));
-            let args: Vec<&str> = ["store", "delete", path].iter().copied().collect();
+            let args: Vec<&str> = ["store", "delete", path].to_vec();
             let cmd = self.prepare_command("nix", &args, &options);
             // we ignore if this command fails, because root might be in use
             let _ = cmd?.output();
@@ -367,8 +366,8 @@ impl<'a> Nix<'a> {
             }
         }
 
-        final_args.extend(args.iter().map(|&s| s));
-        let cmd = self.prepare_command(&command.to_string(), &final_args, options)?;
+        final_args.extend(args.iter().copied());
+        let cmd = self.prepare_command(command, &final_args, options)?;
 
         // handle cachix.push
         if let Some(push_cache) = push_cache {
@@ -456,7 +455,7 @@ impl<'a> Nix<'a> {
             cmd.env("NIX_PATH", ":");
         }
         cmd.args(flags);
-        cmd.current_dir(&self.devenv_root.as_path());
+        cmd.current_dir(self.devenv_root.as_path());
 
         if self.global_options.verbose {
             self.logger
@@ -476,7 +475,7 @@ impl<'a> Nix<'a> {
                 let caches_raw = self.eval(&["devenv.cachix"]).await?;
                 let cachix = serde_json::from_str(&caches_raw).expect("Failed to parse JSON");
                 let known_keys = if let Ok(known_keys) =
-                    std::fs::read_to_string(&self.cachix_trusted_keys.as_path())
+                    std::fs::read_to_string(self.cachix_trusted_keys.as_path())
                 {
                     serde_json::from_str(&known_keys).expect("Failed to parse JSON")
                 } else {
@@ -545,7 +544,7 @@ impl<'a> Nix<'a> {
                     }
 
                     std::fs::write(
-                        &self.cachix_trusted_keys.as_path(),
+                        self.cachix_trusted_keys.as_path(),
                         serde_json::to_string(&caches.known_keys).unwrap(),
                     )
                     .expect("Failed to write cachix caches to file");
diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs
index 5f1a26553..e7b994b7b 100644
--- a/devenv/src/devenv.rs
+++ b/devenv/src/devenv.rs
@@ -536,14 +536,14 @@ impl<'a> Devenv<'a> {
         let (tasks_status, outputs) = tui.run().await?;
 
         if tasks_status.failed > 0 || tasks_status.dependency_failed > 0 {
-            Err(miette::bail!("Some tasks failed"))
-        } else {
-            println!(
-                "{}",
-                serde_json::to_string(&outputs).expect("poarsing of outputs failed")
-            );
-            Ok(())
+            miette::bail!("Some tasks failed");
         }
+
+        println!(
+            "{}",
+            serde_json::to_string(&outputs).expect("parsing of outputs failed")
+        );
+        Ok(())
     }
 
     pub async fn test(&mut self) -> Result<()> {
diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs
index 930e4867a..acfd245d7 100644
--- a/devenv/src/tasks.rs
+++ b/devenv/src/tasks.rs
@@ -163,7 +163,7 @@ impl TaskState {
         cmd: &str,
         outputs: &HashMap,
     ) -> (Command, tempfile::NamedTempFile) {
-        let mut command = Command::new(&cmd);
+        let mut command = Command::new(cmd);
         command.stdout(Stdio::piped()).stderr(Stdio::piped());
 
         // Set DEVENV_TASK_INPUTS

From 6d492ff651f5b20640d05586e0bad60a70b89e07 Mon Sep 17 00:00:00 2001
From: Sander 
Date: Thu, 19 Sep 2024 14:10:05 +0000
Subject: [PATCH 33/45] devenv: avoid modifying paths post-init

---
 devenv/src/devenv.rs | 10 ---------
 devenv/src/main.rs   | 53 ++++++++++++++++++++++++--------------------
 2 files changed, 29 insertions(+), 34 deletions(-)

diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs
index e7b994b7b..a5292a9c8 100644
--- a/devenv/src/devenv.rs
+++ b/devenv/src/devenv.rs
@@ -140,16 +140,6 @@ impl<'a> Devenv<'a> {
         }
     }
 
-    // TODO: refactor test to be able to remove this
-    pub fn update_devenv_dotfile

(&mut self, devenv_dotfile: P) - where - P: AsRef, - { - let devenv_dotfile = devenv_dotfile.as_ref(); - self.devenv_dotfile = devenv_dotfile.to_path_buf(); - self.devenv_dot_gc = devenv_dotfile.join("gc"); - } - pub fn processes_log(&self) -> PathBuf { self.devenv_dotfile.join("processes.log") } diff --git a/devenv/src/main.rs b/devenv/src/main.rs index 96f4b8107..3ccf80e77 100644 --- a/devenv/src/main.rs +++ b/devenv/src/main.rs @@ -14,6 +14,15 @@ use miette::Result; async fn main() -> Result<()> { let cli = Cli::parse(); + if let Commands::Version { .. } = cli.command { + println!( + "devenv {} ({})", + crate_version!(), + cli.global_options.system + ); + return Ok(()); + } + let level = if cli.global_options.verbose { log::Level::Debug } else if cli.global_options.quiet { @@ -29,40 +38,35 @@ async fn main() -> Result<()> { config.add_input(&input[0].clone(), &input[1].clone(), &[]); } - let options = devenv::DevenvOptions { + let mut options = devenv::DevenvOptions { logger: Some(logger.clone()), global_options: Some(cli.global_options), config, ..Default::default() }; - let mut devenv = Devenv::new(options); - - if !matches!(cli.command, Commands::Version {} | Commands::Gc { .. }) { - devenv.create_directories()?; + if let Commands::Test { + dont_override_dotfile, + } = cli.command + { + let pwd = std::env::current_dir().expect("Failed to get current directory"); + let tmpdir = + tempdir::TempDir::new_in(pwd, ".devenv").expect("Failed to create temporary directory"); + if !dont_override_dotfile { + logger.info(&format!( + "Overriding .devenv to {}", + tmpdir.path().file_name().unwrap().to_str().unwrap() + )); + options.devenv_root = Some(tmpdir.path().to_path_buf()); + } } + let mut devenv = Devenv::new(options); + devenv.create_directories()?; + match cli.command { Commands::Shell { cmd, args } => devenv.shell(&cmd, &args, true).await, - Commands::Test { - dont_override_dotfile, - } => { - let tmpdir = tempdir::TempDir::new_in(devenv.devenv_root.as_path(), ".devenv") - .expect("Failed to create temporary directory"); - if !dont_override_dotfile { - logger.info(&format!( - "Overriding .devenv to {}", - tmpdir.path().file_name().unwrap().to_str().unwrap() - )); - devenv.update_devenv_dotfile(tmpdir.as_ref()); - } - devenv.test().await - } - Commands::Version {} => Ok(println!( - "devenv {} ({})", - crate_version!(), - devenv.global_options.system - )), + Commands::Test { .. } => devenv.test().await, Commands::Container { registry, copy, @@ -150,5 +154,6 @@ async fn main() -> Result<()> { config::write_json_schema(); Ok(()) } + Commands::Version {} => unreachable!(), } } From 99057e8bc6896ecd7dfa021c3b5c6ea919b4bdc1 Mon Sep 17 00:00:00 2001 From: Sander Date: Thu, 19 Sep 2024 14:12:12 +0000 Subject: [PATCH 34/45] devenv: remove unnecessary muts, clones, and arcs --- devenv/src/cnix.rs | 364 +++++++++++++++++++++---------------------- devenv/src/devenv.rs | 39 ++--- 2 files changed, 195 insertions(+), 208 deletions(-) diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs index 3f93dbcb2..8dffc36cd 100644 --- a/devenv/src/cnix.rs +++ b/devenv/src/cnix.rs @@ -8,23 +8,23 @@ use std::os::unix::fs::symlink; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; pub struct Nix<'a> { logger: log::Logger, pub options: Options<'a>, // TODO: all these shouldn't be here - config: Arc, - global_options: Arc, - cachix_caches: Option, - cachix_trusted_keys: Arc, - devenv_home_gc: Arc, - devenv_dot_gc: Arc, - devenv_root: Arc, + config: config::Config, + global_options: cli::GlobalOptions, + cachix_caches: Arc>>, + cachix_trusted_keys: PathBuf, + devenv_home_gc: PathBuf, + devenv_dot_gc: PathBuf, + devenv_root: PathBuf, } -#[derive(Copy, Clone)] +#[derive(Clone)] pub struct Options<'a> { pub replace_shell: bool, pub logging: bool, @@ -33,18 +33,25 @@ pub struct Options<'a> { } impl<'a> Nix<'a> { - pub fn new( + pub fn new>( logger: log::Logger, - config: Arc, - global_options: Arc, - cachix_trusted_keys: Arc, - devenv_home_gc: Arc, - devenv_dot_gc: Arc, - devenv_root: Arc, + config: config::Config, + global_options: cli::GlobalOptions, + cachix_trusted_keys: P, + devenv_home_gc: P, + devenv_dot_gc: P, + devenv_root: P, ) -> Self { - Nix { + let cachix_trusted_keys = cachix_trusted_keys.as_ref().to_path_buf(); + let devenv_home_gc = devenv_home_gc.as_ref().to_path_buf(); + let devenv_dot_gc = devenv_dot_gc.as_ref().to_path_buf(); + let devenv_root = devenv_root.as_ref().to_path_buf(); + + let cachix_caches = Arc::new(Mutex::new(None)); + + Self { logger, - cachix_caches: None, + cachix_caches, config, global_options, options: Options { @@ -70,7 +77,7 @@ impl<'a> Nix<'a> { } } - pub async fn develop(&mut self, args: &[&str], replace_shell: bool) -> Result { + pub async fn develop(&self, args: &[&str], replace_shell: bool) -> Result { let options = Options { logging_stdout: true, replace_shell, @@ -79,7 +86,7 @@ impl<'a> Nix<'a> { self.run_nix_with_substituters("nix", args, &options).await } - pub async fn dev_env(&mut self, json: bool, gc_root: &PathBuf) -> Result> { + pub async fn dev_env(&self, json: bool, gc_root: &PathBuf) -> Result> { let gc_root_str = gc_root.to_str().expect("gc root should be utf-8"); let mut args: Vec<&str> = vec!["print-dev-env", "--profile", gc_root_str]; if json { @@ -108,8 +115,7 @@ impl<'a> Nix<'a> { Ok(env.stdout) } - pub fn add_gc(&mut self, name: &str, path: &Path) -> Result<()> { - let options = self.options; + pub fn add_gc(&self, name: &str, path: &Path) -> Result<()> { self.run_nix( "nix-store", &[ @@ -118,7 +124,7 @@ impl<'a> Nix<'a> { "-r", path.to_str().unwrap(), ], - &options, + &self.options, )?; let link_path = self .devenv_dot_gc @@ -127,15 +133,13 @@ impl<'a> Nix<'a> { Ok(()) } - pub fn repl(&mut self) -> Result<()> { - let options = self.options; - let mut cmd = self.prepare_command("nix", &["repl", "."], &options)?; + pub fn repl(&self) -> Result<()> { + let mut cmd = self.prepare_command("nix", &["repl", "."], &self.options)?; cmd.exec(); Ok(()) } - pub async fn build(&mut self, attributes: &[&str]) -> Result> { - let options = self.options; + pub async fn build(&self, attributes: &[&str]) -> Result> { if !attributes.is_empty() { // TODO: use eval underneath let mut args: Vec = vec![ @@ -146,7 +150,7 @@ impl<'a> Nix<'a> { args.extend(attributes.iter().map(|attr| format!(".#{}", attr))); let args_str: Vec<&str> = args.iter().map(AsRef::as_ref).collect(); let output = self - .run_nix_with_substituters("nix", &args_str, &options) + .run_nix_with_substituters("nix", &args_str, &self.options) .await?; Ok(String::from_utf8_lossy(&output.stdout) .to_string() @@ -158,40 +162,37 @@ impl<'a> Nix<'a> { } } - pub async fn eval(&mut self, attributes: &[&str]) -> Result { + pub async fn eval(&self, attributes: &[&str]) -> Result { let mut args: Vec = vec!["eval", "--json"] .into_iter() .map(String::from) .collect(); args.extend(attributes.iter().map(|attr| format!(".#{}", attr))); let args = &args.iter().map(|s| s.as_str()).collect::>(); - let options = self.options; - let result = self.run_nix("nix", args, &options)?; + let result = self.run_nix("nix", args, &self.options)?; String::from_utf8(result.stdout) .map_err(|err| miette::miette!("Failed to parse command output as UTF-8: {}", err)) } - pub fn update(&mut self, input_name: &Option) -> Result<()> { - let options = self.options; + pub fn update(&self, input_name: &Option) -> Result<()> { match input_name { Some(input_name) => { self.run_nix( "nix", &["flake", "lock", "--update-input", input_name], - &options, + &self.options, )?; } None => { - self.run_nix("nix", &["flake", "update"], &options)?; + self.run_nix("nix", &["flake", "update"], &self.options)?; } } Ok(()) } - pub fn metadata(&mut self) -> Result { + pub fn metadata(&self) -> Result { // TODO: use --json - let options = self.options; - let metadata = self.run_nix("nix", &["flake", "metadata"], &options)?; + let metadata = self.run_nix("nix", &["flake", "metadata"], &self.options)?; let re = regex::Regex::new(r"(Inputs:.+)$").unwrap(); let metadata_str = String::from_utf8_lossy(&metadata.stdout); @@ -200,7 +201,7 @@ impl<'a> Nix<'a> { None => "", }; - let info_ = self.run_nix("nix", &["eval", "--raw", ".#info"], &options)?; + let info_ = self.run_nix("nix", &["eval", "--raw", ".#info"], &self.options)?; Ok(format!( "{}\n{}", inputs, @@ -208,14 +209,12 @@ impl<'a> Nix<'a> { )) } - pub async fn search(&mut self, name: &str) -> Result { - let options = self.options; - self.run_nix_with_substituters("nix", &["search", "--json", "nixpkgs", name], &options) + pub async fn search(&self, name: &str) -> Result { + self.run_nix_with_substituters("nix", &["search", "--json", "nixpkgs", name], &self.options) .await } - pub fn gc(&mut self, paths: Vec) -> Result<()> { - let options = self.options; + pub fn gc(&self, paths: Vec) -> Result<()> { let paths: std::collections::HashSet<&str> = paths .iter() .filter_map(|path_buf| path_buf.to_str()) @@ -223,7 +222,7 @@ impl<'a> Nix<'a> { for path in paths { self.logger.info(&format!("Deleting {}...", path)); let args: Vec<&str> = ["store", "delete", path].to_vec(); - let cmd = self.prepare_command("nix", &args, &options); + let cmd = self.prepare_command("nix", &args, &self.options); // we ignore if this command fails, because root might be in use let _ = cmd?.output(); } @@ -232,7 +231,7 @@ impl<'a> Nix<'a> { // Run Nix with debugger capability and return the output pub fn run_nix( - &mut self, + &self, command: &str, args: &[&str], options: &Options<'a>, @@ -242,7 +241,7 @@ impl<'a> Nix<'a> { } pub async fn run_nix_with_substituters( - &mut self, + &self, command: &str, args: &[&str], options: &Options<'a>, @@ -254,13 +253,14 @@ impl<'a> Nix<'a> { } fn run_nix_command( - &mut self, + &self, mut cmd: std::process::Command, options: &Options<'a>, ) -> Result { - let prev_level = self.logger.level.clone(); + let mut logger = self.logger.clone(); + if !options.logging { - self.logger.level = log::Level::Error; + logger.level = log::Level::Error; } if options.replace_shell { @@ -313,7 +313,6 @@ impl<'a> Nix<'a> { display_command(&cmd) )) } else { - self.logger.level = prev_level; Ok(result) } } @@ -321,7 +320,7 @@ impl<'a> Nix<'a> { // We have a separate function to avoid recursion as this needs to call self.prepare_command pub async fn prepare_command_with_substituters( - &mut self, + &self, command: &str, args: &[&str], options: &Options<'a>, @@ -401,7 +400,7 @@ impl<'a> Nix<'a> { } pub fn prepare_command( - &mut self, + &self, command: &str, args: &[&str], options: &Options<'a>, @@ -455,7 +454,7 @@ impl<'a> Nix<'a> { cmd.env("NIX_PATH", ":"); } cmd.args(flags); - cmd.current_dir(self.devenv_root.as_path()); + cmd.current_dir(&self.devenv_root); if self.global_options.verbose { self.logger @@ -464,154 +463,151 @@ impl<'a> Nix<'a> { Ok(cmd) } - async fn get_cachix_caches(&mut self) -> Result { - match &self.cachix_caches { - Some(caches) => Ok(caches.clone()), - None => { - let no_logging = Options { - logging: false, - ..self.options - }; - let caches_raw = self.eval(&["devenv.cachix"]).await?; - let cachix = serde_json::from_str(&caches_raw).expect("Failed to parse JSON"); - let known_keys = if let Ok(known_keys) = - std::fs::read_to_string(self.cachix_trusted_keys.as_path()) - { - serde_json::from_str(&known_keys).expect("Failed to parse JSON") - } else { - HashMap::new() - }; - - let mut caches = CachixCaches { - caches: cachix, - known_keys, - }; + async fn get_cachix_caches(&self) -> Result { + if let Some(caches) = &*self.cachix_caches.lock().unwrap() { + return Ok(caches.clone()); + } - let mut new_known_keys: HashMap = HashMap::new(); - let client = reqwest::Client::new(); - for name in caches.caches.pull.iter() { - if !caches.known_keys.contains_key(name) { - let mut request = - client.get(&format!("https://cachix.org/api/v1/cache/{}", name)); - if let Ok(ret) = env::var("CACHIX_AUTH_TOKEN") { - request = request.bearer_auth(ret); - } - let resp = request.send().await.expect("Failed to get cache"); - if resp.status().is_client_error() { - self.logger.error(&format!( - "Cache {} does not exist or you don't have a CACHIX_AUTH_TOKEN configured.", - name - )); - self.logger - .error("To create a cache, go to https://app.cachix.org/."); - bail!("Cache does not exist or you don't have a CACHIX_AUTH_TOKEN configured.") - } else { - let resp_json = serde_json::from_slice::( - &resp.bytes().await.unwrap(), - ) - .expect("Failed to parse JSON"); - new_known_keys - .insert(name.clone(), resp_json.public_signing_keys[0].clone()); - } - } - } + let no_logging = Options { + logging: false, + ..self.options + }; + let caches_raw = self.eval(&["devenv.cachix"]).await?; + let cachix = serde_json::from_str(&caches_raw).expect("Failed to parse JSON"); + let known_keys = + if let Ok(known_keys) = std::fs::read_to_string(self.cachix_trusted_keys.as_path()) { + serde_json::from_str(&known_keys).expect("Failed to parse JSON") + } else { + HashMap::new() + }; - if !caches.caches.pull.is_empty() { - let store = self.run_nix("nix", &["store", "ping", "--json"], &no_logging)?; - let trusted = serde_json::from_slice::(&store.stdout) - .expect("Failed to parse JSON") - .trusted; - if trusted.is_none() { - self.logger - .warn("You're using very old version of Nix, please upgrade and restart nix-daemon."); - } - let restart_command = if cfg!(target_os = "linux") { - "sudo systemctl restart nix-daemon" - } else { - "sudo launchctl kickstart -k system/org.nixos.nix-daemon" - }; + let mut caches = CachixCaches { + caches: cachix, + known_keys, + }; + let mut new_known_keys: HashMap = HashMap::new(); + let client = reqwest::Client::new(); + for name in caches.caches.pull.iter() { + if !caches.known_keys.contains_key(name) { + let mut request = client.get(&format!("https://cachix.org/api/v1/cache/{}", name)); + if let Ok(ret) = env::var("CACHIX_AUTH_TOKEN") { + request = request.bearer_auth(ret); + } + let resp = request.send().await.expect("Failed to get cache"); + if resp.status().is_client_error() { + self.logger.error(&format!( + "Cache {} does not exist or you don't have a CACHIX_AUTH_TOKEN configured.", + name + )); self.logger - .info(&format!("Using Cachix: {}", caches.caches.pull.join(", "))); - if !new_known_keys.is_empty() { - for (name, pubkey) in new_known_keys.iter() { - self.logger.info(&format!( - "Trusting {}.cachix.org on first use with the public key {}", - name, pubkey - )); - } - caches.known_keys.extend(new_known_keys); - } - - std::fs::write( - self.cachix_trusted_keys.as_path(), - serde_json::to_string(&caches.known_keys).unwrap(), - ) - .expect("Failed to write cachix caches to file"); - - if trusted == Some(0) { - if !Path::new("/etc/NIXOS").exists() { - self.logger.error(&indoc::formatdoc!( - "You're not a trusted user of the Nix store. You have the following options: - - a) Add yourself to the trusted-users list in /etc/nix/nix.conf for devenv to manage caches for you. + .error("To create a cache, go to https://app.cachix.org/."); + bail!("Cache does not exist or you don't have a CACHIX_AUTH_TOKEN configured.") + } else { + let resp_json = + serde_json::from_slice::(&resp.bytes().await.unwrap()) + .expect("Failed to parse JSON"); + new_known_keys.insert(name.clone(), resp_json.public_signing_keys[0].clone()); + } + } + } - trusted-users = root {} + if !caches.caches.pull.is_empty() { + let store = self.run_nix("nix", &["store", "ping", "--json"], &no_logging)?; + let trusted = serde_json::from_slice::(&store.stdout) + .expect("Failed to parse JSON") + .trusted; + if trusted.is_none() { + self.logger.warn( + "You're using very old version of Nix, please upgrade and restart nix-daemon.", + ); + } + let restart_command = if cfg!(target_os = "linux") { + "sudo systemctl restart nix-daemon" + } else { + "sudo launchctl kickstart -k system/org.nixos.nix-daemon" + }; - Restart nix-daemon with: + self.logger + .info(&format!("Using Cachix: {}", caches.caches.pull.join(", "))); + if !new_known_keys.is_empty() { + for (name, pubkey) in new_known_keys.iter() { + self.logger.info(&format!( + "Trusting {}.cachix.org on first use with the public key {}", + name, pubkey + )); + } + caches.known_keys.extend(new_known_keys); + } - $ {restart_command} + std::fs::write( + self.cachix_trusted_keys.as_path(), + serde_json::to_string(&caches.known_keys).unwrap(), + ) + .expect("Failed to write cachix caches to file"); - b) Add binary caches to /etc/nix/nix.conf yourself: + if trusted == Some(0) { + if !Path::new("/etc/NIXOS").exists() { + self.logger.error(&indoc::formatdoc!( + "You're not a trusted user of the Nix store. You have the following options: - extra-substituters = {} - extra-trusted-public-keys = {} + a) Add yourself to the trusted-users list in /etc/nix/nix.conf for devenv to manage caches for you. - And disable automatic cache configuration in `devenv.nix`: + trusted-users = root {} - {{ - cachix.enable = false; - }} - ", whoami::username() - , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") - , caches.known_keys.values().cloned().collect::>().join(" ") - )); - } else { - self.logger.error(&indoc::formatdoc!( - "You're not a trusted user of the Nix store. You have the following options: + Restart nix-daemon with: - a) Add yourself to the trusted-users list in /etc/nix/nix.conf by editing configuration.nix for devenv to manage caches for you. + $ {restart_command} - {{ - nix.extraOptions = '' - trusted-users = root {} - ''; - }} + b) Add binary caches to /etc/nix/nix.conf yourself: - b) Add binary caches to /etc/nix/nix.conf yourself by editing configuration.nix: - {{ - nix.extraOptions = '' - extra-substituters = {}; - extra-trusted-public-keys = {}; - ''; - }} + extra-substituters = {} + extra-trusted-public-keys = {} - Lastly rebuild your system + And disable automatic cache configuration in `devenv.nix`: - $ sudo nixos-rebuild switch - ", whoami::username() - , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") - , caches.known_keys.values().cloned().collect::>().join(" ") - )); - } - bail!("You're not a trusted user of the Nix store.") - } + {{ + cachix.enable = false; + }} + ", whoami::username() + , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") + , caches.known_keys.values().cloned().collect::>().join(" ") + )); + } else { + self.logger.error(&indoc::formatdoc!( + "You're not a trusted user of the Nix store. You have the following options: + + a) Add yourself to the trusted-users list in /etc/nix/nix.conf by editing configuration.nix for devenv to manage caches for you. + + {{ + nix.extraOptions = '' + trusted-users = root {} + ''; + }} + + b) Add binary caches to /etc/nix/nix.conf yourself by editing configuration.nix: + {{ + nix.extraOptions = '' + extra-substituters = {}; + extra-trusted-public-keys = {}; + ''; + }} + + Lastly rebuild your system + + $ sudo nixos-rebuild switch + ", whoami::username() + , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") + , caches.known_keys.values().cloned().collect::>().join(" ") + )); } - - self.cachix_caches = Some(caches.clone()); - Ok(caches) + bail!("You're not a trusted user of the Nix store.") } } + + let mut caches_mutex = self.cachix_caches.lock().unwrap(); + *caches_mutex = Some(caches.clone()); + Ok(caches) } } diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index a5292a9c8..6dfe94275 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -11,7 +11,6 @@ use sha2::Digest; use std::collections::HashMap; use std::io::Write; use std::os::unix::{fs::PermissionsExt, process::CommandExt}; -use std::sync::Arc; use std::{ fs, path::{Path, PathBuf}, @@ -34,14 +33,14 @@ pub struct DevenvOptions { pub devenv_dotfile: Option, } -pub struct Devenv<'a> { +pub struct Devenv { pub(crate) config: config::Config, pub(crate) global_options: cli::GlobalOptions, pub(crate) logger: log::Logger, pub(crate) log_progress: log::LogProgressCreator, - nix: cnix::Nix<'a>, + nix: cnix::Nix<'static>, // All kinds of paths xdg_dirs: xdg::BaseDirectories, @@ -59,7 +58,7 @@ pub struct Devenv<'a> { pub(crate) container_name: Option, } -impl<'a> Devenv<'a> { +impl Devenv { pub fn new(options: DevenvOptions) -> Self { let xdg_dirs = xdg::BaseDirectories::with_prefix("devenv").unwrap(); let devenv_home = xdg_dirs.get_data_home(); @@ -103,33 +102,26 @@ impl<'a> Devenv<'a> { log::LogProgressCreator::Logging }; - let config = Arc::new(options.config); - let global_options = Arc::new(global_options); - let cachix_trusted_keys = Arc::new(cachix_trusted_keys); - let devenv_home_gc = Arc::new(devenv_home_gc); - let devenv_dot_gc = Arc::new(devenv_dot_gc); - let devenv_root = Arc::new(devenv_root); - let nix = cnix::Nix::new( logger.clone(), - Arc::clone(&config), - Arc::clone(&global_options), - Arc::clone(&cachix_trusted_keys), - Arc::clone(&devenv_home_gc), - Arc::clone(&devenv_dot_gc), - Arc::clone(&devenv_root), + options.config.clone(), + global_options.clone(), + cachix_trusted_keys, + devenv_home_gc.clone(), + devenv_dot_gc.clone(), + devenv_root.clone(), ); Self { - config: (*config).clone(), - global_options: (*global_options).clone(), + config: options.config, + global_options, logger, log_progress, xdg_dirs, - devenv_root: (*devenv_root).clone(), + devenv_root, devenv_dotfile, - devenv_dot_gc: (*devenv_dot_gc).clone(), - devenv_home_gc: (*devenv_home_gc).clone(), + devenv_dot_gc, + devenv_home_gc, devenv_tmp, devenv_runtime, nix, @@ -678,7 +670,6 @@ impl<'a> Devenv<'a> { .prepare_develop_args(&Some(processes_script.to_str().unwrap().to_string()), &[]) .await?; - let options = self.nix.options; let mut cmd = self .nix .prepare_command_with_substituters( @@ -687,7 +678,7 @@ impl<'a> Devenv<'a> { .iter() .map(AsRef::as_ref) .collect::>(), - &options, + &self.nix.options, ) .await?; From 5797e8d618674b7670aabff9db4e8306c8e538c8 Mon Sep 17 00:00:00 2001 From: Sander Date: Thu, 19 Sep 2024 14:42:49 +0000 Subject: [PATCH 35/45] devenv: use simpler RefCell for cachix caches --- devenv/src/cnix.rs | 157 +++++++++++++++++++++++---------------------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs index 8dffc36cd..dd8be8987 100644 --- a/devenv/src/cnix.rs +++ b/devenv/src/cnix.rs @@ -1,6 +1,7 @@ use crate::{cli, config, log}; use miette::{bail, IntoDiagnostic, Result, WrapErr}; use serde::Deserialize; +use std::cell::{Ref, RefCell}; use std::collections::HashMap; use std::env; use std::fs; @@ -8,7 +9,6 @@ use std::os::unix::fs::symlink; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process; -use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; pub struct Nix<'a> { @@ -17,7 +17,7 @@ pub struct Nix<'a> { // TODO: all these shouldn't be here config: config::Config, global_options: cli::GlobalOptions, - cachix_caches: Arc>>, + cachix_caches: RefCell>, cachix_trusted_keys: PathBuf, devenv_home_gc: PathBuf, devenv_dot_gc: PathBuf, @@ -47,7 +47,7 @@ impl<'a> Nix<'a> { let devenv_dot_gc = devenv_dot_gc.as_ref().to_path_buf(); let devenv_root = devenv_root.as_ref().to_path_buf(); - let cachix_caches = Arc::new(Mutex::new(None)); + let cachix_caches = RefCell::new(None); Self { logger, @@ -463,92 +463,92 @@ impl<'a> Nix<'a> { Ok(cmd) } - async fn get_cachix_caches(&self) -> Result { - if let Some(caches) = &*self.cachix_caches.lock().unwrap() { - return Ok(caches.clone()); - } - - let no_logging = Options { - logging: false, - ..self.options - }; - let caches_raw = self.eval(&["devenv.cachix"]).await?; - let cachix = serde_json::from_str(&caches_raw).expect("Failed to parse JSON"); - let known_keys = - if let Ok(known_keys) = std::fs::read_to_string(self.cachix_trusted_keys.as_path()) { + async fn get_cachix_caches(&self) -> Result> { + if self.cachix_caches.borrow().is_none() { + let no_logging = Options { + logging: false, + ..self.options + }; + let caches_raw = self.eval(&["devenv.cachix"]).await?; + let cachix = serde_json::from_str(&caches_raw).expect("Failed to parse JSON"); + let known_keys = if let Ok(known_keys) = + std::fs::read_to_string(self.cachix_trusted_keys.as_path()) + { serde_json::from_str(&known_keys).expect("Failed to parse JSON") } else { HashMap::new() }; - let mut caches = CachixCaches { - caches: cachix, - known_keys, - }; + let mut caches = CachixCaches { + caches: cachix, + known_keys, + }; - let mut new_known_keys: HashMap = HashMap::new(); - let client = reqwest::Client::new(); - for name in caches.caches.pull.iter() { - if !caches.known_keys.contains_key(name) { - let mut request = client.get(&format!("https://cachix.org/api/v1/cache/{}", name)); - if let Ok(ret) = env::var("CACHIX_AUTH_TOKEN") { - request = request.bearer_auth(ret); - } - let resp = request.send().await.expect("Failed to get cache"); - if resp.status().is_client_error() { - self.logger.error(&format!( + let mut new_known_keys: HashMap = HashMap::new(); + let client = reqwest::Client::new(); + for name in caches.caches.pull.iter() { + if !caches.known_keys.contains_key(name) { + let mut request = + client.get(&format!("https://cachix.org/api/v1/cache/{}", name)); + if let Ok(ret) = env::var("CACHIX_AUTH_TOKEN") { + request = request.bearer_auth(ret); + } + let resp = request.send().await.expect("Failed to get cache"); + if resp.status().is_client_error() { + self.logger.error(&format!( "Cache {} does not exist or you don't have a CACHIX_AUTH_TOKEN configured.", name )); - self.logger - .error("To create a cache, go to https://app.cachix.org/."); - bail!("Cache does not exist or you don't have a CACHIX_AUTH_TOKEN configured.") - } else { - let resp_json = - serde_json::from_slice::(&resp.bytes().await.unwrap()) - .expect("Failed to parse JSON"); - new_known_keys.insert(name.clone(), resp_json.public_signing_keys[0].clone()); + self.logger + .error("To create a cache, go to https://app.cachix.org/."); + bail!("Cache does not exist or you don't have a CACHIX_AUTH_TOKEN configured.") + } else { + let resp_json = + serde_json::from_slice::(&resp.bytes().await.unwrap()) + .expect("Failed to parse JSON"); + new_known_keys + .insert(name.clone(), resp_json.public_signing_keys[0].clone()); + } } } - } - if !caches.caches.pull.is_empty() { - let store = self.run_nix("nix", &["store", "ping", "--json"], &no_logging)?; - let trusted = serde_json::from_slice::(&store.stdout) - .expect("Failed to parse JSON") - .trusted; - if trusted.is_none() { - self.logger.warn( + if !caches.caches.pull.is_empty() { + let store = self.run_nix("nix", &["store", "ping", "--json"], &no_logging)?; + let trusted = serde_json::from_slice::(&store.stdout) + .expect("Failed to parse JSON") + .trusted; + if trusted.is_none() { + self.logger.warn( "You're using very old version of Nix, please upgrade and restart nix-daemon.", ); - } - let restart_command = if cfg!(target_os = "linux") { - "sudo systemctl restart nix-daemon" - } else { - "sudo launchctl kickstart -k system/org.nixos.nix-daemon" - }; + } + let restart_command = if cfg!(target_os = "linux") { + "sudo systemctl restart nix-daemon" + } else { + "sudo launchctl kickstart -k system/org.nixos.nix-daemon" + }; - self.logger - .info(&format!("Using Cachix: {}", caches.caches.pull.join(", "))); - if !new_known_keys.is_empty() { - for (name, pubkey) in new_known_keys.iter() { - self.logger.info(&format!( - "Trusting {}.cachix.org on first use with the public key {}", - name, pubkey - )); + self.logger + .info(&format!("Using Cachix: {}", caches.caches.pull.join(", "))); + if !new_known_keys.is_empty() { + for (name, pubkey) in new_known_keys.iter() { + self.logger.info(&format!( + "Trusting {}.cachix.org on first use with the public key {}", + name, pubkey + )); + } + caches.known_keys.extend(new_known_keys); } - caches.known_keys.extend(new_known_keys); - } - std::fs::write( - self.cachix_trusted_keys.as_path(), - serde_json::to_string(&caches.known_keys).unwrap(), - ) - .expect("Failed to write cachix caches to file"); + std::fs::write( + self.cachix_trusted_keys.as_path(), + serde_json::to_string(&caches.known_keys).unwrap(), + ) + .expect("Failed to write cachix caches to file"); - if trusted == Some(0) { - if !Path::new("/etc/NIXOS").exists() { - self.logger.error(&indoc::formatdoc!( + if trusted == Some(0) { + if !Path::new("/etc/NIXOS").exists() { + self.logger.error(&indoc::formatdoc!( "You're not a trusted user of the Nix store. You have the following options: a) Add yourself to the trusted-users list in /etc/nix/nix.conf for devenv to manage caches for you. @@ -573,8 +573,8 @@ impl<'a> Nix<'a> { , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") , caches.known_keys.values().cloned().collect::>().join(" ") )); - } else { - self.logger.error(&indoc::formatdoc!( + } else { + self.logger.error(&indoc::formatdoc!( "You're not a trusted user of the Nix store. You have the following options: a) Add yourself to the trusted-users list in /etc/nix/nix.conf by editing configuration.nix for devenv to manage caches for you. @@ -600,14 +600,17 @@ impl<'a> Nix<'a> { , caches.caches.pull.iter().map(|cache| format!("https://{}.cachix.org", cache)).collect::>().join(" ") , caches.known_keys.values().cloned().collect::>().join(" ") )); + } + bail!("You're not a trusted user of the Nix store.") } - bail!("You're not a trusted user of the Nix store.") } + + *self.cachix_caches.borrow_mut() = Some(caches); } - let mut caches_mutex = self.cachix_caches.lock().unwrap(); - *caches_mutex = Some(caches.clone()); - Ok(caches) + Ok(Ref::map(self.cachix_caches.borrow(), |option| { + option.as_ref().unwrap() + })) } } From be6867a94f8483de6fbb00bf9646c940cc2df403 Mon Sep 17 00:00:00 2001 From: Sander Date: Thu, 19 Sep 2024 17:48:06 +0000 Subject: [PATCH 36/45] devenv: fix dotfile override for `devenv test` --- devenv/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv/src/main.rs b/devenv/src/main.rs index 3ccf80e77..25d36634a 100644 --- a/devenv/src/main.rs +++ b/devenv/src/main.rs @@ -57,7 +57,7 @@ async fn main() -> Result<()> { "Overriding .devenv to {}", tmpdir.path().file_name().unwrap().to_str().unwrap() )); - options.devenv_root = Some(tmpdir.path().to_path_buf()); + options.devenv_dotfile = Some(tmpdir.path().to_path_buf()); } } From fde375980979b18def4e5c6d903cc09b324ce127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Thu, 19 Sep 2024 20:50:03 +0100 Subject: [PATCH 37/45] cli: search nixpkgs from devenv.yaml --- devenv/src/cnix.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/devenv/src/cnix.rs b/devenv/src/cnix.rs index dd8be8987..96e2132c7 100644 --- a/devenv/src/cnix.rs +++ b/devenv/src/cnix.rs @@ -210,8 +210,12 @@ impl<'a> Nix<'a> { } pub async fn search(&self, name: &str) -> Result { - self.run_nix_with_substituters("nix", &["search", "--json", "nixpkgs", name], &self.options) - .await + self.run_nix_with_substituters( + "nix", + &["search", "--inputs-from", ".", "--json", "nixpkgs", name], + &self.options, + ) + .await } pub fn gc(&self, paths: Vec) -> Result<()> { From f151daf3a821e8f6cfce0494d670bb5ef5b0b8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 23 Sep 2024 16:47:37 +0100 Subject: [PATCH 38/45] python: port glue code to tasks --- devenv/src/tasks.rs | 29 +++++++++++++---- docs/tasks.md | 4 +-- src/modules/languages/python.nix | 47 +++++++++++++++++---------- src/modules/tasks.nix | 27 ++++++++++------ tasks/src/main.rs | 55 +++++++++++++++++++++++++------- 5 files changed, 117 insertions(+), 45 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index acfd245d7..48bc119d0 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -171,9 +171,26 @@ impl TaskState { command.env("DEVENV_TASK_INPUT", serde_json::to_string(inputs).unwrap()); } - // Create a temporary file for DEVENV_TASK_OUTPUTS + // Create a temporary file for DEVENV_TASK_OUTPUT_FILE let outputs_file = tempfile::NamedTempFile::new().unwrap(); - command.env("DEVENV_TASK_OUTPUT", outputs_file.path()); + command.env("DEVENV_TASK_OUTPUT_FILE", outputs_file.path()); + + // Set environment variables from task outputs + let mut devenv_env = String::new(); + for (_, value) in outputs.iter() { + if let Some(env) = value.get("devenv").and_then(|d| d.get("env")) { + if let Some(env_obj) = env.as_object() { + for (env_key, env_value) in env_obj { + if let Some(env_str) = env_value.as_str() { + command.env(env_key, env_str); + devenv_env.push_str(&format!("export {}={}\n", env_key, env_str)); + } + } + } + } + } + // Internal for now + command.env("DEVENV_TASK_ENV", devenv_env); // Set DEVENV_TASKS_OUTPUTS let outputs_json = serde_json::to_string(outputs).unwrap(); @@ -690,9 +707,9 @@ impl TasksUi { "{}\n{} {}: {}\n", tasks_status.lines.join("\n"), if finished { - format!("Finished in {:.2?}", started.elapsed()) + format!("Tasks done in {:.2?}", started.elapsed()) } else { - format!("Running for {:.2?}", started.elapsed()) + format!("Tasks running for {:.2?}", started.elapsed()) }, names.clone(), [ @@ -1091,7 +1108,7 @@ mod test { async fn test_inputs_outputs() -> Result<(), Error> { let input_script = create_script( r#"#!/bin/sh -echo "{\"key\": \"value\"}" > $DEVENV_TASK_OUTPUT +echo "{\"key\": \"value\"}" > $DEVENV_TASK_OUTPUT_FILE if [ "$DEVENV_TASK_INPUT" != '{"test":"input"}' ]; then echo "Error: Input does not match expected value" >&2 echo "Expected: $expected" >&2 @@ -1109,7 +1126,7 @@ fi echo "Actual: $DEVENV_TASKS_OUTPUTS" >&2 exit 1 fi - echo "{\"result\": \"success\"}" > $DEVENV_TASK_OUTPUT + echo "{\"result\": \"success\"}" > $DEVENV_TASK_OUTPUT_FILE "#, )?; diff --git a/docs/tasks.md b/docs/tasks.md index 6faec992e..2d75490a6 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -88,8 +88,8 @@ If you define a `status` command, it will be executed first and if it returns `0 Tasks support passing inputs and produce outputs, both as JSON objects: - `$DEVENV_TASK_INPUT`: JSON object serializing `tasks."myapp:mytask".inputs`. -- `$DEVENV_TASK_OUTPUT`: a writable file with tasks' outputs in JSON. - `$DEVENV_TASKS_OUTPUTS`: JSON object with dependent tasks as keys and their outputs as values. +- `$DEVENV_TASK_OUTPUT_FILE`: a writable file with tasks' outputs in JSON. ```nix title="devenv.nix" { pkgs, lib, config, ... }: @@ -99,7 +99,7 @@ Tasks support passing inputs and produce outputs, both as JSON objects: "myapp:mytask" = { exec = '' echo $DEVENV_TASK_INPUTS> $DEVENV_ROOT/input.json - echo '{ "output" = 1; }' > $DEVENV_TASK_OUTPUT + echo '{ "output" = 1; }' > $DEVENV_TASK_OUTPUT_FILE echo $DEVENV_TASKS_OUTPUTS > $DEVENV_ROOT/outputs.json ''; input = { diff --git a/src/modules/languages/python.nix b/src/modules/languages/python.nix index ca8ed346c..1956a654a 100644 --- a/src/modules/languages/python.nix +++ b/src/modules/languages/python.nix @@ -43,7 +43,7 @@ let let USE_UV_SYNC = cfg.uv.sync.enable && builtins.compareVersions cfg.uv.package.version "0.4.4" >= 0; in - pkgs.writeShellScript "init-venv.sh" '' + '' pushd "${cfg.directory}" # Make sure any tools are not attempting to use the Python interpreter from any @@ -107,7 +107,7 @@ let popd ''; - initUvScript = pkgs.writeShellScript "init-uv.sh" '' + initUvScript = '' pushd "${cfg.directory}" VENV_PATH="${config.env.DEVENV_STATE}/venv" @@ -173,7 +173,7 @@ let popd ''; - initPoetryScript = pkgs.writeShellScript "init-poetry.sh" '' + initPoetryScript = '' pushd "${cfg.directory}" function _devenv_init_poetry_venv @@ -454,19 +454,32 @@ in } ]; - enterShell = lib.concatStringsSep "\n" ([ - '' - export PYTHONPATH="$DEVENV_PROFILE/${package.sitePackages}''${PYTHONPATH:+:$PYTHONPATH}" - '' - ] ++ - (lib.optional cfg.venv.enable '' - source ${initVenvScript} - '') ++ - (lib.optional cfg.poetry.install.enable '' - source ${initPoetryScript} - '') ++ - (lib.optional cfg.uv.sync.enable '' - source ${initUvScript} - '')); + tasks = { + "devenv:python:venv" = lib.mkIf cfg.venv.enable { + description = "Initialize Python virtual environment"; + exec = initVenvScript; + exports = [ "PATH" "VIRTUAL_ENV" ]; + }; + + "devenv:python:poetry" = lib.mkIf cfg.poetry.install.enable { + description = "Initialize Poetry"; + exec = initPoetryScript; + exports = [ "PATH" "VIRTUAL_ENV" ]; + }; + + "devenv:python:uv" = lib.mkIf cfg.uv.sync.enable { + description = "Initialize uv sync"; + exec = initUvScript; + exports = [ "PATH" "VIRTUAL_ENV" ]; + }; + + "devenv:enterShell".depends = lib.optional cfg.venv.enable "devenv:python:venv" + ++ lib.optional cfg.poetry.install.enable "devenv:python:poetry" + ++ lib.optional cfg.uv.sync.enable "devenv:python:uv"; + }; + + enterShell = '' + export PYTHONPATH="$DEVENV_PROFILE/${package.sitePackages}''${PYTHONPATH:+:$PYTHONPATH}" + ''; }; } diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index d8dbb0a51..da5cd22fc 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -12,6 +12,7 @@ let pkgs.writeScript name '' #!${pkgs.lib.getBin config.package}/bin/${config.binary} ${command} + ${lib.optionalString (config.exports != []) "${devenv}/bin/tasks export ${lib.concatStringsSep " " config.exports}"} ''; in { @@ -60,6 +61,11 @@ let input = config.input; }; }; + exports = lib.mkOption { + type = types.listOf types.str; + default = [ ]; + description = "List of environment variables to export."; + }; description = lib.mkOption { type = types.str; default = ""; @@ -92,6 +98,13 @@ in config = { env.DEVENV_TASKS = builtins.toJSON tasksJSON; + assertions = [ + { + assertion = lib.all (task: task.binary == "bash" || task.export == [ ]) (lib.attrValues config.tasks); + message = "The 'export' option can only be set when 'binary' is set to 'bash'."; + } + ]; + infoSections."tasks" = lib.mapAttrsToList (name: task: "${name}: ${task.description} (${if task.command == null then "no command" else task.command})") @@ -103,12 +116,8 @@ in "devenv:enterShell" = { description = "Runs when entering the shell"; exec = '' - ENTER_SHELL_COMMANDS=$(echo "''${DEVENV_TASKS_OUTPUTS:-{}}" | jq -r 'to_entries[] | select(.value.devenv.enterShell != null) | .value.devenv.enterShell' | tr '\n' ';') - eval "$ENTER_SHELL_COMMANDS" - - mkdir -p "$DEVENV_STATE" - export -p | sed 's/^declare -x /export /' > "$DEVENV_DOTFILE/load-env" - chmod +x "$DEVENV_DOTFILE/load-env" + echo "$DEVENV_TASK_ENV" > "$DEVENV_DOTFILE/load-exports" + chmod +x "$DEVENV_DOTFILE/load-exports" ''; }; "devenv:enterTest" = { @@ -116,11 +125,11 @@ in }; }; enterShell = '' - ${devenv}/bin/tasks devenv:enterShell - source "$DEVENV_DOTFILE/load-env" + ${devenv}/bin/tasks run devenv:enterShell + source "$DEVENV_DOTFILE/load-exports" ''; enterTest = '' - ${devenv}/bin/tasks devenv:enterTest + ${devenv}/bin/tasks run devenv:enterTest ''; }; } diff --git a/tasks/src/main.rs b/tasks/src/main.rs index 081ca3fd9..b334fd0e1 100644 --- a/tasks/src/main.rs +++ b/tasks/src/main.rs @@ -1,28 +1,61 @@ -use clap::Parser; +use clap::{Parser, Subcommand}; use devenv::tasks::{Config, TaskConfig, TasksUi}; use std::env; #[derive(Parser)] #[clap(author, version, about)] struct Args { - #[clap(help = "Root directories to search for tasks")] - roots: Vec, + #[clap(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Run { + #[clap()] + roots: Vec, + }, + Export { + #[clap()] + strings: Vec, + }, } #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); - let tasks_json = env::var("DEVENV_TASKS")?; - let tasks: Vec = serde_json::from_str(&tasks_json)?; + match args.command { + Command::Run { roots } => { + let tasks_json = env::var("DEVENV_TASKS")?; + let tasks: Vec = serde_json::from_str(&tasks_json)?; + + let config = Config { tasks, roots }; + + let mut tasks_ui = TasksUi::new(config).await?; + tasks_ui.run().await?; + } + Command::Export { strings } => { + let output_file = + env::var("DEVENV_TASK_OUTPUT_FILE").expect("DEVENV_TASK_OUTPUT_FILE not set"); + let mut output: serde_json::Value = std::fs::read_to_string(&output_file) + .map(|content| serde_json::from_str(&content).unwrap_or(serde_json::json!({}))) + .unwrap_or(serde_json::json!({})); + + let mut exported_vars = serde_json::Map::new(); + for var in strings { + if let Ok(value) = env::var(&var) { + exported_vars.insert(var, serde_json::Value::String(value)); + } + } - let config = Config { - tasks, - roots: args.roots, - }; + if let serde_json::Value::Object(ref mut map) = output { + map.extend(exported_vars); + } - let mut tasks_ui = TasksUi::new(config).await?; - tasks_ui.run().await?; + std::fs::write(output_file, serde_json::to_string_pretty(&output)?)?; + } + } Ok(()) } From 2dac0f5949d629a58d5ca513c3d8654b524f249a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 24 Sep 2024 11:04:28 +0100 Subject: [PATCH 39/45] tasks: improve formatting --- devenv/src/tasks.rs | 122 ++++++++++++++++++++++---------------------- docs/tasks.md | 10 ++-- 2 files changed, 67 insertions(+), 65 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 48bc119d0..c20ae65d0 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -676,18 +676,17 @@ impl TasksUi { } pub async fn run(&mut self) -> Result<(TasksStatus, Outputs), Error> { - let mut stderr = io::stderr(); let names = self.tasks.root_names.join(", ").bold(); - - let started = std::time::Instant::now(); + eprint!("{:17} {}", "Running tasks", names); // start processing tasks + let started = std::time::Instant::now(); let tasks_clone = Arc::clone(&self.tasks); let handle = tokio::spawn(async move { tasks_clone.run().await }); // start TUI - let mut last_list_height: u16 = 0; + let mut stderr = io::stderr(); loop { let mut finished = false; @@ -697,65 +696,66 @@ impl TasksUi { let tasks_status = self.get_tasks_status().await; - execute!( - stderr, - // Clear the screen from the cursor down - cursor::MoveUp(last_list_height), - Clear(ClearType::FromCursorDown), - style::PrintStyledContent( - format!( - "{}\n{} {}: {}\n", - tasks_status.lines.join("\n"), - if finished { - format!("Tasks done in {:.2?}", started.elapsed()) + let output = style::PrintStyledContent( + format!( + "{}\n{} {:>width$}\n", + tasks_status.lines.join("\n"), + [ + if tasks_status.pending > 0 { + format!("{} {}", tasks_status.pending, "Pending".blue().bold()) } else { - format!("Tasks running for {:.2?}", started.elapsed()) + String::new() }, - names.clone(), - [ - if tasks_status.pending > 0 { - format!("{} {}", tasks_status.pending, "Pending".blue().bold()) - } else { - String::new() - }, - if tasks_status.running > 0 { - format!("{} {}", tasks_status.running, "Running".blue().bold()) - } else { - String::new() - }, - if tasks_status.skipped > 0 { - format!("{} {}", tasks_status.skipped, "Skipped".blue().bold()) - } else { - String::new() - }, - if tasks_status.succeeded > 0 { - format!("{} {}", tasks_status.succeeded, "Succeeded".green().bold()) - } else { - String::new() - }, - if tasks_status.failed > 0 { - format!("{} {}", tasks_status.failed, "Failed".red().bold()) - } else { - String::new() - }, - if tasks_status.dependency_failed > 0 { - format!( - "{} {}", - tasks_status.dependency_failed, - "Dependency Failed".red().bold() - ) - } else { - String::new() - }, - ] - .into_iter() - .filter(|s| !s.is_empty()) - .collect::>() - .join(", ") - ) - .stylize() - ), - )?; + if tasks_status.running > 0 { + format!("{} {}", tasks_status.running, "Running".blue().bold()) + } else { + String::new() + }, + if tasks_status.skipped > 0 { + format!("{} {}", tasks_status.skipped, "Skipped".blue().bold()) + } else { + String::new() + }, + if tasks_status.succeeded > 0 { + format!("{} {}", tasks_status.succeeded, "Succeeded".green().bold()) + } else { + String::new() + }, + if tasks_status.failed > 0 { + format!("{} {}", tasks_status.failed, "Failed".red().bold()) + } else { + String::new() + }, + if tasks_status.dependency_failed > 0 { + format!( + "{} {}", + tasks_status.dependency_failed, + "Dependency Failed".red().bold() + ) + } else { + String::new() + }, + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(", "), + format!("{:.2?}", started.elapsed()), + width = self.tasks.longest_task_name + 15 + ) + .stylize(), + ); + + if last_list_height > 0 { + execute!( + stderr, + cursor::MoveUp(last_list_height), + Clear(ClearType::FromCursorDown), + output + )?; + } else { + execute!(stderr, output)?; + } if finished { break; diff --git a/docs/tasks.md b/docs/tasks.md index 2d75490a6..e12a0295c 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -18,8 +18,9 @@ Tasks allow you to form dependencies between code, executed in parallel. ```shell-session $ devenv tasks run myapp:hello +Running tasks myapp:hello Succeeded myapp:hello 9ms -Finished in 50.14ms myapp:hello: 1 Succeeded +1 Succeeded 50.14ms ``` ## enterShell / enterTest @@ -41,15 +42,16 @@ If you'd like the tasks to run as part of the `enterShell` or `enterTest`: ```shell-session $ devenv shell ... +Running tasks devenv:enterShell Succeeded devenv:pre-commit:install 25ms Succeeded bash:hello 9ms Succeeded devenv:enterShell 23ms -Finished in 103.14ms devenv:enterShell: 3 Succeeded +3 Succeeded 103.14ms ``` ## Using your favourite language -Tasks can also reference scripts and depend on other tasks, for example when entering the shell: +Tasks can also use another package for execution, for example when entering the shell: ```nix title="devenv.nix" { pkgs, lib, config, ... }: @@ -87,7 +89,7 @@ If you define a `status` command, it will be executed first and if it returns `0 Tasks support passing inputs and produce outputs, both as JSON objects: -- `$DEVENV_TASK_INPUT`: JSON object serializing `tasks."myapp:mytask".inputs`. +- `$DEVENV_TASK_INPUT`: JSON object of `tasks."myapp:mytask".input`. - `$DEVENV_TASKS_OUTPUTS`: JSON object with dependent tasks as keys and their outputs as values. - `$DEVENV_TASK_OUTPUT_FILE`: a writable file with tasks' outputs in JSON. From 4fc8c007002c05c2ef241bde169989916aad4954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 24 Sep 2024 11:29:32 +0100 Subject: [PATCH 40/45] tasks: rename depends to after --- devenv/init/devenv.nix | 2 +- devenv/src/tasks.rs | 16 ++++++++-------- docs/overrides/home.html | 5 +++-- docs/tasks.md | 4 ++-- src/modules/integrations/pre-commit.nix | 4 ++-- src/modules/languages/python.nix | 2 +- src/modules/tasks.nix | 4 ++-- tests/tasks/devenv.nix | 4 ++-- 8 files changed, 21 insertions(+), 20 deletions(-) diff --git a/devenv/init/devenv.nix b/devenv/init/devenv.nix index 4220a2db5..884e6047b 100644 --- a/devenv/init/devenv.nix +++ b/devenv/init/devenv.nix @@ -29,7 +29,7 @@ # https://devenv.sh/tasks/ # tasks = { # "myproj:setup".exec = "mytool build"; - # "devenv:enterShell".depends = ["myproj:setup"]; + # "devenv:enterShell".after = [ "myproj:setup" ]; # }; # https://devenv.sh/tests/ diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index c20ae65d0..7f62614a9 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -69,7 +69,7 @@ impl Display for Error { pub struct TaskConfig { name: String, #[serde(default)] - depends: Vec, + after: Vec, #[serde(default)] command: Option, #[serde(default)] @@ -419,7 +419,7 @@ impl Tasks { for index in self.graph.node_indices() { let task_state = &self.graph[index].read().await; - for dep_name in &task_state.task.depends { + for dep_name in &task_state.task.after { if let Some(dep_idx) = task_indices.get(dep_name) { edges_to_add.push((*dep_idx, index)); } else { @@ -890,12 +890,12 @@ mod test { }, { "name": "myapp:task_3", - "depends": ["myapp:task_1"], + "after": ["myapp:task_1"], "command": script3.to_str().unwrap() }, { "name": "myapp:task_4", - "depends": ["myapp:task_3"], + "after": ["myapp:task_3"], "command": script4.to_str().unwrap() } ] @@ -926,12 +926,12 @@ mod test { "tasks": [ { "name": "myapp:task_1", - "depends": ["myapp:task_2"], + "after": ["myapp:task_2"], "command": "echo 'Task 1 is running' && echo 'Task 1 completed'" }, { "name": "myapp:task_2", - "depends": ["myapp:task_1"], + "after": ["myapp:task_1"], "command": "echo 'Task 2 is running' && echo 'Task 2 completed'" } ] @@ -1077,7 +1077,7 @@ mod test { }, { "name": "myapp:task_2", - "depends": ["myapp:task_1"], + "after": ["myapp:task_1"], "command": dependent_script.to_str().unwrap() } ] @@ -1142,7 +1142,7 @@ fi { "name": "myapp:task_2", "command": output_script.to_str().unwrap(), - "depends": ["myapp:task_1"] + "after": ["myapp:task_1"] } ] })) diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 526f961ab..3cbde9ca8 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -161,7 +161,7 @@

Fast, Decla tasks = { "myapp:build".exec = "build"; - "devenv:enterShell".depends = ["myapp:build"]; + "devenv:enterShell".after = [ "myapp:build" ]; }; # Runs on `git commit` and `devenv test` @@ -182,10 +182,11 @@

Fast, Decla
devenv shell
 ...
+Running tasks     devenv:enterShell
 Succeeded         devenv:pre-commit:install 15ms
 Succeeded         myapp:build               23ms
 Succeeded         devenv:enterShell         23ms
-Finished in 50.14ms devenv:enterShell: 3 Succeeded
+3 Succeeded                                 50.14ms
 $
 
 
diff --git a/docs/tasks.md b/docs/tasks.md index e12a0295c..bbb5e1e60 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -33,8 +33,8 @@ If you'd like the tasks to run as part of the `enterShell` or `enterTest`: { tasks = { "bash:hello".exec = "echo 'Hello world from bash!'"; - "devenv:enterShell".depends = [ "bash:hello" ]; - "devenv:enterTest".depends = [ "bash:hello" ]; + "devenv:enterShell".after = [ "bash:hello" ]; + "devenv:enterTest".after = [ "bash:hello" ]; }; } ``` diff --git a/src/modules/integrations/pre-commit.nix b/src/modules/integrations/pre-commit.nix index 90397841a..bfec2b34d 100644 --- a/src/modules/integrations/pre-commit.nix +++ b/src/modules/integrations/pre-commit.nix @@ -26,8 +26,8 @@ # TODO: split installation script into status + exec "devenv:pre-commit:install".exec = config.pre-commit.installationScript; "devenv:pre-commit:run".exec = "pre-commit run -a"; - "devenv:enterShell".depends = [ "devenv:pre-commit:install" ]; - "devenv:enterTest".depends = [ "devenv:pre-commit:run" ]; + "devenv:enterShell".after = [ "devenv:pre-commit:install" ]; + "devenv:enterTest".after = [ "devenv:pre-commit:run" ]; }; }; } diff --git a/src/modules/languages/python.nix b/src/modules/languages/python.nix index 1956a654a..20dbb34e4 100644 --- a/src/modules/languages/python.nix +++ b/src/modules/languages/python.nix @@ -473,7 +473,7 @@ in exports = [ "PATH" "VIRTUAL_ENV" ]; }; - "devenv:enterShell".depends = lib.optional cfg.venv.enable "devenv:python:venv" + "devenv:enterShell".after = lib.optional cfg.venv.enable "devenv:python:venv" ++ lib.optional cfg.poetry.install.enable "devenv:python:poetry" ++ lib.optional cfg.uv.sync.enable "devenv:python:uv"; }; diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index da5cd22fc..7da3ae217 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -56,7 +56,7 @@ let name = name; description = config.description; status = config.statusCommand; - depends = config.depends; + after = config.after; command = config.command; input = config.input; }; @@ -71,7 +71,7 @@ let default = ""; description = "Description of the task."; }; - depends = lib.mkOption { + after = lib.mkOption { type = types.listOf types.str; description = "List of tasks to run before this task."; default = [ ]; diff --git a/tests/tasks/devenv.nix b/tests/tasks/devenv.nix index e4df981af..6674c8cba 100644 --- a/tests/tasks/devenv.nix +++ b/tests/tasks/devenv.nix @@ -1,9 +1,9 @@ { tasks = { "myapp:shell".exec = "touch shell"; - "devenv:enterShell".depends = [ "myapp:shell" ]; + "devenv:enterShell".after = [ "myapp:shell" ]; "myapp:test".exec = "touch test"; - "devenv:enterTest".depends = [ "myapp:test" ]; + "devenv:enterTest".after = [ "myapp:test" ]; }; enterTest = '' From c192c8091d55ea369023cc408187dac5b2c89a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 24 Sep 2024 11:44:53 +0100 Subject: [PATCH 41/45] tasks: fix exporting --- tasks/src/main.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tasks/src/main.rs b/tasks/src/main.rs index b334fd0e1..4d70776e5 100644 --- a/tasks/src/main.rs +++ b/tasks/src/main.rs @@ -49,10 +49,21 @@ async fn main() -> Result<(), Box> { } } - if let serde_json::Value::Object(ref mut map) = output { - map.extend(exported_vars); + if !output.as_object().unwrap().contains_key("devenv") { + output["devenv"] = serde_json::json!({}); } - + if !output["devenv"].as_object().unwrap().contains_key("env") { + output["devenv"]["env"] = serde_json::json!({}); + } + output["devenv"]["env"] = serde_json::Value::Object( + output["devenv"]["env"] + .as_object() + .cloned() + .unwrap_or_default() + .into_iter() + .chain(exported_vars) + .collect(), + ); std::fs::write(output_file, serde_json::to_string_pretty(&output)?)?; } } From 10fcc8d87e46337ca8012a467d5b2420d1975067 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:52:51 +0000 Subject: [PATCH 42/45] Auto generate docs/reference/options.md --- docs/reference/options.md | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/reference/options.md b/docs/reference/options.md index 8505f5166..a31dac9f2 100644 --- a/docs/reference/options.md +++ b/docs/reference/options.md @@ -41521,42 +41521,42 @@ package -## tasks.\.binary +## tasks.\.after -Override the binary name if it doesn’t match package name +List of tasks to run before this task. *Type:* -string +list of string *Default:* -` "bash" ` +` [ ] ` *Declared by:* - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) -## tasks.\.depends +## tasks.\.binary -List of tasks to run before this task. +Override the binary name if it doesn’t match package name *Type:* -list of string +string *Default:* -` [ ] ` +` "bash" ` *Declared by:* - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) @@ -41605,6 +41605,27 @@ null or string +## tasks.\.exports + + + +List of environment variables to export. + + + +*Type:* +list of string + + + +*Default:* +` [ ] ` + +*Declared by:* + - [https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix](https://github.com/cachix/devenv/blob/main/src/modules/tasks.nix) + + + ## tasks.\.input From 10eba31a4bc63a23b41d21c74c149c01f9eb2464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 24 Sep 2024 12:14:43 +0100 Subject: [PATCH 43/45] garden --- src/modules/languages/python.nix | 4 ++-- src/modules/tasks.nix | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/modules/languages/python.nix b/src/modules/languages/python.nix index 20dbb34e4..9cfb18551 100644 --- a/src/modules/languages/python.nix +++ b/src/modules/languages/python.nix @@ -455,7 +455,7 @@ in ]; tasks = { - "devenv:python:venv" = lib.mkIf cfg.venv.enable { + "devenv:python:virtualenv" = lib.mkIf cfg.venv.enable { description = "Initialize Python virtual environment"; exec = initVenvScript; exports = [ "PATH" "VIRTUAL_ENV" ]; @@ -473,7 +473,7 @@ in exports = [ "PATH" "VIRTUAL_ENV" ]; }; - "devenv:enterShell".after = lib.optional cfg.venv.enable "devenv:python:venv" + "devenv:enterShell".after = lib.optional cfg.venv.enable "devenv:python:virtualenv" ++ lib.optional cfg.poetry.install.enable "devenv:python:poetry" ++ lib.optional cfg.uv.sync.enable "devenv:python:uv"; }; diff --git a/src/modules/tasks.nix b/src/modules/tasks.nix index 7da3ae217..7b1401d71 100644 --- a/src/modules/tasks.nix +++ b/src/modules/tasks.nix @@ -126,7 +126,9 @@ in }; enterShell = '' ${devenv}/bin/tasks run devenv:enterShell - source "$DEVENV_DOTFILE/load-exports" + if [ -f "$DEVENV_DOTFILE/load-exports" ]; then + source "$DEVENV_DOTFILE/load-exports" + fi ''; enterTest = '' ${devenv}/bin/tasks run devenv:enterTest From 488078019170ba9076ba8bc4acbd2e00cc2bbb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 24 Sep 2024 12:25:12 +0100 Subject: [PATCH 44/45] tasks: for formatting --- devenv/src/tasks.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devenv/src/tasks.rs b/devenv/src/tasks.rs index 7f62614a9..eb3bbb76c 100644 --- a/devenv/src/tasks.rs +++ b/devenv/src/tasks.rs @@ -698,7 +698,7 @@ impl TasksUi { let output = style::PrintStyledContent( format!( - "{}\n{} {:>width$}\n", + "{}\n{:width$} {}\n", tasks_status.lines.join("\n"), [ if tasks_status.pending > 0 { @@ -741,7 +741,7 @@ impl TasksUi { .collect::>() .join(", "), format!("{:.2?}", started.elapsed()), - width = self.tasks.longest_task_name + 15 + width = self.tasks.longest_task_name + 36 ) .stylize(), ); From 256291043d4d8d7656d047e4252c37f4c4246a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 24 Sep 2024 12:52:39 +0100 Subject: [PATCH 45/45] blog: 1.2 tasks --- docs/assets/images/tasks.gif | Bin 0 -> 250445 bytes docs/blog/posts/devenv-v1.2-tasks.md | 95 +++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/assets/images/tasks.gif create mode 100644 docs/blog/posts/devenv-v1.2-tasks.md diff --git a/docs/assets/images/tasks.gif b/docs/assets/images/tasks.gif new file mode 100644 index 0000000000000000000000000000000000000000..801add92bb853fa882cce284f75ad32c0e8be795 GIT binary patch literal 250445 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 gt&1+Uo6;dSjci5 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 + +We've designed [Task Server Protocol](https://github.com/cachix/devenv/issues/1457) so that you can write tasks +using your existing automation by providing an executable that exposes the tasks to devenv: +
+ +```nix title="devenv.nix" +{ pkgs, ... }: +let + myexecutable = pkgs.rustPlatform.buildRustPackage rec { + pname = "foo-bar"; + version = "0.1"; + cargoLock.lockFile = ./myexecutable/Cargo.lock; + src = pkgs.lib.cleanSource ./myexecutable; + } +in { + task.serverProtocol = [ "${myexecutable}/bin/myexecutable" ]; +} +``` + +In a few weeks we're planning to provide [Rust TSP SDK](https://github.com/cachix/devenv/issues/1457) +with a **full test suite** so you can implement your own abstraction in your language of choice. +
+ +You can now use your **preferred language for automation**, running tasks with a simple `devenv tasks run ` command. This +**flexibility** allows for more **intuitive and maintainable scripts**, tailored to your team's familiarity. + +For devenv itself, we'll slowly **transition from bash to Rust for +internal glue code**, enhancing performance and reliability. This change will make devenv more +**robust and easier to extend**, ultimately providing you with a **smoother development experience**. + +## Upgrading + +If you run `devenv update` on your existing repository you should already be using tasks, +without needing to upgrade to devenv 1.2. + +Domen