git.delta.rocks / jrsonnet / refs/commits / 1470de8a447c

difftreelog

feat minimal rollback support

Lach2025-06-18parent: #11412de.patch.diff
in: trunk

20 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -83,10 +83,10 @@
  "i18n-embed",
  "i18n-embed-fl",
  "lazy_static",
- "nom",
+ "nom 7.1.3",
  "num-traits",
  "pin-project",
- "rand",
+ "rand 0.8.5",
  "rsa",
  "rust-embed",
  "scrypt",
@@ -107,8 +107,8 @@
  "cookie-factory",
  "hkdf",
  "io_tee",
- "nom",
- "rand",
+ "nom 7.1.3",
+ "rand 0.8.5",
  "secrecy",
  "sha2",
 ]
@@ -233,18 +233,18 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
 name = "async-trait"
-version = "0.1.83"
+version = "0.1.88"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
+checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -395,15 +395,15 @@
  "regex",
  "rustc-hash",
  "shlex",
- "syn 2.0.87",
+ "syn",
  "which",
 ]
 
 [[package]]
 name = "bitflags"
-version = "2.6.0"
+version = "2.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
 dependencies = [
  "serde",
 ]
@@ -493,7 +493,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
 dependencies = [
- "nom",
+ "nom 7.1.3",
 ]
 
 [[package]]
@@ -534,9 +534,9 @@
 
 [[package]]
 name = "chrono"
-version = "0.4.38"
+version = "0.4.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
 dependencies = [
  "android-tzdata",
  "iana-time-zone",
@@ -544,7 +544,7 @@
  "num-traits",
  "serde",
  "wasm-bindgen",
- "windows-targets",
+ "windows-link",
 ]
 
 [[package]]
@@ -609,10 +609,10 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab"
 dependencies = [
- "heck 0.5.0",
+ "heck",
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -647,6 +647,15 @@
 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
 
 [[package]]
+name = "convert_case"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
 name = "cookie-factory"
 version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -684,16 +693,18 @@
 
 [[package]]
 name = "crossterm"
-version = "0.28.1"
+version = "0.29.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
 dependencies = [
  "bitflags",
  "crossterm_winapi",
+ "derive_more",
+ "document-features",
  "filedescriptor",
  "mio",
  "parking_lot",
- "rustix",
+ "rustix 1.0.7",
  "signal-hook",
  "signal-hook-mio",
  "winapi",
@@ -715,7 +726,7 @@
 checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
 dependencies = [
  "generic-array",
- "rand_core",
+ "rand_core 0.6.4",
  "typenum",
 ]
 
@@ -752,7 +763,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -781,15 +792,36 @@
 
 [[package]]
 name = "deranged"
-version = "0.3.11"
+version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
 dependencies = [
  "powerfmt",
  "serde",
 ]
 
 [[package]]
+name = "derive_more"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
 name = "digest"
 version = "0.10.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -809,10 +841,19 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
+name = "document-features"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
 name = "ed25519"
 version = "2.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -830,7 +871,6 @@
 dependencies = [
  "curve25519-dalek",
  "ed25519",
- "rand_core",
  "serde",
  "sha2",
  "subtle",
@@ -857,12 +897,12 @@
 
 [[package]]
 name = "errno"
-version = "0.3.9"
+version = "0.3.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
+checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
@@ -924,10 +964,10 @@
  "hostname",
  "human-repr",
  "indicatif",
- "itertools 0.13.0",
+ "itertools 0.14.0",
  "nix-eval",
  "nixlike",
- "nom",
+ "nom 8.0.0",
  "openssh",
  "owo-colors",
  "peg",
@@ -958,15 +998,17 @@
  "futures",
  "hostname",
  "indoc",
- "itertools 0.13.0",
+ "itertools 0.14.0",
  "nix-eval",
  "nixlike",
- "nom",
+ "nom 8.0.0",
  "openssh",
- "rand",
+ "rand 0.9.1",
  "serde",
  "serde_json",
+ "tabled",
  "tempfile",
+ "time",
  "tokio",
  "tokio-util",
  "tracing",
@@ -983,7 +1025,7 @@
  "ed25519-dalek",
  "fleet-shared",
  "hex",
- "rand",
+ "rand 0.9.1",
  "x25519-dalek",
 ]
 
@@ -1119,7 +1161,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -1170,7 +1212,19 @@
 dependencies = [
  "cfg-if",
  "libc",
- "wasi",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.2+wasi-0.2.4",
 ]
 
 [[package]]
@@ -1237,12 +1291,6 @@
 version = "0.15.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3"
-
-[[package]]
-name = "heck"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
 
 [[package]]
 name = "heck"
@@ -1297,13 +1345,13 @@
 
 [[package]]
 name = "hostname"
-version = "0.4.0"
+version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba"
+checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65"
 dependencies = [
  "cfg-if",
  "libc",
- "windows",
+ "windows-link",
 ]
 
 [[package]]
@@ -1463,7 +1511,7 @@
  "proc-macro2",
  "quote",
  "strsim",
- "syn 2.0.87",
+ "syn",
  "unic-langid",
 ]
 
@@ -1477,7 +1525,7 @@
  "i18n-config",
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -1620,6 +1668,15 @@
 ]
 
 [[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
 name = "itoa"
 version = "1.0.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1651,9 +1708,9 @@
 
 [[package]]
 name = "libc"
-version = "0.2.164"
+version = "0.2.174"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f"
+checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
 
 [[package]]
 name = "libloading"
@@ -1694,6 +1751,18 @@
 checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
 
 [[package]]
+name = "linux-raw-sys"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+
+[[package]]
+name = "litrs"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
+
+[[package]]
 name = "lock_api"
 version = "0.4.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1778,7 +1847,7 @@
  "hermit-abi 0.3.9",
  "libc",
  "log",
- "wasi",
+ "wasi 0.11.0+wasi-snapshot-preview1",
  "windows-sys 0.52.0",
 ]
 
@@ -1790,9 +1859,9 @@
 
 [[package]]
 name = "nix"
-version = "0.29.0"
+version = "0.30.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
 dependencies = [
  "bitflags",
  "cfg-if",
@@ -1807,13 +1876,13 @@
  "anyhow",
  "better-command",
  "futures",
- "itertools 0.13.0",
+ "itertools 0.14.0",
  "nixlike",
  "r2d2",
  "regex",
  "serde",
  "serde_json",
- "thiserror 2.0.3",
+ "thiserror 2.0.12",
  "tokio",
  "tokio-util",
  "tracing",
@@ -1839,7 +1908,7 @@
  "serde",
  "serde-transcode",
  "serde_json",
- "thiserror 2.0.3",
+ "thiserror 2.0.12",
 ]
 
 [[package]]
@@ -1872,6 +1941,15 @@
 ]
 
 [[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "nu-ansi-term"
 version = "0.46.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1893,7 +1971,7 @@
  "num-integer",
  "num-iter",
  "num-traits",
- "rand",
+ "rand 0.8.5",
  "smallvec",
  "zeroize",
 ]
@@ -1963,15 +2041,15 @@
 
 [[package]]
 name = "openssh"
-version = "0.11.3"
+version = "0.11.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b52987a10526b8daef7f1946b0aadfc214479f897ba624776327fd3beec2722c"
+checksum = "ea0bb128ba90e86bc55dae66031935f361cda4cbc1f011547c55a7d80079bc3e"
 dependencies = [
  "libc",
  "once_cell",
  "shell-escape",
  "tempfile",
- "thiserror 2.0.3",
+ "thiserror 2.0.12",
  "tokio",
 ]
 
@@ -1983,9 +2061,9 @@
 
 [[package]]
 name = "owo-colors"
-version = "4.1.0"
+version = "4.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56"
+checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec"
 dependencies = [
  "supports-color 2.1.0",
  "supports-color 3.0.1",
@@ -1993,13 +2071,13 @@
 
 [[package]]
 name = "papergrid"
-version = "0.12.0"
+version = "0.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7419ad52a7de9b60d33e11085a0fe3df1fbd5926aa3f93d3dd53afbc9e86725"
+checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1"
 dependencies = [
  "bytecount",
  "fnv",
- "unicode-width 0.1.11",
+ "unicode-width 0.2.0",
 ]
 
 [[package]]
@@ -2043,9 +2121,9 @@
 
 [[package]]
 name = "peg"
-version = "0.8.4"
+version = "0.8.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "295283b02df346d1ef66052a757869b2876ac29a6bb0ac3f5f7cd44aebe40e8f"
+checksum = "9928cfca101b36ec5163e70049ee5368a8a1c3c6efc9ca9c5f9cc2f816152477"
 dependencies = [
  "peg-macros",
  "peg-runtime",
@@ -2053,9 +2131,9 @@
 
 [[package]]
 name = "peg-macros"
-version = "0.8.4"
+version = "0.8.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bdad6a1d9cf116a059582ce415d5f5566aabcd4008646779dab7fdc2a9a9d426"
+checksum = "6298ab04c202fa5b5d52ba03269fb7b74550b150323038878fe6c372d8280f71"
 dependencies = [
  "peg-runtime",
  "proc-macro2",
@@ -2064,9 +2142,9 @@
 
 [[package]]
 name = "peg-runtime"
-version = "0.8.3"
+version = "0.8.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3aeb8f54c078314c2065ee649a7241f46b9d8e418e1a9581ba0546657d7aa3a"
+checksum = "132dca9b868d927b35b5dd728167b2dee150eb1ad686008fc71ccb298b776fca"
 
 [[package]]
 name = "pem"
@@ -2111,7 +2189,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -2204,31 +2282,7 @@
 checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033"
 dependencies = [
  "proc-macro2",
- "syn 2.0.87",
-]
-
-[[package]]
-name = "proc-macro-error"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
-dependencies = [
- "proc-macro-error-attr",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
- "version_check",
-]
-
-[[package]]
-name = "proc-macro-error-attr"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
-dependencies = [
- "proc-macro2",
- "quote",
- "version_check",
+ "syn",
 ]
 
 [[package]]
@@ -2250,7 +2304,7 @@
  "proc-macro-error-attr2",
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -2279,7 +2333,7 @@
 checksum = "0c1318b19085f08681016926435853bbf7858f9c082d0999b80550ff5d9abe15"
 dependencies = [
  "bytes",
- "heck 0.5.0",
+ "heck",
  "itertools 0.13.0",
  "log",
  "multimap",
@@ -2289,7 +2343,7 @@
  "prost",
  "prost-types",
  "regex",
- "syn 2.0.87",
+ "syn",
  "tempfile",
 ]
 
@@ -2303,7 +2357,7 @@
  "itertools 0.13.0",
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -2325,6 +2379,12 @@
 ]
 
 [[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
 name = "r2d2"
 version = "0.8.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2342,18 +2402,38 @@
 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
 dependencies = [
  "libc",
- "rand_chacha",
- "rand_core",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
 ]
 
 [[package]]
+name = "rand"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.3",
+]
+
+[[package]]
 name = "rand_chacha"
 version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
 dependencies = [
  "ppv-lite86",
- "rand_core",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.3",
 ]
 
 [[package]]
@@ -2362,10 +2442,19 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
 dependencies = [
- "getrandom",
+ "getrandom 0.2.15",
 ]
 
 [[package]]
+name = "rand_core"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+dependencies = [
+ "getrandom 0.3.3",
+]
+
+[[package]]
 name = "rcgen"
 version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2439,7 +2528,7 @@
 dependencies = [
  "cc",
  "cfg-if",
- "getrandom",
+ "getrandom 0.2.15",
  "libc",
  "spin",
  "untrusted",
@@ -2481,14 +2570,15 @@
 
 [[package]]
 name = "ron"
-version = "0.8.1"
+version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
+checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f"
 dependencies = [
- "base64 0.21.7",
+ "base64 0.22.1",
  "bitflags",
  "serde",
  "serde_derive",
+ "unicode-ident",
 ]
 
 [[package]]
@@ -2517,7 +2607,7 @@
  "num-traits",
  "pkcs1",
  "pkcs8",
- "rand_core",
+ "rand_core 0.6.4",
  "signature",
  "spki",
  "subtle",
@@ -2544,7 +2634,7 @@
  "proc-macro2",
  "quote",
  "rust-embed-utils",
- "syn 2.0.87",
+ "syn",
  "walkdir",
 ]
 
@@ -2588,11 +2678,24 @@
  "bitflags",
  "errno",
  "libc",
- "linux-raw-sys",
+ "linux-raw-sys 0.4.14",
  "windows-sys 0.52.0",
 ]
 
 [[package]]
+name = "rustix"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.9.4",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
 name = "rustls"
 version = "0.23.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2721,9 +2824,9 @@
 
 [[package]]
 name = "serde"
-version = "1.0.215"
+version = "1.0.219"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
 dependencies = [
  "serde_derive",
 ]
@@ -2748,20 +2851,20 @@
 
 [[package]]
 name = "serde_derive"
-version = "1.0.215"
+version = "1.0.219"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
 name = "serde_json"
-version = "1.0.133"
+version = "1.0.140"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
+checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
 dependencies = [
  "itoa",
  "memchr",
@@ -2838,7 +2941,7 @@
 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
 dependencies = [
  "digest",
- "rand_core",
+ "rand_core 0.6.4",
 ]
 
 [[package]]
@@ -2920,17 +3023,6 @@
 checksum = "8775305acf21c96926c900ad056abeef436701108518cf890020387236ac5a77"
 dependencies = [
  "is_ci",
-]
-
-[[package]]
-name = "syn"
-version = "1.0.109"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
 ]
 
 [[package]]
@@ -2958,37 +3050,38 @@
 
 [[package]]
 name = "tabled"
-version = "0.16.0"
+version = "0.20.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77c9303ee60b9bedf722012ea29ae3711ba13a67c9b9ae28993838b63057cb1b"
+checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d"
 dependencies = [
  "papergrid",
  "tabled_derive",
+ "testing_table",
 ]
 
 [[package]]
 name = "tabled_derive"
-version = "0.8.0"
+version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf0fb8bfdc709786c154e24a66777493fb63ae97e3036d914c8666774c477069"
+checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846"
 dependencies = [
- "heck 0.4.1",
- "proc-macro-error",
+ "heck",
+ "proc-macro-error2",
  "proc-macro2",
  "quote",
- "syn 1.0.109",
+ "syn",
 ]
 
 [[package]]
 name = "tempfile"
-version = "3.14.0"
+version = "3.20.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
 dependencies = [
- "cfg-if",
  "fastrand",
+ "getrandom 0.3.3",
  "once_cell",
- "rustix",
+ "rustix 1.0.7",
  "windows-sys 0.59.0",
 ]
 
@@ -2998,7 +3091,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef"
 dependencies = [
- "rustix",
+ "rustix 0.38.40",
  "windows-sys 0.59.0",
 ]
 
@@ -3014,6 +3107,15 @@
 ]
 
 [[package]]
+name = "testing_table"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc"
+dependencies = [
+ "unicode-width 0.2.0",
+]
+
+[[package]]
 name = "text-size"
 version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3058,11 +3160,11 @@
 
 [[package]]
 name = "thiserror"
-version = "2.0.3"
+version = "2.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
+checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
 dependencies = [
- "thiserror-impl 2.0.3",
+ "thiserror-impl 2.0.12",
 ]
 
 [[package]]
@@ -3073,18 +3175,18 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.3"
+version = "2.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
+checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -3099,9 +3201,9 @@
 
 [[package]]
 name = "time"
-version = "0.3.36"
+version = "0.3.41"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
+checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
 dependencies = [
  "deranged",
  "num-conv",
@@ -3113,15 +3215,15 @@
 
 [[package]]
 name = "time-core"
-version = "0.1.2"
+version = "0.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
 
 [[package]]
 name = "time-macros"
-version = "0.2.18"
+version = "0.2.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
+checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
 dependencies = [
  "num-conv",
  "time-core",
@@ -3138,9 +3240,9 @@
 
 [[package]]
 name = "tokio"
-version = "1.41.1"
+version = "1.45.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33"
+checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
 dependencies = [
  "backtrace",
  "bytes",
@@ -3155,13 +3257,13 @@
 
 [[package]]
 name = "tokio-macros"
-version = "2.4.0"
+version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
+checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -3189,9 +3291,9 @@
 
 [[package]]
 name = "tokio-util"
-version = "0.7.12"
+version = "0.7.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a"
+checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
 dependencies = [
  "bytes",
  "futures-core",
@@ -3252,7 +3354,7 @@
  "prost-build",
  "prost-types",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -3266,7 +3368,7 @@
  "indexmap 1.9.3",
  "pin-project",
  "pin-project-lite",
- "rand",
+ "rand 0.8.5",
  "slab",
  "tokio",
  "tokio-util",
@@ -3337,7 +3439,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -3457,6 +3559,12 @@
 checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
 
 [[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
 name = "unicode-width"
 version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3476,9 +3584,9 @@
 
 [[package]]
 name = "unindent"
-version = "0.2.3"
+version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce"
+checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
 
 [[package]]
 name = "universal-hash"
@@ -3573,6 +3681,15 @@
 checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
 
 [[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
+[[package]]
 name = "wasm-bindgen"
 version = "0.2.95"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3594,7 +3711,7 @@
  "once_cell",
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
  "wasm-bindgen-shared",
 ]
 
@@ -3616,7 +3733,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
  "wasm-bindgen-backend",
  "wasm-bindgen-shared",
 ]
@@ -3646,7 +3763,7 @@
  "either",
  "home",
  "once_cell",
- "rustix",
+ "rustix 0.38.40",
 ]
 
 [[package]]
@@ -3681,23 +3798,19 @@
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
 [[package]]
-name = "windows"
+name = "windows-core"
 version = "0.52.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
 dependencies = [
- "windows-core",
  "windows-targets",
 ]
 
 [[package]]
-name = "windows-core"
-version = "0.52.0"
+name = "windows-link"
+version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
-dependencies = [
- "windows-targets",
-]
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
 
 [[package]]
 name = "windows-sys"
@@ -3782,13 +3895,22 @@
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
 [[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
 name = "x25519-dalek"
 version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
 dependencies = [
  "curve25519-dalek",
- "rand_core",
+ "rand_core 0.6.4",
  "serde",
  "zeroize",
 ]
@@ -3804,9 +3926,9 @@
 
 [[package]]
 name = "z85"
-version = "3.0.5"
+version = "3.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a599daf1b507819c1121f0bf87fa37eb19daac6aff3aefefd4e6e2e0f2020fc"
+checksum = "9b3a41ce106832b4da1c065baa4c31cf640cf965fa1483816402b7f6b96f0a64"
 
 [[package]]
 name = "zerocopy"
@@ -3826,7 +3948,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
 
 [[package]]
@@ -3846,5 +3968,5 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.87",
+ "syn",
 ]
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,12 +15,12 @@
 anyhow = "1.0"
 clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
 clap_complete = "4.5"
-nix = { version = "0.29.0", features = ["fs", "user"] }
+nix = { version = "0.30.1", features = ["fs", "user"] }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
-tempfile = "3.10"
-thiserror = "2.0.3"
-tokio = { version = "1.36.0", features = ["fs", "macros", "rt", "rt-multi-thread", "sync", "time"] }
-tokio-util = { version = "0.7.11", features = ["codec"] }
+tempfile = "3.20"
+thiserror = "2.0.12"
+tokio = { version = "1.45.1", features = ["fs", "macros", "rt", "rt-multi-thread", "sync", "time"] }
+tokio-util = { version = "0.7.15", features = ["codec"] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -5,6 +5,7 @@
 authors = ["Yaroslav Bolyukin <iam@lach.pw>"]
 edition.workspace = true
 rust-version.workspace = true
+default-run = "fleet"
 
 [dependencies]
 age = { workspace = true, features = ["armor"] }
@@ -27,23 +28,23 @@
 async-trait = "0.1"
 base64 = "0.22.1"
 chrono = { version = "0.4", features = ["serde"] }
-crossterm = { version = "0.28.0", features = ["use-dev-tty"] }
+crossterm = { version = "0.29.0", features = ["use-dev-tty"] }
 futures = "0.3"
-hostname = "0.4.0"
-itertools = "0.13"
+hostname = "0.4.1"
+itertools = "0.14"
 openssh = "0.11"
-owo-colors = { version = "4.0", features = ["supports-color", "supports-colors"] }
+owo-colors = { version = "4.2", features = ["supports-color", "supports-colors"] }
 peg = "0.8"
-regex = "1.10"
+regex = "1.11"
 shlex = "1.3"
-tabled = { version = "0.16" }
+tabled = { version = "0.20" }
 time = { version = "0.3", features = ["serde"] }
 tokio-util = { version = "0.7", features = ["codec"] }
 
 fleet-base = { version = "0.1.0", path = "../../crates/fleet-base" }
 human-repr = { version = "1.1", optional = true }
 indicatif = { version = "0.17", optional = true }
-nom = "7.1.3"
+nom = "8.0.0"
 tracing-indicatif = { version = "0.3", optional = true }
 
 [features]
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -1,14 +1,14 @@
-use std::{env::current_dir, os::unix::fs::symlink, path::PathBuf, time::Duration};
+use std::{env::current_dir, os::unix::fs::symlink, path::PathBuf};
 
-use anyhow::{anyhow, bail, Context, Result};
-use clap::{Parser, ValueEnum};
+use anyhow::{anyhow, Result};
+use clap::Parser;
 use fleet_base::{
-	host::{Config, ConfigHost, DeployKind},
+	deploy::{deploy_task, upload_task, DeployAction},
+	host::{Config, DeployKind, GenerationStorage},
 	opts::FleetOpts,
 };
-use itertools::Itertools as _;
 use nix_eval::{nix_go, NixBuildBatch};
-use tokio::{task::LocalSet, time::sleep};
+use tokio::task::LocalSet;
 use tracing::{error, field, info, info_span, warn, Instrument};
 
 #[derive(Parser)]
@@ -18,300 +18,16 @@
 	disable_rollback: bool,
 	/// Action to execute after system is built
 	action: DeployAction,
-}
-
-#[derive(ValueEnum, Clone, Copy)]
-enum DeployAction {
-	/// Upload derivation, but do not execute the update.
-	Upload,
-	/// Upload and execute the activation script, old version will be used after reboot.
-	Test,
-	/// Upload and set as current system profile, but do not execute activation script.
-	Boot,
-	/// Upload, set current profile, and execute activation script.
-	Switch,
 }
 
-impl DeployAction {
-	pub(crate) fn name(&self) -> Option<&'static str> {
-		match self {
-			Self::Upload => None,
-			Self::Test => Some("test"),
-			Self::Boot => Some("boot"),
-			Self::Switch => Some("switch"),
-		}
-	}
-	pub(crate) fn should_switch_profile(&self) -> bool {
-		matches!(self, Self::Switch | Self::Boot)
-	}
-	pub(crate) fn should_activate(&self) -> bool {
-		matches!(self, Self::Switch | Self::Test | Self::Boot)
-	}
-	pub(crate) fn should_create_rollback_marker(&self) -> bool {
-		// Upload does nothing on the target machine, other than uploading the closure.
-		// In boot case we want to have rollback marker prepared, so that the system may rollback itself on the next boot.
-		!matches!(self, Self::Upload)
-	}
-	pub(crate) fn should_schedule_rollback_run(&self) -> bool {
-		matches!(self, Self::Switch | Self::Test)
-	}
-}
-
 #[derive(Parser, Clone)]
 pub struct BuildSystems {
 	/// Attribute to build. Systems are deployed from "toplevel" attr, well-known used attributes
 	/// are "sdImage"/"isoImage", and your configuration may include any other build attributes.
 	#[clap(long, default_value = "toplevel")]
 	build_attr: String,
-}
-
-struct Generation {
-	id: u32,
-	current: bool,
-	datetime: String,
-}
-
-fn parse_generation_line(g: &str) -> Option<Generation> {
-	let mut parts = g.split_whitespace();
-	let id = parts.next()?;
-	let id: u32 = id.parse().ok()?;
-	let date = parts.next()?;
-	let time = parts.next()?;
-	let current = if let Some(current) = parts.next() {
-		if current == "(current)" {
-			Some(true)
-		} else {
-			None
-		}
-	} else {
-		Some(false)
-	};
-	let current = current?;
-	if parts.next().is_some() {
-		warn!("unexpected text after generation: {g}");
-	}
-	Some(Generation {
-		id,
-		current,
-		datetime: format!("{date} {time}"),
-	})
-}
-
-async fn get_current_generation(host: &ConfigHost) -> Result<Generation> {
-	let mut cmd = host.cmd("nix-env").await?;
-	cmd.comparg("--profile", "/nix/var/nix/profiles/system")
-		.arg("--list-generations");
-	// Sudo is required due to --list-generations acquiring lock on the profile.
-	let data = cmd.sudo().run_string().await?;
-	let generations = data
-		.split('\n')
-		.map(|e| e.trim())
-		.filter(|&l| !l.is_empty())
-		.filter_map(|g| {
-			let gen = parse_generation_line(g);
-			if gen.is_none() {
-				warn!("bad generation: {g}");
-			}
-			gen
-		})
-		.collect::<Vec<_>>();
-	let current = generations
-		.into_iter()
-		.filter(|g| g.current)
-		.at_most_one()
-		.map_err(|_e| anyhow!("bad list-generations output"))?
-		.ok_or_else(|| anyhow!("failed to find generation"))?;
-	Ok(current)
 }
-
-async fn deploy_task(
-	action: DeployAction,
-	host: &ConfigHost,
-	built: PathBuf,
-	specialisation: Option<String>,
-	disable_rollback: bool,
-) -> Result<()> {
-	let deploy_kind = host.deploy_kind().await?;
-	if (deploy_kind == DeployKind::NixosInstall || deploy_kind == DeployKind::NixosLustrate)
-		&& !matches!(action, DeployAction::Boot | DeployAction::Upload)
-	{
-		bail!("{deploy_kind:?} deploy kind only supports boot and upload actions");
-	}
-
-	let mut failed = false;
 
-	// TODO: Lockfile, to prevent concurrent system switch?
-	// TODO: If rollback target exists - bail, it should be removed. Lockfile will not work in case if rollback
-	// is scheduler on next boot (default behavior). On current boot - rollback activator will fail due to
-	// unit name conflict in systemd-run
-	// This code is tied to rollback.nix
-	if !disable_rollback && action.should_create_rollback_marker() {
-		let _span = info_span!("preparing").entered();
-		info!("preparing for rollback");
-		let generation = get_current_generation(host).await?;
-		info!(
-			"rollback target would be {} {}",
-			generation.id, generation.datetime
-		);
-		{
-			let mut cmd = host.cmd("sh").await?;
-			cmd.arg("-c").arg(format!("mark=$(mktemp -p /etc -t fleet_rollback_marker.XXXXX) && echo -n {} > $mark && mv --no-clobber $mark /etc/fleet_rollback_marker", generation.id));
-			if let Err(e) = cmd.sudo().run().await {
-				error!("failed to set rollback marker: {e}");
-				failed = true;
-			}
-		}
-		// Activation script also starts rollback-watchdog.timer, however, it is possible that it won't be started.
-		// Kicking it on manually will work best.
-		//
-		// There wouldn't be conflict, because here we trigger start of the primary service, and systemd will
-		// only allow one instance of it.
-
-		// TODO: We should also watch how this process is going.
-		// After running this command, we have less than 3 minutes to deploy everything,
-		// if we fail to perform generation switch in time, then we will still call the activation script, and this may break something.
-		// Anyway, reboot will still help in this case.
-		if action.should_schedule_rollback_run() {
-			let mut cmd = host.cmd("systemd-run").await?;
-			cmd.comparg("--on-active", "3min")
-				.comparg("--unit", "rollback-watchdog-run")
-				.arg("systemctl")
-				.arg("start")
-				.arg("rollback-watchdog.service");
-			if let Err(e) = cmd.sudo().run().await {
-				error!("failed to schedule rollback run: {e}");
-				failed = true;
-			}
-		}
-	}
-	if deploy_kind == DeployKind::NixosLustrate {
-		// Fleet could also create this file, but as this operation is potentially disruptive,
-		// make user do it themself.
-		if !host.file_exists("/etc/NIXOS_LUSTRATE").await? {
-			bail!("/etc/NIXOS_LUSTRATE should be created on remote host");
-		}
-		// Wanted by NixOS to recognize the system as NixOS.
-		let mut cmd = host.cmd("touch").await?;
-		cmd.arg("/etc/NIXOS");
-		cmd.sudo().run().await.context("creating /etc/NIXOS")?;
-	}
-	if deploy_kind == DeployKind::NixosInstall {
-		info!(
-			"running nixos-install to switch profile, install bootloader, and perform activation"
-		);
-		let mut cmd = host.cmd("nixos-install").await?;
-		cmd.arg("--system").arg(&built).args([
-			// Channels here aren't fleet host system channels, but channels embedded in installation cd, which might be old.
-			// It is possible to copy host channels, but I would prefer non-flake nix just to be unsupported.
-			"--no-channel-copy",
-			"--root",
-			"/mnt",
-		]);
-		if let Err(e) = cmd.sudo().run().await {
-			error!("failed to execute nixos-install: {e}");
-			failed = true;
-		}
-	} else {
-		if action.should_switch_profile() && !failed {
-			info!("switching system profile generation");
-
-			// To avoid even more problems, using nixos-install for now.
-			// // nix build is unable to work with --store argument for some reason, and nix until 2.26 didn't support copy with --profile argument,
-			// // falling back to using nix-env command
-			// // After stable NixOS starts using 2.26 - use `nix --store /mnt copy --from /mnt --profile ...` here, and instead of nix build below.
-			// let mut cmd = host.cmd("nix-env").await?;
-			// cmd.args([
-			// 	"--store",
-			// 	"/mnt",
-			// 	"--profile",
-			// 	"/mnt/nix/var/nix/profiles/system",
-			// 	"--set",
-			// ])
-			// .arg(&built);
-			// if let Err(e) = cmd.sudo().run_nix().await {
-			// 	error!("failed to switch system profile generation: {e}");
-			// 	failed = true;
-			// }
-			// It would also be possible to update profile atomically during copy:
-			// https://github.com/NixOS/nix/pull/11657
-			let mut cmd = host.nix_cmd().await?;
-			cmd.arg("build");
-			cmd.comparg("--profile", "/nix/var/nix/profiles/system");
-			cmd.arg(&built);
-			if let Err(e) = cmd.sudo().run_nix().await {
-				error!("failed to switch system profile generation: {e}");
-				failed = true;
-			}
-		}
-
-		// FIXME: Connection might be disconnected after activation run
-
-		if action.should_activate() && !failed {
-			let _span = info_span!("activating").entered();
-			info!("executing activation script");
-			let specialised = if let Some(specialisation) = specialisation {
-				let mut specialised = built.join("specialisation");
-				specialised.push(specialisation);
-				specialised
-			} else {
-				built.clone()
-			};
-			let switch_script = specialised.join("bin/switch-to-configuration");
-			let mut cmd = host.cmd(switch_script).in_current_span().await?;
-			if deploy_kind == DeployKind::NixosLustrate {
-				cmd.env("NIXOS_INSTALL_BOOTLOADER", "1");
-			}
-			cmd.env("FLEET_ONLINE_ACTIVATION", "1")
-				.arg(action.name().expect("upload.should_activate == false"));
-			if let Err(e) = cmd.sudo().run().in_current_span().await {
-				error!("failed to activate: {e}");
-				failed = true;
-			}
-		}
-	}
-	if action.should_create_rollback_marker() {
-		if !disable_rollback {
-			if failed {
-				if action.should_schedule_rollback_run() {
-					info!("executing rollback");
-					if let Err(e) = host
-						.systemctl_start("rollback-watchdog.service")
-						.instrument(info_span!("rollback"))
-						.await
-					{
-						error!("failed to trigger rollback: {e}")
-					}
-				}
-			} else {
-				info!("trying to mark upgrade as successful");
-				if let Err(e) = host
-					.rm_file("/etc/fleet_rollback_marker", true)
-					.in_current_span()
-					.await
-				{
-					error!("failed to remove rollback marker. This is bad, as the system will be rolled back by watchdog: {e}")
-				}
-			}
-			info!("disarming watchdog, just in case");
-			if let Err(_e) = host.systemctl_stop("rollback-watchdog.timer").await {
-				// It is ok, if there was no reboot - then timer might not be running.
-			}
-			if action.should_schedule_rollback_run() {
-				if let Err(e) = host.systemctl_stop("rollback-watchdog-run.timer").await {
-					error!("failed to disarm rollback run: {e}");
-				}
-			}
-		} else if let Err(_e) = host
-			.rm_file("/etc/fleet_rollback_marker", true)
-			.in_current_span()
-			.await
-		{
-			// Marker might not exist, yet better try to remove it.
-		}
-	}
-	Ok(())
-}
-
 async fn build_task(
 	config: Config,
 	hostname: String,
@@ -328,7 +44,8 @@
 		.get("out")
 		.ok_or_else(|| anyhow!("system build should produce \"out\" output"))?;
 
-	{
+	// We already have system profiles for backups.
+	if !host.local {
 		info!("adding gc root");
 		let mut cmd = config.local_host().cmd("nix").await?;
 		cmd.arg("build")
@@ -403,7 +120,6 @@
 			let config = config.clone();
 			let span = info_span!("deploy", host = field::display(&host.name));
 			let hostname = host.name.clone();
-			let local_host = config.local_host();
 			let opts = opts.clone();
 			let batch = batch.clone();
 			if let Some(deploy_kind) = opts.action_attr::<DeployKind>(&host, "deploy_kind").await? {
@@ -437,51 +153,20 @@
 						disable_rollback = true;
 					}
 
-					if !opts.is_local(&hostname) {
-						info!("uploading system closure");
+					let remote_path =
+						match upload_task(&config, &host, GenerationStorage::Deployer, built).await
 						{
-							// TODO: Move to remote_derivation method.
-							// Alternatively, nix store make-content-addressed can be used,
-							// at least for the first deployment, to provide trusted store key.
-							//
-							// It is much slower, yet doesn't require root on the deployer machine.
-							let Ok(mut sign) = local_host.cmd("nix").await else {
-								error!("failed to setup local");
+							Ok(v) => v,
+							Err(e) => {
+								error!("upload failed: {e}");
 								return;
-							};
-							// Private key for host machine is registered in nix-sign.nix
-							sign.arg("store")
-								.arg("sign")
-								.comparg("--key-file", "/etc/nix/private-key")
-								.arg("-r")
-								.arg(&built);
-							if let Err(e) = sign.sudo().run_nix().await {
-								warn!("failed to sign store paths: {e}");
-							};
-						}
-						let mut tries = 0;
-						loop {
-							match host.remote_derivation(&built).await {
-								Ok(remote) => {
-									assert!(remote == built, "CA derivations aren't implemented");
-									break;
-								}
-								Err(e) if tries < 3 => {
-									tries += 1;
-									warn!("copy failure ({}/3): {}", tries, e);
-									sleep(Duration::from_millis(5000)).await;
-								}
-								Err(e) => {
-									error!("upload failed: {e}");
-									return;
-								}
 							}
-						}
-					}
+						};
+
 					if let Err(e) = deploy_task(
 						self.action,
 						&host,
-						built,
+						remote_path,
 						if let Ok(v) = opts.action_attr(&host, "specialisation").await {
 							v
 						} else {
modifiedcmds/fleet/src/cmds/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/mod.rs
+++ b/cmds/fleet/src/cmds/mod.rs
@@ -3,3 +3,4 @@
 pub mod info;
 pub mod secrets;
 pub mod tf;
+pub mod rollback;
\ No newline at end of file
addedcmds/fleet/src/cmds/rollback.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/fleet/src/cmds/rollback.rs
@@ -0,0 +1,127 @@
+use std::collections::HashSet;
+
+use anyhow::{bail, Result};
+use clap::Parser;
+use fleet_base::{
+	deploy::{deploy_task, upload_task, DeployAction},
+	host::{Config, ConfigHost, Generation, GenerationStorage},
+	opts::FleetOpts,
+};
+use tabled::Table;
+use tracing::{info, warn};
+
+#[derive(Parser)]
+pub struct RollbackSingle {
+	machine: String,
+	#[clap(subcommand)]
+	action: RollbackAction,
+}
+
+#[derive(Parser, Clone)]
+struct DeployOptions {
+	/// Rollback target to use
+	id: String,
+	/// Rollback to the current generation if rollback fails
+	// Automatic rollback seems to be unnecessary for manual rollback...
+	#[clap(long)]
+	enable_rollback: bool,
+	/// Specialization to use
+	#[clap(long)]
+	specialization: Option<String>,
+}
+
+#[derive(Parser, Clone)]
+enum RollbackAction {
+	/// List available rollback targets
+	ListTargets,
+	/// Upload and execute the activation script, old version will be used after reboot.
+	Test(#[clap(flatten)] DeployOptions),
+	/// Upload, set current profile, and execute activation script.
+	Switch(#[clap(flatten)] DeployOptions),
+	/// Upload and set as current system profile, but do not execute activation script.
+	Boot(#[clap(flatten)] DeployOptions),
+}
+
+pub async fn list_all_generations(host: &ConfigHost, config: &Config) -> Vec<Generation> {
+	let stored_on_machine = host
+		.list_generations("system")
+		.await
+		.inspect_err(|e| {
+			warn!("failed to list generations available on the remote machine: {e}");
+		})
+		.unwrap_or_default();
+	let on_machine_store_paths = stored_on_machine
+		.iter()
+		.map(|g| &g.store_path)
+		.collect::<HashSet<_>>();
+	let mut stored_locally = config
+		.local_host()
+		.list_generations(&format!("{}-{}", config.data().gc_root_prefix, host.name))
+		.await
+		.inspect_err(|e| {
+			warn!("failed to list generations available locally: {e}");
+		})
+		.unwrap_or_default();
+	dbg!(&stored_locally);
+	stored_locally.retain(|g| !on_machine_store_paths.contains(&g.store_path));
+	for ele in stored_locally.iter_mut() {
+		ele.current = false;
+		ele.location = GenerationStorage::Deployer;
+	}
+	stored_locally.extend(stored_on_machine);
+	stored_locally.sort_by_key(|v| v.datetime);
+	stored_locally
+}
+
+impl RollbackSingle {
+	pub(crate) async fn run(&self, config: &Config, _opts: &FleetOpts) -> Result<()> {
+		let host = config.host(&self.machine).await?;
+		match &self.action {
+			RollbackAction::ListTargets => {
+				let generations = list_all_generations(&host, config).await;
+				if generations.is_empty() {
+					bail!("no available rollback targets found");
+				}
+				info!("Generation list:\n{}", Table::new(&generations));
+				Ok(())
+			}
+			RollbackAction::Boot(o) | RollbackAction::Test(o) | RollbackAction::Switch(o) => {
+				let DeployOptions {
+					id,
+					enable_rollback,
+					specialization,
+				} = o;
+				let action: DeployAction = match self.action {
+					RollbackAction::Test { .. } => DeployAction::Test,
+					RollbackAction::Switch { .. } => DeployAction::Switch,
+					RollbackAction::Boot { .. } => DeployAction::Boot,
+					_ => unreachable!(),
+				};
+				let generations = list_all_generations(&host, config).await;
+				let Some(generation) = generations.iter().find(|g| &g.rollback_id() == id) else {
+					bail!(
+						"generation by this name is not found, existing generations:\n{}",
+						Table::new(&generations)
+					);
+				};
+				let remote_path = upload_task(
+					config,
+					&host,
+					generation.location,
+					generation.store_path.clone(),
+				)
+				.await?;
+
+				deploy_task(
+					action,
+					&host,
+					remote_path,
+					specialization.clone(),
+					!*enable_rollback,
+				)
+				.await?;
+				Ok(())
+			}
+		}
+	}
+}
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -10,6 +10,7 @@
 use clap::{CommandFactory, Parser};
 use cmds::{
 	build_systems::{BuildSystems, Deploy},
+	rollback::RollbackSingle,
 	complete::Complete,
 	info::Info,
 	secrets::Secret,
@@ -70,6 +71,8 @@
 	BuildSystems(BuildSystems),
 	/// Upload and switch system closures
 	Deploy(Deploy),
+	/// Rollback remote machine by redeploying old generation as the new one
+	RollbackSingle(RollbackSingle),
 	/// Secret management
 	#[clap(subcommand)]
 	Secret(Secret),
@@ -97,6 +100,7 @@
 	match command {
 		Opts::BuildSystems(c) => c.run(config, &opts).await?,
 		Opts::Deploy(d) => d.run(config, &opts).await?,
+		Opts::RollbackSingle(r) => r.run(config, &opts).await?,
 		Opts::Secret(s) => s.run(config, &opts).await?,
 		Opts::Info(i) => i.run(config).await?,
 		Opts::Prefetch(p) => p.run(config).await?,
modifiedcmds/generator-helper/Cargo.tomldiffbeforeafterboth
--- a/cmds/generator-helper/Cargo.toml
+++ b/cmds/generator-helper/Cargo.toml
@@ -11,7 +11,7 @@
 fleet-shared.workspace = true
 
 base64 = "0.22.1"
-ed25519-dalek = { version = "2.1", features = ["rand_core"] }
+ed25519-dalek = { version = "2.1" }
 hex = "0.4.3"
-rand = "0.8.5"
-x25519-dalek = "2.0.1"
+rand = "0.9.1"
+x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
modifiedcmds/generator-helper/src/main.rsdiffbeforeafterboth
--- a/cmds/generator-helper/src/main.rs
+++ b/cmds/generator-helper/src/main.rs
@@ -11,10 +11,11 @@
 };
 use anyhow::{anyhow, bail, ensure, Context, Result};
 use clap::{Parser, ValueEnum};
+use ed25519_dalek::SecretKey;
 use fleet_shared::SecretData;
 use rand::{
-	distributions::{Alphanumeric, DistString, Distribution, Uniform},
-	thread_rng, RngCore,
+	distr::{Alphanumeric, Distribution, SampleString, Uniform},
+	rng, RngCore,
 };
 
 fn write_output_file(out: &str) -> Result<File> {
@@ -224,7 +225,7 @@
 fn main() -> Result<()> {
 	let opts = Opts::parse();
 	// Assumed to be secure, seeded from secure OsRng+reseeded.
-	let mut rng = thread_rng();
+	let mut rng = rng();
 
 	match opts {
 		Opts::Public { output, encoding } => {
@@ -245,7 +246,10 @@
 					use ed25519_dalek::SigningKey;
 
 					let recipients = load_identities()?;
-					let key = SigningKey::generate(&mut rng).to_keypair_bytes();
+					let mut secret = SecretKey::default();
+					rng.fill_bytes(&mut secret);
+					// TODO: Use SigningKey::generate after https://github.com/dalek-cryptography/curve25519-dalek/pull/762
+					let key = SigningKey::from_bytes(&secret).to_keypair_bytes();
 					write_public(&public, &key[32..], encoding)?;
 					write_private(
 						&recipients,
@@ -268,7 +272,8 @@
 					use x25519_dalek::{PublicKey, StaticSecret};
 
 					let recipients = load_identities()?;
-					let key = StaticSecret::random_from_rng(rng);
+					// TODO: Use random_from_rng after https://github.com/dalek-cryptography/curve25519-dalek/pull/762
+					let key = StaticSecret::random();
 					let public_key: PublicKey = (&key).into();
 					write_public(&public, public_key.as_bytes().as_slice(), encoding)?;
 					write_private(&recipients, &private, key.as_bytes().as_slice(), encoding)?;
@@ -289,7 +294,8 @@
 					} else {
 						// Alphabet of Alphanumberic + symbols
 						const GEN_ASCII_SYMBOLS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
-						let uniform = Uniform::new(0, GEN_ASCII_SYMBOLS.len());
+						let uniform =
+							Uniform::new(0, GEN_ASCII_SYMBOLS.len()).expect("range is valid");
 						(0..size)
 							.map(|_| uniform.sample(&mut rng))
 							.map(|i| GEN_ASCII_SYMBOLS[i] as char)
@@ -310,7 +316,9 @@
 					let recipients = load_identities()?;
 					let mut bytes = vec![0u8; count];
 					if no_nuls {
-						let rand = Uniform::new_inclusive(0x1u8, 0xffu8).sample_iter(&mut rng);
+						let rand = Uniform::new_inclusive(0x1u8, 0xffu8)
+							.expect("range is valid")
+							.sample_iter(&mut rng);
 						for (byte, rand) in bytes.iter_mut().zip(rand) {
 							*byte = rand;
 						}
modifiedcmds/terraform-provider-fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/terraform-provider-fleet/Cargo.toml
+++ b/cmds/terraform-provider-fleet/Cargo.toml
@@ -9,5 +9,5 @@
 serde = { workspace = true, features = ["derive"] }
 tokio.workspace = true
 
-async-trait = "0.1.81"
+async-trait = "0.1.88"
 tf-provider = "0.2.2"
modifiedcrates/better-command/Cargo.tomldiffbeforeafterboth
--- a/crates/better-command/Cargo.toml
+++ b/crates/better-command/Cargo.toml
@@ -5,7 +5,7 @@
 rust-version.workspace = true
 
 [dependencies]
-regex = "1.10"
+regex = "1.11"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 tracing = "0.1"
modifiedcrates/fleet-base/Cargo.tomldiffbeforeafterboth
--- a/crates/fleet-base/Cargo.toml
+++ b/crates/fleet-base/Cargo.toml
@@ -8,21 +8,23 @@
 age.workspace = true
 anyhow.workspace = true
 better-command.workspace = true
-chrono = "0.4.38"
+chrono = "0.4.41"
 clap = { workspace = true, features = ["derive"] }
 fleet-shared.workspace = true
-futures = "0.3.30"
-hostname = "0.4.0"
+futures = "0.3.31"
+hostname = "0.4.1"
 indoc = "2.0.6"
-itertools = "0.13.0"
+itertools = "0.14.0"
 nix-eval.workspace = true
 nixlike.workspace = true
-nom = "7.1.3"
-openssh = "0.11.0"
-rand = "0.8.5"
+nom = "8.0.0"
+openssh = "0.11.5"
+rand = "0.9.1"
 serde.workspace = true
-serde_json = "1.0.127"
+serde_json = "1.0.140"
+tabled = "0.20.0"
 tempfile.workspace = true
+time = { version = "0.3.41", features = ["parsing"] }
 tokio.workspace = true
-tokio-util = "0.7.11"
+tokio-util = "0.7.15"
 tracing.workspace = true
addedcrates/fleet-base/src/deploy.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/deploy.rs
@@ -0,0 +1,297 @@
+use std::{path::PathBuf, time::Duration};
+
+use anyhow::{anyhow, bail, Context as _, Result};
+use clap::ValueEnum;
+use itertools::Itertools;
+use tokio::time::sleep;
+use tracing::{error, info, info_span, warn, Instrument as _};
+
+use crate::host::{Config, ConfigHost, DeployKind, Generation, GenerationStorage};
+
+#[derive(ValueEnum, Clone, Copy)]
+pub enum DeployAction {
+	/// Upload derivation, but do not execute the update.
+	Upload,
+	/// Upload and execute the activation script, old version will be used after reboot.
+	Test,
+	/// Upload and set as current system profile, but do not execute activation script.
+	Boot,
+	/// Upload, set current profile, and execute activation script.
+	Switch,
+}
+
+impl DeployAction {
+	pub(crate) fn name(&self) -> Option<&'static str> {
+		match self {
+			Self::Upload => None,
+			Self::Test => Some("test"),
+			Self::Boot => Some("boot"),
+			Self::Switch => Some("switch"),
+		}
+	}
+	pub(crate) fn should_switch_profile(&self) -> bool {
+		matches!(self, Self::Switch | Self::Boot)
+	}
+	pub(crate) fn should_activate(&self) -> bool {
+		matches!(self, Self::Switch | Self::Test | Self::Boot)
+	}
+	pub(crate) fn should_create_rollback_marker(&self) -> bool {
+		// Upload does nothing on the target machine, other than uploading the closure.
+		// In boot case we want to have rollback marker prepared, so that the system may rollback itself on the next boot.
+		!matches!(self, Self::Upload)
+	}
+	pub(crate) fn should_schedule_rollback_run(&self) -> bool {
+		matches!(self, Self::Switch | Self::Test)
+	}
+}
+
+async fn get_current_generation(host: &ConfigHost) -> Result<Generation> {
+	let generations = host.list_generations("system").await?;
+	let current = generations
+		.into_iter()
+		.filter(|g| g.current)
+		.at_most_one()
+		.map_err(|_e| anyhow!("bad list-generations output"))?
+		.ok_or_else(|| anyhow!("failed to find generation"))?;
+	Ok(current)
+}
+
+pub async fn deploy_task(
+	action: DeployAction,
+	host: &ConfigHost,
+	built: PathBuf,
+	specialisation: Option<String>,
+	disable_rollback: bool,
+) -> Result<()> {
+	let deploy_kind = host.deploy_kind().await?;
+	if (deploy_kind == DeployKind::NixosInstall || deploy_kind == DeployKind::NixosLustrate)
+		&& !matches!(action, DeployAction::Boot | DeployAction::Upload)
+	{
+		bail!("{deploy_kind:?} deploy kind only supports boot and upload actions");
+	}
+
+	let mut failed = false;
+
+	// TODO: Lockfile, to prevent concurrent system switch?
+	// TODO: If rollback target exists - bail, it should be removed. Lockfile will not work in case if rollback
+	// is scheduler on next boot (default behavior). On current boot - rollback activator will fail due to
+	// unit name conflict in systemd-run
+	// This code is tied to rollback.nix
+	if !disable_rollback && action.should_create_rollback_marker() {
+		let _span = info_span!("preparing").entered();
+		info!("preparing for rollback");
+		let generation = get_current_generation(host).await?;
+		info!(
+			"rollback target would be {} {}",
+			generation.id, generation.datetime
+		);
+		{
+			let mut cmd = host.cmd("sh").await?;
+			cmd.arg("-c").arg(format!("mark=$(mktemp -p /etc -t fleet_rollback_marker.XXXXX) && echo -n {} > $mark && mv --no-clobber $mark /etc/fleet_rollback_marker", generation.id));
+			if let Err(e) = cmd.sudo().run().await {
+				error!("failed to set rollback marker: {e}");
+				failed = true;
+			}
+		}
+		// Activation script also starts rollback-watchdog.timer, however, it is possible that it won't be started.
+		// Kicking it on manually will work best.
+		//
+		// There wouldn't be conflict, because here we trigger start of the primary service, and systemd will
+		// only allow one instance of it.
+
+		// TODO: We should also watch how this process is going.
+		// After running this command, we have less than 3 minutes to deploy everything,
+		// if we fail to perform generation switch in time, then we will still call the activation script, and this may break something.
+		// Anyway, reboot will still help in this case.
+		if action.should_schedule_rollback_run() {
+			let mut cmd = host.cmd("systemd-run").await?;
+			cmd.comparg("--on-active", "3min")
+				.comparg("--unit", "rollback-watchdog-run")
+				.arg("systemctl")
+				.arg("start")
+				.arg("rollback-watchdog.service");
+			if let Err(e) = cmd.sudo().run().await {
+				error!("failed to schedule rollback run: {e}");
+				failed = true;
+			}
+		}
+	}
+	if deploy_kind == DeployKind::NixosLustrate {
+		// Fleet could also create this file, but as this operation is potentially disruptive,
+		// make user do it themself.
+		if !host.file_exists("/etc/NIXOS_LUSTRATE").await? {
+			bail!("/etc/NIXOS_LUSTRATE should be created on remote host");
+		}
+		// Wanted by NixOS to recognize the system as NixOS.
+		let mut cmd = host.cmd("touch").await?;
+		cmd.arg("/etc/NIXOS");
+		cmd.sudo().run().await.context("creating /etc/NIXOS")?;
+	}
+	if deploy_kind == DeployKind::NixosInstall {
+		info!(
+			"running nixos-install to switch profile, install bootloader, and perform activation"
+		);
+		let mut cmd = host.cmd("nixos-install").await?;
+		cmd.arg("--system").arg(&built).args([
+			// Channels here aren't fleet host system channels, but channels embedded in installation cd, which might be old.
+			// It is possible to copy host channels, but I would prefer non-flake nix just to be unsupported.
+			"--no-channel-copy",
+			"--root",
+			"/mnt",
+		]);
+		if let Err(e) = cmd.sudo().run().await {
+			error!("failed to execute nixos-install: {e}");
+			failed = true;
+		}
+	} else {
+		if action.should_switch_profile() && !failed {
+			info!("switching system profile generation");
+
+			// To avoid even more problems, using nixos-install for now.
+			// // nix build is unable to work with --store argument for some reason, and nix until 2.26 didn't support copy with --profile argument,
+			// // falling back to using nix-env command
+			// // After stable NixOS starts using 2.26 - use `nix --store /mnt copy --from /mnt --profile ...` here, and instead of nix build below.
+			// let mut cmd = host.cmd("nix-env").await?;
+			// cmd.args([
+			// 	"--store",
+			// 	"/mnt",
+			// 	"--profile",
+			// 	"/mnt/nix/var/nix/profiles/system",
+			// 	"--set",
+			// ])
+			// .arg(&built);
+			// if let Err(e) = cmd.sudo().run_nix().await {
+			// 	error!("failed to switch system profile generation: {e}");
+			// 	failed = true;
+			// }
+			// It would also be possible to update profile atomically during copy:
+			// https://github.com/NixOS/nix/pull/11657
+			let mut cmd = host.nix_cmd().await?;
+			cmd.arg("build");
+			cmd.comparg("--profile", "/nix/var/nix/profiles/system");
+			cmd.arg(&built);
+			if let Err(e) = cmd.sudo().run_nix().await {
+				error!("failed to switch system profile generation: {e}");
+				failed = true;
+			}
+		}
+
+		// FIXME: Connection might be disconnected after activation run
+
+		if action.should_activate() && !failed {
+			let _span = info_span!("activating").entered();
+			info!("executing activation script");
+			let specialised = if let Some(specialisation) = specialisation {
+				let mut specialised = built.join("specialisation");
+				specialised.push(specialisation);
+				specialised
+			} else {
+				built.clone()
+			};
+			let switch_script = specialised.join("bin/switch-to-configuration");
+			let mut cmd = host.cmd(switch_script).in_current_span().await?;
+			if deploy_kind == DeployKind::NixosLustrate {
+				cmd.env("NIXOS_INSTALL_BOOTLOADER", "1");
+			}
+			cmd.env("FLEET_ONLINE_ACTIVATION", "1")
+				.arg(action.name().expect("upload.should_activate == false"));
+			if let Err(e) = cmd.sudo().run().in_current_span().await {
+				error!("failed to activate: {e}");
+				failed = true;
+			}
+		}
+	}
+	if action.should_create_rollback_marker() {
+		if !disable_rollback {
+			if failed {
+				if action.should_schedule_rollback_run() {
+					info!("executing rollback");
+					if let Err(e) = host
+						.systemctl_start("rollback-watchdog.service")
+						.instrument(info_span!("rollback"))
+						.await
+					{
+						error!("failed to trigger rollback: {e}")
+					}
+				}
+			} else {
+				info!("trying to mark upgrade as successful");
+				if let Err(e) = host
+					.rm_file("/etc/fleet_rollback_marker", true)
+					.in_current_span()
+					.await
+				{
+					error!("failed to remove rollback marker. This is bad, as the system will be rolled back by watchdog: {e}")
+				}
+			}
+			info!("disarming watchdog, just in case");
+			if let Err(_e) = host.systemctl_stop("rollback-watchdog.timer").await {
+				// It is ok, if there was no reboot - then timer might not be running.
+			}
+			if action.should_schedule_rollback_run() {
+				if let Err(e) = host.systemctl_stop("rollback-watchdog-run.timer").await {
+					error!("failed to disarm rollback run: {e}");
+				}
+			}
+		} else if let Err(_e) = host
+			.rm_file("/etc/fleet_rollback_marker", true)
+			.in_current_span()
+			.await
+		{
+			// Marker might not exist, yet better try to remove it.
+		}
+	}
+	Ok(())
+}
+
+pub async fn upload_task(
+	config: &Config,
+	host: &ConfigHost,
+	location: GenerationStorage,
+	generation: PathBuf,
+) -> Result<PathBuf> {
+	let local_host = config.local_host();
+	if matches!(location, GenerationStorage::Pusher) {
+		bail!("pusher is not enabled in this version of fleet");
+	}
+	if !host.local {
+		info!("uploading system closure");
+		{
+			// TODO: Move to remote_derivation method.
+			// Alternatively, nix store make-content-addressed can be used,
+			// at least for the first deployment, to provide trusted store key.
+			//
+			// It is much slower, yet doesn't require root on the deployer machine.
+			let Ok(mut sign) = local_host.cmd("nix").await else {
+				bail!("failed to setup local");
+			};
+			// Private key for host machine is registered in nix-sign.nix
+			sign.arg("store")
+				.arg("sign")
+				.comparg("--key-file", "/etc/nix/private-key")
+				.arg("-r")
+				.arg(&generation);
+			if let Err(e) = sign.sudo().run_nix().await {
+				warn!("failed to sign store paths: {e}");
+			};
+		}
+		let mut tries = 0;
+		loop {
+			match host.remote_derivation(&generation).await {
+				Ok(remote) => {
+					assert!(remote == generation, "CA derivations aren't implemented");
+					return Ok(remote);
+				}
+				Err(e) if tries < 3 => {
+					tries += 1;
+					warn!("copy failure ({}/3): {}", tries, e);
+					sleep(Duration::from_millis(5000)).await;
+				}
+				Err(e) => {
+					bail!("upload failed: {e}");
+				}
+			}
+		}
+	}
+	Ok(generation)
+}
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -7,8 +7,8 @@
 use chrono::{DateTime, Utc};
 use fleet_shared::SecretData;
 use rand::{
-	distributions::{Alphanumeric, DistString},
-	thread_rng,
+	distr::{Alphanumeric, SampleString as _},
+	rng,
 };
 use serde::{de::Error, Deserialize, Serialize};
 use serde_json::Value;
@@ -47,7 +47,7 @@
 }
 
 fn generate_gc_prefix() -> String {
-	let id = Alphanumeric.sample_string(&mut thread_rng(), 8);
+	let id = Alphanumeric.sample_string(&mut rng(), 8);
 	format!("fleet-gc-{id}")
 }
 
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
before · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	collections::BTreeSet,4	ffi::{OsStr, OsString},5	fmt::Display,6	io::Write,7	ops::Deref,8	path::PathBuf,9	str::FromStr,10	sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{anyhow, bail, ensure, Context, Result};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tempfile::NamedTempFile;1920use crate::{21	command::MyCommand,22	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23};2425pub struct FleetConfigInternals {26	/// Fleet project directory, containing fleet.nix file.27	pub directory: PathBuf,28	/// builtins.currentSystem29	pub local_system: String,30	pub data: Mutex<FleetData>,31	pub nix_args: Vec<OsString>,32	/// fleet_config.config33	pub config_field: Value,34	// TODO: Remove with connectivity refactor35	pub localhost: String,3637	/// import nixpkgs {system = local};38	pub default_pkgs: Value,39	/// inputs.nixpkgs40	pub nixpkgs: Value,4142	pub nix_session: NixSession,43}4445// TODO: Make field not pub46#[derive(Clone)]47pub struct Config(pub Arc<FleetConfigInternals>);4849impl Deref for Config {50	type Target = FleetConfigInternals;5152	fn deref(&self) -> &Self::Target {53		&self.054	}55}5657#[derive(Clone, Copy, Debug)]58pub enum EscalationStrategy {59	Sudo,60	Run0,61	Su,62}6364#[derive(Clone, PartialEq, Copy, Debug)]65pub enum DeployKind {66	/// NixOS => NixOS managed by fleet67	UpgradeToFleet,68	/// NixOS managed by fleet => NixOS managed by fleet69	Fleet,70	/// Remote host has /mnt, /mnt/boot mounted,71	/// generated config is added to fleet configuration.72	NixosInstall,73	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),74	/// generated config is added to fleet configuration,75	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.76	NixosLustrate,77}7879impl FromStr for DeployKind {80	type Err = anyhow::Error;81	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {82		match s {83			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),84			"fleet" => Ok(Self::Fleet),85			"nixos-install" => Ok(Self::NixosInstall),86			"nixos-lustrate" => Ok(Self::NixosLustrate),87			v => bail!("unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""),88		}89	}90}91pub struct ConfigHost {92	config: Config,93	pub name: String,94	groups: OnceCell<Vec<String>>,9596	deploy_kind: OnceCell<DeployKind>,9798	pub host_config: Option<Value>,99	pub nixos_config: OnceCell<Value>,100	pub nixos_unchecked_config: OnceCell<Value>,101	pub pkgs_override: Option<Value>,102103	// TODO: Move command helpers away with connectivity refactor104	pub local: bool,105	pub session: OnceLock<Arc<openssh::Session>>,106}107// TODO: Move command helpers away with connectivity refactor108impl ConfigHost {109	pub fn set_deploy_kind(&self, kind: DeployKind) {110		self.deploy_kind111			.set(kind)112			.ok()113			.expect("deploy kind is already set");114	}115	pub async fn deploy_kind(&self) -> Result<DeployKind> {116		if let Some(kind) = self.deploy_kind.get() {117			return Ok(kind.clone());118		}119		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {120			Ok(v) => v,121			Err(e) => {122				bail!("failed to query remote system kind: {}", e);123			}124		};125		if !is_fleet_managed {126			bail!(indoc::indoc! {"127				host is not marked as managed by fleet128				if you're not trying to lustrate/install system from scratch,129				you should either130					1. manually create /etc/FLEET_HOST file on the target host,131					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet132					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos133			"});134		}135		// TOCTOU is possible136		let _ = self.deploy_kind.set(DeployKind::Fleet);137		Ok(self138			.deploy_kind139			.get()140			.expect("deploy kind is just set")141			.clone())142	}143	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {144		// Prefer sudo, as run0 has some gotchas with polkit145		// and too many repeating prompts.146		if (self.find_in_path("sudo").await).is_ok() {147			return Ok(EscalationStrategy::Sudo);148		}149		if (self.find_in_path("run0").await).is_ok() {150			return Ok(EscalationStrategy::Run0);151		}152		Ok(EscalationStrategy::Su)153	}154	async fn open_session(&self) -> Result<Arc<openssh::Session>> {155		assert!(!self.local, "do not open ssh connection to local session");156		// FIXME: TOCTOU157		if let Some(session) = &self.session.get() {158			return Ok((*session).clone());159		};160		let session = SessionBuilder::default();161		let session = session162			.connect(&self.name)163			.await164			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;165		let session = Arc::new(session);166		self.session.set(session.clone()).expect("TOCTOU happened");167		Ok(session)168	}169	pub async fn mktemp_dir(&self) -> Result<String> {170		let mut cmd = self.cmd("mktemp").await?;171		cmd.arg("-d");172		let path = cmd.run_string().await?;173		Ok(path.trim_end().to_owned())174	}175	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {176		let mut cmd = self.cmd("sh").await?;177		cmd.arg("-c")178			.arg("test -e \"$1\" && echo true || echo false")179			.arg("_")180			.arg(path);181		Ok(cmd.run_value().await?)182	}183	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {184		let mut cmd = self.cmd("cat").await?;185		cmd.arg(path);186		cmd.run_bytes().await187	}188	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {189		let mut cmd = self.cmd("cat").await?;190		cmd.arg(path);191		cmd.run_string().await192	}193	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {194		let mut cmd = self.cmd("ls").await?;195		cmd.arg(path);196		let out = cmd.run_string().await?;197		let mut lines = out.split('\n');198		if let Some(last) = lines.next_back() {199			ensure!(last.is_empty(), "output of ls should end with newline");200		}201		Ok(lines.map(ToOwned::to_owned).collect())202	}203	#[allow(dead_code)]204	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {205		let text = self.read_file_text(path).await?;206		Ok(serde_json::from_str(&text)?)207	}208	pub async fn read_env(&self, env: &str) -> Result<String> {209		let mut cmd = self.cmd("printenv").await?;210		cmd.arg(env);211		cmd.run_string().await212	}213	pub async fn find_in_path(&self, command: &str) -> Result<String> {214		// // `which` is not a part of coreutils, and it might not exist on machine.215		// let path = self.read_env("PATH").await?;216		// // Assuming delimiter is :, we don't work with windows host, this check will be much217		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)218		// for ele in path.split(':') {219		// 	let test_path = format!("{ele}/{cmd}");220		// 	test -x etc221		// }222		// let mut cmd = self.cmd("printenv").await?;223		// cmd.arg(env);224		// Ok(cmd.run_string().await?)225		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.226		let mut cmd = self227			.cmd_escalation(228				// Not used229				EscalationStrategy::Su,230				"which",231			)232			.await?;233		cmd.arg(command);234		cmd.run_string().await235	}236	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>237	where238		<D as FromStr>::Err: Display,239	{240		let text = self.read_file_text(path).await?;241		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))242	}243	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {244		self.cmd_escalation(self.escalation_strategy().await?, cmd)245			.await246	}247	pub async fn cmd_escalation(248		&self,249		escalation: EscalationStrategy,250		cmd: impl AsRef<OsStr>,251	) -> Result<MyCommand> {252		if self.local {253			Ok(MyCommand::new(escalation, cmd))254		} else {255			let session = self.open_session().await?;256			Ok(MyCommand::new_on(escalation, cmd, session))257		}258	}259	pub async fn nix_cmd(&self) -> Result<MyCommand> {260		let mut nix = self.cmd("nix").await?;261		nix.args([262			"--extra-experimental-features",263			"nix-command",264			"--extra-experimental-features",265			"flakes",266		]);267		Ok(nix)268	}269270	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {271		ensure!(data.encrypted, "secret is not encrypted");272		let mut cmd = self.cmd("fleet-install-secrets").await?;273		cmd.arg("decrypt").eqarg("--secret", data.to_string());274		let encoded = cmd275			.sudo()276			.run_string()277			.await278			.context("failed to call remote host for decrypt")?;279		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;280		ensure!(!data.encrypted, "secret came out encrypted");281		Ok(data.data)282	}283	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {284		ensure!(data.encrypted, "secret is not encrypted");285		let mut cmd = self.cmd("fleet-install-secrets").await?;286		cmd.arg("reencrypt").eqarg("--secret", data.to_string());287		for target in targets {288			let key = self.config.key(&target).await?;289			cmd.eqarg("--targets", key);290		}291		let encoded = cmd292			.sudo()293			.run_string()294			.await295			.context("failed to call remote host for decrypt")?;296		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;297		ensure!(data.encrypted, "secret came out not encrypted");298		Ok(data)299	}300	/// Returns path for futureproofing, as path might change i.e on conversion to CA301	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {302		if self.local {303			// Path is located locally, thus already trusted.304			return Ok(path.to_owned());305		}306		let mut nix = MyCommand::new(307			// Not used308			EscalationStrategy::Su,309			"nix",310		);311		nix.arg("copy").arg("--substitute-on-destination");312313		match self.deploy_kind().await? {314			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {315				nix.comparg("--to", format!("ssh-ng://{}", self.name));316			}317			DeployKind::NixosInstall => {318				nix319					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon320					.arg("--no-check-sigs")321					.comparg(322						"--to",323						format!("ssh-ng://root@{}?remote-store=/mnt", self.name),324					);325			}326		}327		nix.arg(path);328		nix.run_nix().await.context("nix copy")?;329		Ok(path.to_owned())330	}331	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {332		let mut cmd = self.cmd("systemctl").await?;333		cmd.arg("stop").arg(name);334		cmd.sudo().run().await335	}336	pub async fn systemctl_start(&self, name: &str) -> Result<()> {337		let mut cmd = self.cmd("systemctl").await?;338		cmd.arg("start").arg(name);339		cmd.sudo().run().await340	}341342	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {343		let mut cmd = self.cmd("rm").await?;344		cmd.arg("-f").arg(path);345		if sudo {346			cmd = cmd.sudo()347		}348		cmd.run().await349	}350}351impl ConfigHost {352	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,353	// assuming getting tags always returns the same value.354	pub async fn tags(&self) -> Result<Vec<String>> {355		if let Some(v) = self.groups.get() {356			return Ok(v.clone());357		}358		let Some(host_config) = &self.host_config else {359			return Ok(vec![]);360		};361		let tags: Vec<String> = nix_go_json!(host_config.tags);362363		let _ = self.groups.set(tags.clone());364365		Ok(tags)366	}367	pub async fn nixos_config(&self) -> Result<Value> {368		if let Some(v) = self.nixos_config.get() {369			return Ok(v.clone());370		}371		let Some(host_config) = &self.host_config else {372			bail!("local host has no nixos_config");373		};374		let nixos_config = nix_go!(host_config.nixos.config);375		assert_warn("nixos config evaluation", &nixos_config).await?;376377		let _ = self.nixos_config.set(nixos_config.clone());378379		Ok(nixos_config)380	}381	pub async fn nixos_unchecked_config(&self) -> Result<Value> {382		if let Some(v) = self.nixos_unchecked_config.get() {383			return Ok(v.clone());384		}385		let Some(host_config) = &self.host_config else {386			bail!("local host has no nixos_config");387		};388		let nixos_config = nix_go!(host_config.nixos_unchecked.config);389390		let _ = self.nixos_unchecked_config.set(nixos_config.clone());391392		Ok(nixos_config)393	}394395	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {396		let nixos = self.nixos_unchecked_config().await?;397		let secrets = nix_go!(nixos.secrets);398		let mut out = Vec::new();399		for name in secrets.list_fields().await? {400			let secret = nix_go!(secrets[{ name }]);401			let is_shared: bool = nix_go_json!(secret.shared);402			if is_shared {403				continue;404			}405			out.push(name);406		}407		Ok(out)408	}409	pub async fn secret_field(&self, name: &str) -> Result<Value> {410		let nixos = self.nixos_unchecked_config().await?;411		Ok(nix_go!(nixos.secrets[{ name }]))412	}413414	/// Packages for this host, resolved with nixpkgs overlays415	pub async fn pkgs(&self) -> Result<Value> {416		if let Some(value) = &self.pkgs_override {417			return Ok(value.clone());418		}419		let Some(host_config) = &self.host_config else {420			bail!("local host has no host_config");421		};422		// TODO: Should nixos.options be cached?423		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))424	}425}426427impl Config {428	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {429		let config = &self.config_field;430		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);431		Ok(tagged)432	}433	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {434		let mut out = BTreeSet::new();435		for owner in owners {436			if let Some(tag) = owner.strip_prefix('@') {437				let hosts = self.tagged_hostnames(tag).await?;438				out.extend(hosts);439			} else {440				out.insert(owner);441			}442		}443		Ok(out)444	}445	pub fn local_host(&self) -> ConfigHost {446		ConfigHost {447			config: self.clone(),448			name: "<virtual localhost>".to_owned(),449			host_config: None,450			nixos_config: OnceCell::new(),451			nixos_unchecked_config: OnceCell::new(),452			groups: {453				let cell = OnceCell::new();454				let _ = cell.set(vec![]);455				cell456			},457			pkgs_override: Some(self.default_pkgs.clone()),458459			local: true,460			session: OnceLock::new(),461			deploy_kind: OnceCell::new(),462		}463	}464465	pub async fn host(&self, name: &str) -> Result<ConfigHost> {466		let config = &self.config_field;467		let host_config = nix_go!(config.hosts[{ name }]);468469		Ok(ConfigHost {470			config: self.clone(),471			name: name.to_owned(),472			host_config: Some(host_config),473			nixos_config: OnceCell::new(),474			nixos_unchecked_config: OnceCell::new(),475			groups: OnceCell::new(),476			pkgs_override: None,477478			// TODO: Remove with connectivit refactor479			local: self.localhost == name,480			session: OnceLock::new(),481			deploy_kind: OnceCell::new(),482		})483	}484	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {485		let config = &self.config_field;486		let names = nix_go!(config.hosts).list_fields().await?;487		let mut out = vec![];488		for name in names {489			out.push(self.host(&name).await?);490		}491		Ok(out)492	}493	// TODO: Replace usages with .host().nixos_config494	pub async fn system_config(&self, host: &str) -> Result<Value> {495		let fleet_field = &self.config_field;496		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))497	}498499	/// Shared secrets configured in fleet.nix or in flake500	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {501		let config_field = &self.config_field;502		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)503	}504	/// Shared secrets configured in fleet.nix505	pub fn list_shared(&self) -> Vec<String> {506		let data = self.data();507		data.shared_secrets.keys().cloned().collect()508	}509	pub fn has_shared(&self, name: &str) -> bool {510		let data = self.data();511		data.shared_secrets.contains_key(name)512	}513	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {514		let mut data = self.data_mut();515		data.shared_secrets.insert(name.to_owned(), shared);516	}517	pub fn remove_shared(&self, secret: &str) {518		let mut data = self.data_mut();519		data.shared_secrets.remove(secret);520	}521522	pub fn list_secrets(&self, host: &str) -> Vec<String> {523		let data = self.data();524		let Some(secrets) = data.host_secrets.get(host) else {525			return Vec::new();526		};527		secrets.keys().cloned().collect()528	}529530	pub fn has_secret(&self, host: &str, secret: &str) -> bool {531		let data = self.data();532		let Some(host_secrets) = data.host_secrets.get(host) else {533			return false;534		};535		host_secrets.contains_key(secret)536	}537	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {538		let mut data = self.data_mut();539		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();540		host_secrets.insert(secret, value);541	}542543	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {544		let data = self.data();545		let Some(host_secrets) = data.host_secrets.get(host) else {546			bail!("no secrets for machine {host}");547		};548		let Some(secret) = host_secrets.get(secret) else {549			bail!("machine {host} has no secret {secret}");550		};551		Ok(secret.clone())552	}553	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {554		let data = self.data();555		let Some(secret) = data.shared_secrets.get(secret) else {556			bail!("no shared secret {secret}");557		};558		Ok(secret.clone())559	}560	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {561		let config_field = &self.config_field;562		Ok(nix_go_json!(563			config_field.sharedSecrets[{ secret }].expectedOwners564		))565	}566567	// TODO: Should this be something modifiable from other processes?568	// E.g terraform provider might want to update FleetData (e.g secrets),569	// and current implementation assumes only one process holds current fleet.nix570	// Given that it is no longer needs to be a file for nix evaluation,571	// maybe it can be a .nix file for persistence, but accessible only572	// thru some shared state controller? Might it be stored in terraform573	// state provider?574	pub fn data(&self) -> MutexGuard<FleetData> {575		self.data.lock().unwrap()576	}577	pub fn data_mut(&self) -> MutexGuard<FleetData> {578		self.data.lock().unwrap()579	}580	pub fn save(&self) -> Result<()> {581		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;582		let data = nixlike::serialize(&self.data() as &FleetData)?;583		tempfile.write_all(584			format!(585				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",586				data587			)588			.as_bytes(),589		)?;590		let mut fleet_data_path = self.directory.clone();591		fleet_data_path.push("fleet.nix");592		tempfile.persist(fleet_data_path)?;593		Ok(())594	}595}
after · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	collections::BTreeSet,4	ffi::{OsStr, OsString},5	fmt::Display,6	io::Write,7	ops::Deref,8	path::PathBuf,9	str::FromStr,10	sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{anyhow, bail, ensure, Context, Result};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{format_description, UtcDateTime};21use tracing::warn;2223use crate::{24	command::MyCommand,25	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},26};2728pub struct FleetConfigInternals {29	/// Fleet project directory, containing fleet.nix file.30	pub directory: PathBuf,31	/// builtins.currentSystem32	pub local_system: String,33	pub data: Mutex<FleetData>,34	pub nix_args: Vec<OsString>,35	/// fleet_config.config36	pub config_field: Value,37	// TODO: Remove with connectivity refactor38	pub localhost: String,3940	/// import nixpkgs {system = local};41	pub default_pkgs: Value,42	/// inputs.nixpkgs43	pub nixpkgs: Value,4445	pub nix_session: NixSession,46}4748// TODO: Make field not pub49#[derive(Clone)]50pub struct Config(pub Arc<FleetConfigInternals>);5152impl Deref for Config {53	type Target = FleetConfigInternals;5455	fn deref(&self) -> &Self::Target {56		&self.057	}58}5960#[derive(Clone, Copy, Debug)]61pub enum EscalationStrategy {62	Sudo,63	Run0,64	Su,65}6667#[derive(Clone, PartialEq, Copy, Debug)]68pub enum DeployKind {69	/// NixOS => NixOS managed by fleet70	UpgradeToFleet,71	/// NixOS managed by fleet => NixOS managed by fleet72	Fleet,73	/// Remote host has /mnt, /mnt/boot mounted,74	/// generated config is added to fleet configuration.75	NixosInstall,76	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),77	/// generated config is added to fleet configuration,78	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.79	NixosLustrate,80}8182impl FromStr for DeployKind {83	type Err = anyhow::Error;84	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {85		match s {86			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),87			"fleet" => Ok(Self::Fleet),88			"nixos-install" => Ok(Self::NixosInstall),89			"nixos-lustrate" => Ok(Self::NixosLustrate),90			v => bail!("unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""),91		}92	}93}94pub struct ConfigHost {95	config: Config,96	pub name: String,97	groups: OnceCell<Vec<String>>,9899	deploy_kind: OnceCell<DeployKind>,100101	pub host_config: Option<Value>,102	pub nixos_config: OnceCell<Value>,103	pub nixos_unchecked_config: OnceCell<Value>,104	pub pkgs_override: Option<Value>,105106	// TODO: Move command helpers away with connectivity refactor107	pub local: bool,108	pub session: OnceLock<Arc<openssh::Session>>,109}110111#[derive(Debug, Clone, Copy)]112pub enum GenerationStorage {113	Deployer,114	Machine,115	Pusher,116}117impl GenerationStorage {118	fn prefix(&self) -> &'static str {119		match self {120			GenerationStorage::Deployer => "deployer.",121			GenerationStorage::Machine => "",122			GenerationStorage::Pusher => "pusher.",123		}124	}125}126127#[derive(Tabled, Debug)]128pub struct Generation {129	#[tabled(rename = "ID", format("{}", self.rollback_id()))]130	pub id: u32,131	#[tabled(rename = "Current")]132	pub current: bool,133	#[tabled(rename = "Created at")]134	pub datetime: UtcDateTime,135	#[tabled(format = "{:?}")]136	pub store_path: PathBuf,137	#[tabled(skip)]138	pub location: GenerationStorage,139}140impl Generation {141	pub fn rollback_id(&self) -> String {142		format!("{}{}", self.location.prefix(), self.id)143	}144}145146fn parse_generation_line(g: &str) -> Option<Generation> {147	let mut parts = g.split_whitespace();148	let id = parts.next()?;149	let id: u32 = id.parse().ok()?;150	let date = parts.next()?;151	let time = parts.next()?;152	let current = if let Some(current) = parts.next() {153		if current == "(current)" {154			Some(true)155		} else {156			None157		}158	} else {159		Some(false)160	};161	let current = current?;162	if parts.next().is_some() {163		warn!("unexpected text after generation: {g}");164	}165166	let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")167		.expect("valid format");168	let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;169170	Some(Generation {171		id,172		current,173		datetime,174		store_path: PathBuf::new(),175		location: GenerationStorage::Machine,176	})177}178// TODO: Move command helpers away with connectivity refactor179impl ConfigHost {180	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {181		let mut cmd = self.cmd("nix-env").await?;182		cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))183			.arg("--list-generations")184			.env("TZ", "UTC");185		// Sudo is required because --list-generations tries to acquire profile lock186		let data = cmd.sudo().run_string().await?;187		let mut generations = data188			.split('\n')189			.map(|e| e.trim())190			.filter(|&l| !l.is_empty())191			.filter_map(|g| {192				let gen = parse_generation_line(g);193				if gen.is_none() {194					warn!("bad generation: {g}");195				};196				gen197			})198			.collect::<Vec<_>>();199		for ele in generations.iter_mut() {200			let mut cmd = self.cmd("readlink").await?;201			cmd.arg("--")202				.arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));203			let path = cmd.run_string().await?;204			ele.store_path = PathBuf::from(path.trim_end_matches("\n"));205		}206207		Ok(generations)208	}209210	pub fn set_deploy_kind(&self, kind: DeployKind) {211		self.deploy_kind212			.set(kind)213			.ok()214			.expect("deploy kind is already set");215	}216	pub async fn deploy_kind(&self) -> Result<DeployKind> {217		if let Some(kind) = self.deploy_kind.get() {218			return Ok(kind.clone());219		}220		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {221			Ok(v) => v,222			Err(e) => {223				bail!("failed to query remote system kind: {}", e);224			}225		};226		if !is_fleet_managed {227			bail!(indoc::indoc! {"228				host is not marked as managed by fleet229				if you're not trying to lustrate/install system from scratch,230				you should either231					1. manually create /etc/FLEET_HOST file on the target host,232					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet233					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos234			"});235		}236		// TOCTOU is possible237		let _ = self.deploy_kind.set(DeployKind::Fleet);238		Ok(self239			.deploy_kind240			.get()241			.expect("deploy kind is just set")242			.clone())243	}244	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {245		// Prefer sudo, as run0 has some gotchas with polkit246		// and too many repeating prompts.247		if (self.find_in_path("sudo").await).is_ok() {248			return Ok(EscalationStrategy::Sudo);249		}250		if (self.find_in_path("run0").await).is_ok() {251			return Ok(EscalationStrategy::Run0);252		}253		Ok(EscalationStrategy::Su)254	}255	async fn open_session(&self) -> Result<Arc<openssh::Session>> {256		assert!(!self.local, "do not open ssh connection to local session");257		// FIXME: TOCTOU258		if let Some(session) = &self.session.get() {259			return Ok((*session).clone());260		};261		let session = SessionBuilder::default();262		let session = session263			.connect(&self.name)264			.await265			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;266		let session = Arc::new(session);267		self.session.set(session.clone()).expect("TOCTOU happened");268		Ok(session)269	}270	pub async fn mktemp_dir(&self) -> Result<String> {271		let mut cmd = self.cmd("mktemp").await?;272		cmd.arg("-d");273		let path = cmd.run_string().await?;274		Ok(path.trim_end().to_owned())275	}276	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {277		let mut cmd = self.cmd("sh").await?;278		cmd.arg("-c")279			.arg("test -e \"$1\" && echo true || echo false")280			.arg("_")281			.arg(path);282		Ok(cmd.run_value().await?)283	}284	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {285		let mut cmd = self.cmd("cat").await?;286		cmd.arg(path);287		cmd.run_bytes().await288	}289	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {290		let mut cmd = self.cmd("cat").await?;291		cmd.arg(path);292		cmd.run_string().await293	}294	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {295		let mut cmd = self.cmd("ls").await?;296		cmd.arg(path);297		let out = cmd.run_string().await?;298		let mut lines = out.split('\n');299		if let Some(last) = lines.next_back() {300			ensure!(last.is_empty(), "output of ls should end with newline");301		}302		Ok(lines.map(ToOwned::to_owned).collect())303	}304	#[allow(dead_code)]305	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {306		let text = self.read_file_text(path).await?;307		Ok(serde_json::from_str(&text)?)308	}309	pub async fn read_env(&self, env: &str) -> Result<String> {310		let mut cmd = self.cmd("printenv").await?;311		cmd.arg(env);312		cmd.run_string().await313	}314	pub async fn find_in_path(&self, command: &str) -> Result<String> {315		// // `which` is not a part of coreutils, and it might not exist on machine.316		// let path = self.read_env("PATH").await?;317		// // Assuming delimiter is :, we don't work with windows host, this check will be much318		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)319		// for ele in path.split(':') {320		// 	let test_path = format!("{ele}/{cmd}");321		// 	test -x etc322		// }323		// let mut cmd = self.cmd("printenv").await?;324		// cmd.arg(env);325		// Ok(cmd.run_string().await?)326		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.327		let mut cmd = self328			.cmd_escalation(329				// Not used330				EscalationStrategy::Su,331				"which",332			)333			.await?;334		cmd.arg(command);335		cmd.run_string().await336	}337	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>338	where339		<D as FromStr>::Err: Display,340	{341		let text = self.read_file_text(path).await?;342		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))343	}344	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {345		self.cmd_escalation(self.escalation_strategy().await?, cmd)346			.await347	}348	pub async fn cmd_escalation(349		&self,350		escalation: EscalationStrategy,351		cmd: impl AsRef<OsStr>,352	) -> Result<MyCommand> {353		if self.local {354			Ok(MyCommand::new(escalation, cmd))355		} else {356			let session = self.open_session().await?;357			Ok(MyCommand::new_on(escalation, cmd, session))358		}359	}360	pub async fn nix_cmd(&self) -> Result<MyCommand> {361		let mut nix = self.cmd("nix").await?;362		nix.args([363			"--extra-experimental-features",364			"nix-command",365			"--extra-experimental-features",366			"flakes",367		]);368		Ok(nix)369	}370371	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {372		ensure!(data.encrypted, "secret is not encrypted");373		let mut cmd = self.cmd("fleet-install-secrets").await?;374		cmd.arg("decrypt").eqarg("--secret", data.to_string());375		let encoded = cmd376			.sudo()377			.run_string()378			.await379			.context("failed to call remote host for decrypt")?;380		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;381		ensure!(!data.encrypted, "secret came out encrypted");382		Ok(data.data)383	}384	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {385		ensure!(data.encrypted, "secret is not encrypted");386		let mut cmd = self.cmd("fleet-install-secrets").await?;387		cmd.arg("reencrypt").eqarg("--secret", data.to_string());388		for target in targets {389			let key = self.config.key(&target).await?;390			cmd.eqarg("--targets", key);391		}392		let encoded = cmd393			.sudo()394			.run_string()395			.await396			.context("failed to call remote host for decrypt")?;397		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;398		ensure!(data.encrypted, "secret came out not encrypted");399		Ok(data)400	}401	/// Returns path for futureproofing, as path might change i.e on conversion to CA402	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {403		if self.local {404			// Path is located locally, thus already trusted.405			return Ok(path.to_owned());406		}407		let mut nix = MyCommand::new(408			// Not used409			EscalationStrategy::Su,410			"nix",411		);412		nix.arg("copy").arg("--substitute-on-destination");413414		match self.deploy_kind().await? {415			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {416				nix.comparg("--to", format!("ssh-ng://{}", self.name));417			}418			DeployKind::NixosInstall => {419				nix420					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon421					.arg("--no-check-sigs")422					.comparg(423						"--to",424						format!("ssh-ng://root@{}?remote-store=/mnt", self.name),425					);426			}427		}428		nix.arg(path);429		nix.run_nix().await.context("nix copy")?;430		Ok(path.to_owned())431	}432	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {433		let mut cmd = self.cmd("systemctl").await?;434		cmd.arg("stop").arg(name);435		cmd.sudo().run().await436	}437	pub async fn systemctl_start(&self, name: &str) -> Result<()> {438		let mut cmd = self.cmd("systemctl").await?;439		cmd.arg("start").arg(name);440		cmd.sudo().run().await441	}442443	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {444		let mut cmd = self.cmd("rm").await?;445		cmd.arg("-f").arg(path);446		if sudo {447			cmd = cmd.sudo()448		}449		cmd.run().await450	}451}452impl ConfigHost {453	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,454	// assuming getting tags always returns the same value.455	pub async fn tags(&self) -> Result<Vec<String>> {456		if let Some(v) = self.groups.get() {457			return Ok(v.clone());458		}459		let Some(host_config) = &self.host_config else {460			return Ok(vec![]);461		};462		let tags: Vec<String> = nix_go_json!(host_config.tags);463464		let _ = self.groups.set(tags.clone());465466		Ok(tags)467	}468	pub async fn nixos_config(&self) -> Result<Value> {469		if let Some(v) = self.nixos_config.get() {470			return Ok(v.clone());471		}472		let Some(host_config) = &self.host_config else {473			bail!("local host has no nixos_config");474		};475		let nixos_config = nix_go!(host_config.nixos.config);476		assert_warn("nixos config evaluation", &nixos_config).await?;477478		let _ = self.nixos_config.set(nixos_config.clone());479480		Ok(nixos_config)481	}482	pub async fn nixos_unchecked_config(&self) -> Result<Value> {483		if let Some(v) = self.nixos_unchecked_config.get() {484			return Ok(v.clone());485		}486		let Some(host_config) = &self.host_config else {487			bail!("local host has no nixos_config");488		};489		let nixos_config = nix_go!(host_config.nixos_unchecked.config);490491		let _ = self.nixos_unchecked_config.set(nixos_config.clone());492493		Ok(nixos_config)494	}495496	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {497		let nixos = self.nixos_unchecked_config().await?;498		let secrets = nix_go!(nixos.secrets);499		let mut out = Vec::new();500		for name in secrets.list_fields().await? {501			let secret = nix_go!(secrets[{ name }]);502			let is_shared: bool = nix_go_json!(secret.shared);503			if is_shared {504				continue;505			}506			out.push(name);507		}508		Ok(out)509	}510	pub async fn secret_field(&self, name: &str) -> Result<Value> {511		let nixos = self.nixos_unchecked_config().await?;512		Ok(nix_go!(nixos.secrets[{ name }]))513	}514515	/// Packages for this host, resolved with nixpkgs overlays516	pub async fn pkgs(&self) -> Result<Value> {517		if let Some(value) = &self.pkgs_override {518			return Ok(value.clone());519		}520		let Some(host_config) = &self.host_config else {521			bail!("local host has no host_config");522		};523		// TODO: Should nixos.options be cached?524		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))525	}526}527528impl Config {529	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {530		let config = &self.config_field;531		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);532		Ok(tagged)533	}534	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {535		let mut out = BTreeSet::new();536		for owner in owners {537			if let Some(tag) = owner.strip_prefix('@') {538				let hosts = self.tagged_hostnames(tag).await?;539				out.extend(hosts);540			} else {541				out.insert(owner);542			}543		}544		Ok(out)545	}546	pub fn local_host(&self) -> ConfigHost {547		ConfigHost {548			config: self.clone(),549			name: "<virtual localhost>".to_owned(),550			host_config: None,551			nixos_config: OnceCell::new(),552			nixos_unchecked_config: OnceCell::new(),553			groups: {554				let cell = OnceCell::new();555				let _ = cell.set(vec![]);556				cell557			},558			pkgs_override: Some(self.default_pkgs.clone()),559560			local: true,561			session: OnceLock::new(),562			deploy_kind: OnceCell::new(),563		}564	}565566	pub async fn host(&self, name: &str) -> Result<ConfigHost> {567		let config = &self.config_field;568		let host_config = nix_go!(config.hosts[{ name }]);569570		Ok(ConfigHost {571			config: self.clone(),572			name: name.to_owned(),573			host_config: Some(host_config),574			nixos_config: OnceCell::new(),575			nixos_unchecked_config: OnceCell::new(),576			groups: OnceCell::new(),577			pkgs_override: None,578579			// TODO: Remove with connectivit refactor580			local: self.localhost == name,581			session: OnceLock::new(),582			deploy_kind: OnceCell::new(),583		})584	}585	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {586		let config = &self.config_field;587		let names = nix_go!(config.hosts).list_fields().await?;588		let mut out = vec![];589		for name in names {590			out.push(self.host(&name).await?);591		}592		Ok(out)593	}594	// TODO: Replace usages with .host().nixos_config595	pub async fn system_config(&self, host: &str) -> Result<Value> {596		let fleet_field = &self.config_field;597		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))598	}599600	/// Shared secrets configured in fleet.nix or in flake601	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {602		let config_field = &self.config_field;603		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)604	}605	/// Shared secrets configured in fleet.nix606	pub fn list_shared(&self) -> Vec<String> {607		let data = self.data();608		data.shared_secrets.keys().cloned().collect()609	}610	pub fn has_shared(&self, name: &str) -> bool {611		let data = self.data();612		data.shared_secrets.contains_key(name)613	}614	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {615		let mut data = self.data_mut();616		data.shared_secrets.insert(name.to_owned(), shared);617	}618	pub fn remove_shared(&self, secret: &str) {619		let mut data = self.data_mut();620		data.shared_secrets.remove(secret);621	}622623	pub fn list_secrets(&self, host: &str) -> Vec<String> {624		let data = self.data();625		let Some(secrets) = data.host_secrets.get(host) else {626			return Vec::new();627		};628		secrets.keys().cloned().collect()629	}630631	pub fn has_secret(&self, host: &str, secret: &str) -> bool {632		let data = self.data();633		let Some(host_secrets) = data.host_secrets.get(host) else {634			return false;635		};636		host_secrets.contains_key(secret)637	}638	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {639		let mut data = self.data_mut();640		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();641		host_secrets.insert(secret, value);642	}643644	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {645		let data = self.data();646		let Some(host_secrets) = data.host_secrets.get(host) else {647			bail!("no secrets for machine {host}");648		};649		let Some(secret) = host_secrets.get(secret) else {650			bail!("machine {host} has no secret {secret}");651		};652		Ok(secret.clone())653	}654	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {655		let data = self.data();656		let Some(secret) = data.shared_secrets.get(secret) else {657			bail!("no shared secret {secret}");658		};659		Ok(secret.clone())660	}661	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {662		let config_field = &self.config_field;663		Ok(nix_go_json!(664			config_field.sharedSecrets[{ secret }].expectedOwners665		))666	}667668	// TODO: Should this be something modifiable from other processes?669	// E.g terraform provider might want to update FleetData (e.g secrets),670	// and current implementation assumes only one process holds current fleet.nix671	// Given that it is no longer needs to be a file for nix evaluation,672	// maybe it can be a .nix file for persistence, but accessible only673	// thru some shared state controller? Might it be stored in terraform674	// state provider?675	pub fn data(&self) -> MutexGuard<FleetData> {676		self.data.lock().unwrap()677	}678	pub fn data_mut(&self) -> MutexGuard<FleetData> {679		self.data.lock().unwrap()680	}681	pub fn save(&self) -> Result<()> {682		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;683		let data = nixlike::serialize(&self.data() as &FleetData)?;684		tempfile.write_all(685			format!(686				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",687				data688			)689			.as_bytes(),690		)?;691		let mut fleet_data_path = self.directory.clone();692		fleet_data_path.push("fleet.nix");693		tempfile.persist(fleet_data_path)?;694		Ok(())695	}696}
modifiedcrates/fleet-base/src/lib.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/lib.rs
+++ b/crates/fleet-base/src/lib.rs
@@ -3,3 +3,4 @@
 pub mod host;
 mod keys;
 pub mod opts;
+pub mod deploy;
\ No newline at end of file
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -7,7 +7,6 @@
 };
 
 use anyhow::{bail, Context, Result};
-use clap::Parser;
 use nix_eval::{nix_go, util::assert_warn, NixSessionPool, Value};
 use nom::{
 	bytes::complete::take_while1,
@@ -15,6 +14,7 @@
 	combinator::{map, opt},
 	multi::separated_list1,
 	sequence::{preceded, separated_pair},
+	Parser,
 };
 
 use crate::{
@@ -38,11 +38,13 @@
 		err.to_string()
 	}
 
-	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;
+	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())
+		.parse_complete(input)
+		.map_err(err_to_string)?;
 	let (input, name) = map(
 		take_while1(|v| v != ',' && v != '?' && v != '@'),
 		str::to_owned,
-	)(input)
+	).parse_complete(input)
 	.map_err(err_to_string)?;
 
 	let kw_item = separated_pair(
@@ -55,7 +57,7 @@
 	});
 	let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);
 
-	let (input, attrs) = opt_kw(input).map_err(err_to_string)?;
+	let (input, attrs) = opt_kw.parse_complete(input).map_err(err_to_string)?;
 
 	if !input.is_empty() {
 		return Err(format!("unexpected trailing input: {input:?}"));
@@ -68,7 +70,7 @@
 }
 
 // TODO: Rename to HostSelector
-#[derive(Parser, Clone)]
+#[derive(clap::Parser, Clone)]
 pub struct FleetOpts {
 	/// All hosts except those would be skipped
 	#[clap(long, number_of_values = 1, value_parser = host_item_parser)]
modifiedcrates/fleet-shared/Cargo.tomldiffbeforeafterboth
--- a/crates/fleet-shared/Cargo.toml
+++ b/crates/fleet-shared/Cargo.toml
@@ -6,6 +6,6 @@
 
 [dependencies]
 base64 = "0.22.1"
-serde = "1.0.202"
+serde = "1.0.219"
 unicode_categories = "0.1.1"
-z85 = "3.0.5"
+z85 = "3.0.6"
modifiedcrates/nix-eval/Cargo.tomldiffbeforeafterboth
--- a/crates/nix-eval/Cargo.toml
+++ b/crates/nix-eval/Cargo.toml
@@ -16,11 +16,11 @@
 tokio-util.workspace = true
 tracing.workspace = true
 
-futures = "0.3.30"
-itertools = "0.13.0"
+futures = "0.3.31"
+itertools = "0.14.0"
 r2d2 = "0.8.10"
-regex = "1.10.6"
-unindent = "0.2.3"
+regex = "1.11.1"
+unindent = "0.2.4"
 
 # [build-dependencies]
 # bindgen = "0.69.4"
modifiedcrates/nixlike/Cargo.tomldiffbeforeafterboth
--- a/crates/nixlike/Cargo.toml
+++ b/crates/nixlike/Cargo.toml
@@ -9,8 +9,8 @@
 
 alejandra = { git = "https://github.com/kamadorueda/alejandra" }
 linked-hash-map = "0.5.6"
-peg = "0.8.2"
-ron = "0.8.1"
-serde = "1.0.196"
+peg = "0.8.5"
+ron = "0.10.1"
+serde = "1.0.219"
 serde-transcode = "1.1.1"
-serde_json = "1.0.113"
+serde_json = "1.0.140"