git.delta.rocks / jrsonnet / refs/commits / 87c4900cde09

difftreelog

refactor prepare for decoupling fleet-cli from fleet-data-storage

Yaroslav Bolyukin2024-08-31parent: #213ad7d.patch.diff
in: trunk

26 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -215,23 +215,98 @@
 checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
 
 [[package]]
+name = "async-stream"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.76",
+]
+
+[[package]]
 name = "async-trait"
-version = "0.1.80"
+version = "0.1.81"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
+checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
 name = "autocfg"
 version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
 
 [[package]]
+name = "axum"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf"
+dependencies = [
+ "async-trait",
+ "axum-core",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustversion",
+ "serde",
+ "sync_wrapper 1.0.1",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "rustversion",
+ "sync_wrapper 0.1.2",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
 name = "backtrace"
 version = "0.3.72"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -482,7 +557,7 @@
  "heck 0.5.0",
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -617,7 +692,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -673,7 +748,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -762,6 +837,12 @@
 ]
 
 [[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
 name = "fleet"
 version = "0.2.0"
 dependencies = [
@@ -776,6 +857,7 @@
  "clap",
  "clap_complete",
  "crossterm",
+ "fleet-base",
  "fleet-shared",
  "futures",
  "hostname",
@@ -785,7 +867,7 @@
  "nix-eval",
  "nixlike",
  "nom",
- "openssh",
+ "openssh 0.10.4",
  "owo-colors",
  "peg",
  "regex",
@@ -803,6 +885,31 @@
 ]
 
 [[package]]
+name = "fleet-base"
+version = "0.1.0"
+dependencies = [
+ "age",
+ "anyhow",
+ "better-command",
+ "chrono",
+ "clap",
+ "fleet-shared",
+ "futures",
+ "hostname",
+ "itertools",
+ "nix-eval",
+ "nixlike",
+ "nom",
+ "openssh 0.11.0",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
 name = "fleet-generator-helper"
 version = "0.1.0"
 dependencies = [
@@ -949,7 +1056,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -1020,6 +1127,25 @@
 checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
 
 [[package]]
+name = "h2"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap 2.2.6",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
 name = "hashbrown"
 version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1027,6 +1153,12 @@
 
 [[package]]
 name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
 version = "0.14.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
@@ -1085,12 +1217,112 @@
 ]
 
 [[package]]
+name = "http"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
 name = "human-repr"
 version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f58b778a5761513caf593693f8951c97a5b610841e754788400f32102eefdff1"
 
 [[package]]
+name = "hyper"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-timeout"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3203a961e5c83b6f5498933e78b6b263e208c197b63e9c6c53cc82ffd3f63793"
+dependencies = [
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
 name = "i18n-config"
 version = "0.4.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1142,7 +1374,7 @@
  "proc-macro2",
  "quote",
  "strsim 0.10.0",
- "syn 2.0.66",
+ "syn 2.0.76",
  "unic-langid",
 ]
 
@@ -1156,7 +1388,7 @@
  "i18n-config",
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -1184,6 +1416,16 @@
 
 [[package]]
 name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+]
+
+[[package]]
+name = "indexmap"
 version = "2.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
@@ -1303,7 +1545,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 dependencies = [
- "spin",
+ "spin 0.5.2",
 ]
 
 [[package]]
@@ -1366,6 +1608,12 @@
 ]
 
 [[package]]
+name = "matchit"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+
+[[package]]
 name = "memchr"
 version = "2.7.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1390,6 +1638,12 @@
 ]
 
 [[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
 name = "minimal-lexical"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1417,6 +1671,12 @@
 ]
 
 [[package]]
+name = "multimap"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
+
+[[package]]
 name = "nix"
 version = "0.29.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1588,6 +1848,20 @@
 ]
 
 [[package]]
+name = "openssh"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f27389e5da64700a3efb7f925e442f824f6e3d4b1c27f75e115a92ad3aecbb1"
+dependencies = [
+ "libc",
+ "once_cell",
+ "shell-escape",
+ "tempfile",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
 name = "overload"
 version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1637,6 +1911,12 @@
 ]
 
 [[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
 name = "pbkdf2"
 version = "0.12.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1674,6 +1954,32 @@
 checksum = "e3aeb8f54c078314c2065ee649a7241f46b9d8e418e1a9581ba0546657d7aa3a"
 
 [[package]]
+name = "pem"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
+dependencies = [
+ "base64 0.22.1",
+ "serde",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "petgraph"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
+dependencies = [
+ "fixedbitset",
+ "indexmap 2.2.6",
+]
+
+[[package]]
 name = "pin-project"
 version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1690,7 +1996,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -1774,6 +2080,16 @@
 checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
 
 [[package]]
+name = "prettyplease"
+version = "0.2.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.76",
+]
+
+[[package]]
 name = "proc-macro-error"
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1807,6 +2123,59 @@
 ]
 
 [[package]]
+name = "prost"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13db3d3fde688c61e2446b4d843bc27a7e8af269a69440c0308021dc92333cc"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bb182580f71dd070f88d01ce3de9f4da5021db7115d2e1c3605a754153b77c1"
+dependencies = [
+ "bytes",
+ "heck 0.5.0",
+ "itertools",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph",
+ "prettyplease",
+ "prost",
+ "prost-types",
+ "regex",
+ "syn 2.0.76",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18bec9b0adc4eba778b33684b7ba3e7137789434769ee3ce3930463ef904cfca"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.76",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cee5168b05f49d4b0ca581206eb14a7b22fafd963efe729ac48eb03266e25cc2"
+dependencies = [
+ "prost",
+]
+
+[[package]]
 name = "quote"
 version = "1.0.36"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1857,6 +2226,19 @@
 ]
 
 [[package]]
+name = "rcgen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54077e1872c46788540de1ea3d7f4ccb1983d12f9aa909b234468676c1a36779"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "yasna",
+]
+
+[[package]]
 name = "redox_syscall"
 version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1910,8 +2292,41 @@
 checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
 
 [[package]]
-name = "remowt-fs"
-version = "0.1.0"
+name = "ring"
+version = "0.17.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom",
+ "libc",
+ "spin 0.9.8",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rmp"
+version = "0.8.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
+dependencies = [
+ "byteorder",
+ "num-traits",
+ "paste",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
+dependencies = [
+ "byteorder",
+ "rmp",
+ "serde",
+]
 
 [[package]]
 name = "rnix"
@@ -1989,7 +2404,7 @@
  "proc-macro2",
  "quote",
  "rust-embed-utils",
- "syn 2.0.66",
+ "syn 2.0.76",
  "walkdir",
 ]
 
@@ -2038,6 +2453,54 @@
 ]
 
 [[package]]
+name = "rustls"
+version = "0.23.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425"
+dependencies = [
+ "base64 0.22.1",
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.102.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84678086bd54edf2b415183ed7a94d0efb049f1b646a33e22a36f3794be6ae56"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
+
+[[package]]
 name = "ryu"
 version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2136,6 +2599,15 @@
 ]
 
 [[package]]
+name = "serde_bytes"
+version = "0.11.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
 name = "serde_derive"
 version = "1.0.203"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2143,16 +2615,17 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
 name = "serde_json"
-version = "1.0.117"
+version = "1.0.127"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3"
+checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad"
 dependencies = [
  "itoa",
+ "memchr",
  "ryu",
  "serde",
 ]
@@ -2279,6 +2752,12 @@
 checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
 
 [[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
+[[package]]
 name = "spki"
 version = "0.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2329,9 +2808,9 @@
 
 [[package]]
 name = "syn"
-version = "2.0.66"
+version = "2.0.76"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5"
+checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2339,6 +2818,18 @@
 ]
 
 [[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
+
+[[package]]
 name = "tabled"
 version = "0.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2385,12 +2876,51 @@
 ]
 
 [[package]]
+name = "terraform-provider-fleet"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "serde",
+ "tf-provider",
+ "tokio",
+]
+
+[[package]]
 name = "text-size"
 version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
 
 [[package]]
+name = "tf-provider"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d80ea2e5f9f54717952d199888aab7e607dc99275ec5221f1259ce7a5f55f5a6"
+dependencies = [
+ "anyhow",
+ "async-stream",
+ "async-trait",
+ "base64 0.22.1",
+ "futures",
+ "prost",
+ "rcgen",
+ "rmp-serde",
+ "serde",
+ "serde_bytes",
+ "serde_json",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "tonic",
+ "tonic-build",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
 name = "thiserror"
 version = "1.0.61"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2407,7 +2937,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -2485,7 +3015,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -2499,6 +3029,29 @@
 ]
 
 [[package]]
+name = "tokio-rustls"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
+dependencies = [
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
 name = "tokio-util"
 version = "0.7.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2547,7 +3100,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c"
 dependencies = [
- "indexmap",
+ "indexmap 2.2.6",
  "serde",
  "serde_spanned",
  "toml_datetime",
@@ -2555,6 +3108,100 @@
 ]
 
 [[package]]
+name = "tonic"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f6ba989e4b2c58ae83d862d3a3e27690b6e3ae630d0deb59f3697f32aa88ad"
+dependencies = [
+ "async-stream",
+ "async-trait",
+ "axum",
+ "base64 0.22.1",
+ "bytes",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-timeout",
+ "hyper-util",
+ "percent-encoding",
+ "pin-project",
+ "prost",
+ "rustls-pemfile",
+ "socket2",
+ "tokio",
+ "tokio-rustls",
+ "tokio-stream",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tonic-build"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe4ee8877250136bd7e3d2331632810a4df4ea5e004656990d8d66d2f5ee8a67"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "prost-build",
+ "quote",
+ "syn 2.0.76",
+]
+
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "indexmap 1.9.3",
+ "pin-project",
+ "pin-project-lite",
+ "rand",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "http",
+ "http-body",
+ "http-body-util",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
 name = "tracing"
 version = "0.1.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2573,7 +3220,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
 
 [[package]]
@@ -2610,6 +3257,16 @@
 ]
 
 [[package]]
+name = "tracing-serde"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1"
+dependencies = [
+ "serde",
+ "tracing-core",
+]
+
+[[package]]
 name = "tracing-subscriber"
 version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2619,15 +3276,24 @@
  "nu-ansi-term",
  "once_cell",
  "regex",
+ "serde",
+ "serde_json",
  "sharded-slab",
  "smallvec",
  "thread_local",
  "tracing",
  "tracing-core",
  "tracing-log",
+ "tracing-serde",
 ]
 
 [[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
 name = "type-map"
 version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2705,6 +3371,12 @@
 ]
 
 [[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
 name = "utf8parse"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2766,6 +3438,15 @@
 ]
 
 [[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
 name = "wasi"
 version = "0.11.0+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2792,7 +3473,7 @@
  "once_cell",
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
  "wasm-bindgen-shared",
 ]
 
@@ -2814,7 +3495,7 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
  "wasm-bindgen-backend",
  "wasm-bindgen-shared",
 ]
@@ -3036,6 +3717,15 @@
 ]
 
 [[package]]
+name = "yasna"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
+dependencies = [
+ "time",
+]
+
+[[package]]
 name = "z85"
 version = "3.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3058,5 +3748,5 @@
 dependencies = [
  "proc-macro2",
  "quote",
- "syn 2.0.66",
+ "syn 2.0.76",
 ]
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -45,6 +45,7 @@
 indicatif = { version = "0.17", optional = true }
 nix-eval.workspace = true
 nom = "7.1.3"
+fleet-base = { version = "0.1.0", path = "../../crates/fleet-base" }
 
 [features]
 # Not quite stable
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/build_systems.rs
1use std::{env::current_dir, os::unix::fs::symlink, path::PathBuf, time::Duration};23use anyhow::{anyhow, Result};4use clap::{Parser, ValueEnum};5use itertools::Itertools as _;6use nix_eval::nix_go;7use tokio::{task::LocalSet, time::sleep};8use tracing::{error, field, info, info_span, warn, Instrument};910use crate::{11	command::MyCommand,12	host::{Config, ConfigHost},13};1415#[derive(Parser)]16pub struct Deploy {17	/// Disable automatic rollback18	#[clap(long)]19	disable_rollback: bool,20	/// Action to execute after system is built21	action: DeployAction,22}2324#[derive(ValueEnum, Clone, Copy)]25enum DeployAction {26	/// Upload derivation, but do not execute the update.27	Upload,28	/// Upload and execute the activation script, old version will be used after reboot.29	Test,30	/// Upload and set as current system profile, but do not execute activation script.31	Boot,32	/// Upload, set current profile, and execute activation script.33	Switch,34}3536impl DeployAction {37	pub(crate) fn name(&self) -> Option<&'static str> {38		match self {39			DeployAction::Upload => None,40			DeployAction::Test => Some("test"),41			DeployAction::Boot => Some("boot"),42			DeployAction::Switch => Some("switch"),43		}44	}45	pub(crate) fn should_switch_profile(&self) -> bool {46		matches!(self, Self::Switch | Self::Boot)47	}48	pub(crate) fn should_activate(&self) -> bool {49		matches!(self, Self::Switch | Self::Test)50	}51	pub(crate) fn should_create_rollback_marker(&self) -> bool {52		// Upload does nothing on the target machine, other than uploading the closure.53		// In boot case we want to have rollback marker prepared, so that the system may rollback itself on the next boot.54		!matches!(self, Self::Upload)55	}56	pub(crate) fn should_schedule_rollback_run(&self) -> bool {57		matches!(self, Self::Switch | Self::Test)58	}59}6061#[derive(Parser, Clone)]62pub struct BuildSystems {63	/// Attribute to build. Systems are deployed from "toplevel" attr, well-known used attributes64	/// are "sdImage"/"isoImage", and your configuration may include any other build attributes.65	#[clap(long, default_value = "toplevel")]66	build_attr: String,67}6869struct Generation {70	id: u32,71	current: bool,72	datetime: String,73}74async fn get_current_generation(host: &ConfigHost) -> Result<Generation> {75	let mut cmd = host.cmd("nix-env").await?;76	cmd.comparg("--profile", "/nix/var/nix/profiles/system")77		.arg("--list-generations");78	// Sudo is required due to --list-generations acquiring lock on the profile.79	let data = cmd.sudo().run_string().await?;80	let generations = data81		.split('\n')82		.map(|e| e.trim())83		.filter(|&l| !l.is_empty())84		.filter_map(|g| {85			let gen: Option<Generation> = try {86				let mut parts = g.split_whitespace();87				let id = parts.next()?;88				let id: u32 = id.parse().ok()?;89				let date = parts.next()?;90				let time = parts.next()?;91				let current = if let Some(current) = parts.next() {92					if current == "(current)" {93						Some(true)94					} else {95						None96					}97				} else {98					Some(false)99				};100				let current = current?;101				if parts.next().is_some() {102					warn!("unexpected text after generation: {g}");103				}104				Generation {105					id,106					current,107					datetime: format!("{date} {time}"),108				}109			};110			if gen.is_none() {111				warn!("bad generation: {g}")112			}113			gen114		})115		.collect::<Vec<_>>();116	let current = generations117		.into_iter()118		.filter(|g| g.current)119		.at_most_one()120		.map_err(|_e| anyhow!("bad list-generations output"))?121		.ok_or_else(|| anyhow!("failed to find generation"))?;122	Ok(current)123}124125async fn deploy_task(126	action: DeployAction,127	host: &ConfigHost,128	built: PathBuf,129	specialisation: Option<String>,130	disable_rollback: bool,131) -> Result<()> {132	let mut failed = false;133	// TODO: Lockfile, to prevent concurrent system switch?134	// TODO: If rollback target exists - bail, it should be removed. Lockfile will not work in case if rollback135	// is scheduler on next boot (default behavior). On current boot - rollback activator will fail due to136	// unit name conflict in systemd-run137	// This code is tied to rollback.nix138	if !disable_rollback && action.should_create_rollback_marker() {139		let _span = info_span!("preparing").entered();140		info!("preparing for rollback");141		let generation = get_current_generation(host).await?;142		info!(143			"rollback target would be {} {}",144			generation.id, generation.datetime145		);146		{147			let mut cmd = host.cmd("sh").await?;148			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));149			if let Err(e) = cmd.sudo().run().await {150				error!("failed to set rollback marker: {e}");151				failed = true;152			}153		}154		// Activation script also starts rollback-watchdog.timer, however, it is possible that it won't be started.155		// Kicking it on manually will work best.156		//157		// There wouldn't be conflict, because here we trigger start of the primary service, and systemd will158		// only allow one instance of it.159160		// TODO: We should also watch how this process is going.161		// After running this command, we have less than 3 minutes to deploy everything,162		// if we fail to perform generation switch in time, then we will still call the activation script, and this may break something.163		// Anyway, reboot will still help in this case.164		if action.should_schedule_rollback_run() {165			let mut cmd = host.cmd("systemd-run").await?;166			cmd.comparg("--on-active", "3min")167				.comparg("--unit", "rollback-watchdog-run")168				.arg("systemctl")169				.arg("start")170				.arg("rollback-watchdog.service");171			if let Err(e) = cmd.sudo().run().await {172				error!("failed to schedule rollback run: {e}");173				failed = true;174			}175		}176	}177178	if action.should_switch_profile() && !failed {179		info!("switching generation");180		let mut cmd = host.cmd("nix-env").await?;181		cmd.comparg("--profile", "/nix/var/nix/profiles/system")182			.comparg("--set", &built);183		if let Err(e) = cmd.sudo().run().await {184			error!("failed to switch generation: {e}");185			failed = true;186		}187	}188189	// FIXME: Connection might be disconnected after activation run190191	if action.should_activate() && !failed {192		let _span = info_span!("activating").entered();193		info!("executing activation script");194		let specialised = if let Some(specialisation) = specialisation {195			let mut specialised = built.join("specialisation");196			specialised.push(specialisation);197			specialised198		} else {199			built.clone()200		};201		let switch_script = specialised.join("bin/switch-to-configuration");202		let mut cmd = host.cmd(switch_script).in_current_span().await?;203		cmd.arg(action.name().expect("upload.should_activate == false"));204		if let Err(e) = cmd.sudo().run().in_current_span().await {205			error!("failed to activate: {e}");206			failed = true;207		}208	}209	if action.should_create_rollback_marker() {210		if !disable_rollback {211			if failed {212				if action.should_schedule_rollback_run() {213					info!("executing rollback");214					if let Err(e) = host215						.systemctl_start("rollback-watchdog.service")216						.instrument(info_span!("rollback"))217						.await218					{219						error!("failed to trigger rollback: {e}")220					}221				}222			} else {223				info!("trying to mark upgrade as successful");224				if let Err(e) = host225					.rm_file("/etc/fleet_rollback_marker", true)226					.in_current_span()227					.await228				{229					error!("failed to remove rollback marker. This is bad, as the system will be rolled back by watchdog: {e}")230				}231			}232			info!("disarming watchdog, just in case");233			if let Err(_e) = host.systemctl_stop("rollback-watchdog.timer").await {234				// It is ok, if there was no reboot - then timer might not be running.235			}236			if action.should_schedule_rollback_run() {237				if let Err(e) = host.systemctl_stop("rollback-watchdog-run.timer").await {238					error!("failed to disarm rollback run: {e}");239				}240			}241		} else if let Err(_e) = host242			.rm_file("/etc/fleet_rollback_marker", true)243			.in_current_span()244			.await245		{246			// Marker might not exist, yet better try to remove it.247		}248	}249	Ok(())250}251252async fn build_task(config: Config, host: String, build_attr: &str) -> Result<PathBuf> {253	info!("building");254	let host = config.host(&host).await?;255	// let action = Action::from(self.subcommand.clone());256	let fleet_config = &config.config_field;257	let nixos = host.nixos_config().await?;258	let drv = nix_go!(nixos.system.build[{ build_attr }]);259	let outputs = drv.build().await.inspect_err(|_| {260			if build_attr == "sdImage" {261				info!("sd-image build failed");262				info!("Make sure you have imported modulesPath/installer/sd-card/sd-image-<arch>[-installer].nix (For installer, you may want to check config)");263			}264		})?;265	let out_output = outputs266		.get("out")267		.ok_or_else(|| anyhow!("system build should produce \"out\" output"))?;268269	Ok(out_output.clone())270}271272impl BuildSystems {273	pub async fn run(self, config: &Config) -> Result<()> {274		let hosts = config.list_hosts().await?;275		let set = LocalSet::new();276		let build_attr = self.build_attr.clone();277		for host in hosts.into_iter() {278			if config.should_skip(&host).await? {279				continue;280			}281			let config = config.clone();282			let span = info_span!("build", host = field::display(&host.name));283			let hostname = host.name;284			let build_attr = build_attr.clone();285			// FIXME: Since the introduction of better-nix-eval,286			// due to single repl used for builds, hosts are waiting for each other to build,287			// instead of building concurrently.288			//289			// Open multiple repls?290			//291			// Create build batcher, which will behave similar to golangs292			// WaitGroup, and start executing once all the build tasks are scheduled?293			// This also allows to cleanup build output, as there will be no longer294			// "waiting for remote machine" messages in the cases when one package is needed for295			// multiple hosts.296			set.spawn_local(297				(async move {298					let built = match build_task(config, hostname.clone(), &build_attr).await {299						Ok(path) => path,300						Err(e) => {301							error!("failed to deploy host: {}", e);302							return;303						}304					};305					// TODO: Handle error306					let mut out = current_dir().expect("cwd exists");307					out.push(format!("built-{}", hostname));308309					info!("linking iso image to {:?}", out);310					if let Err(e) = symlink(built, out) {311						error!("failed to symlink: {e}")312					}313				})314				.instrument(span),315			);316		}317		set.await;318		Ok(())319	}320}321322impl Deploy {323	pub async fn run(self, config: &Config) -> Result<()> {324		let hosts = config.list_hosts().await?;325		let set = LocalSet::new();326		for host in hosts.into_iter() {327			if config.should_skip(&host).await? {328				continue;329			}330			let config = config.clone();331			let span = info_span!("deploy", host = field::display(&host.name));332			let hostname = host.name.clone();333			let local_host = config.local_host();334			// FIXME: Fix repl concurrency (see build-systems)335			set.spawn_local(336				(async move {337					let built = match build_task(config.clone(), hostname.clone(), "toplevel").await338					{339						Ok(path) => path,340						Err(e) => {341							error!("failed to deploy host: {}", e);342							return;343						}344					};345					if !config.is_local(&hostname) {346						info!("uploading system closure");347						{348							// TODO: Move to remote_derivation method.349							// Alternatively, nix store make-content-addressed can be used,350							// at least for the first deployment, to provide trusted store key.351							//352							// It is much slower, yet doesn't require root on the deployer machine.353							let Ok(mut sign) = local_host.cmd("nix").await else {354								error!("failed to setup local");355								return;356							};357							// Private key for host machine is registered in nix-sign.nix358							sign.arg("store")359								.arg("sign")360								.comparg("--key-file", "/etc/nix/private-key")361								.arg("-r")362								.arg(&built);363							if let Err(e) = sign.sudo().run_nix().await {364								warn!("failed to sign store paths: {e}");365							};366						}367						let mut tries = 0;368						loop {369							match host.remote_derivation(&built).await {370								Ok(remote) => {371									assert!(remote == built, "CA derivations aren't implemented");372									break;373								}374								Err(e) if tries < 3 => {375									tries += 1;376									warn!("copy failure ({}/3): {}", tries, e);377									sleep(Duration::from_millis(5000)).await;378								}379								Err(e) => {380									error!("upload failed: {e}");381									return;382								}383							}384						}385					}386					if let Err(e) = deploy_task(387						self.action,388						&host,389						built,390						if let Ok(v) = config.action_attr(&host, "specialisation").await {391							v392						} else {393							error!("unreachable? failed to get specialization");394							return;395						},396						self.disable_rollback,397					)398					.await399					{400						error!("activation failed: {e}");401					}402				})403				.instrument(span),404			);405		}406		set.await;407		Ok(())408	}409}
after · cmds/fleet/src/cmds/build_systems.rs
1use std::{env::current_dir, os::unix::fs::symlink, path::PathBuf, time::Duration};23use anyhow::{anyhow, Result};4use clap::{Parser, ValueEnum};5use fleet_base::{6	host::{Config, ConfigHost},7	opts::FleetOpts,8};9use itertools::Itertools as _;10use nix_eval::nix_go;11use tokio::{task::LocalSet, time::sleep};12use tracing::{error, field, info, info_span, warn, Instrument};1314#[derive(Parser)]15pub struct Deploy {16	/// Disable automatic rollback17	#[clap(long)]18	disable_rollback: bool,19	/// Action to execute after system is built20	action: DeployAction,21}2223#[derive(ValueEnum, Clone, Copy)]24enum DeployAction {25	/// Upload derivation, but do not execute the update.26	Upload,27	/// Upload and execute the activation script, old version will be used after reboot.28	Test,29	/// Upload and set as current system profile, but do not execute activation script.30	Boot,31	/// Upload, set current profile, and execute activation script.32	Switch,33}3435impl DeployAction {36	pub(crate) fn name(&self) -> Option<&'static str> {37		match self {38			DeployAction::Upload => None,39			DeployAction::Test => Some("test"),40			DeployAction::Boot => Some("boot"),41			DeployAction::Switch => Some("switch"),42		}43	}44	pub(crate) fn should_switch_profile(&self) -> bool {45		matches!(self, Self::Switch | Self::Boot)46	}47	pub(crate) fn should_activate(&self) -> bool {48		matches!(self, Self::Switch | Self::Test)49	}50	pub(crate) fn should_create_rollback_marker(&self) -> bool {51		// Upload does nothing on the target machine, other than uploading the closure.52		// In boot case we want to have rollback marker prepared, so that the system may rollback itself on the next boot.53		!matches!(self, Self::Upload)54	}55	pub(crate) fn should_schedule_rollback_run(&self) -> bool {56		matches!(self, Self::Switch | Self::Test)57	}58}5960#[derive(Parser, Clone)]61pub struct BuildSystems {62	/// Attribute to build. Systems are deployed from "toplevel" attr, well-known used attributes63	/// are "sdImage"/"isoImage", and your configuration may include any other build attributes.64	#[clap(long, default_value = "toplevel")]65	build_attr: String,66}6768struct Generation {69	id: u32,70	current: bool,71	datetime: String,72}73async fn get_current_generation(host: &ConfigHost) -> Result<Generation> {74	let mut cmd = host.cmd("nix-env").await?;75	cmd.comparg("--profile", "/nix/var/nix/profiles/system")76		.arg("--list-generations");77	// Sudo is required due to --list-generations acquiring lock on the profile.78	let data = cmd.sudo().run_string().await?;79	let generations = data80		.split('\n')81		.map(|e| e.trim())82		.filter(|&l| !l.is_empty())83		.filter_map(|g| {84			let gen: Option<Generation> = try {85				let mut parts = g.split_whitespace();86				let id = parts.next()?;87				let id: u32 = id.parse().ok()?;88				let date = parts.next()?;89				let time = parts.next()?;90				let current = if let Some(current) = parts.next() {91					if current == "(current)" {92						Some(true)93					} else {94						None95					}96				} else {97					Some(false)98				};99				let current = current?;100				if parts.next().is_some() {101					warn!("unexpected text after generation: {g}");102				}103				Generation {104					id,105					current,106					datetime: format!("{date} {time}"),107				}108			};109			if gen.is_none() {110				warn!("bad generation: {g}")111			}112			gen113		})114		.collect::<Vec<_>>();115	let current = generations116		.into_iter()117		.filter(|g| g.current)118		.at_most_one()119		.map_err(|_e| anyhow!("bad list-generations output"))?120		.ok_or_else(|| anyhow!("failed to find generation"))?;121	Ok(current)122}123124async fn deploy_task(125	action: DeployAction,126	host: &ConfigHost,127	built: PathBuf,128	specialisation: Option<String>,129	disable_rollback: bool,130) -> Result<()> {131	let mut failed = false;132	// TODO: Lockfile, to prevent concurrent system switch?133	// TODO: If rollback target exists - bail, it should be removed. Lockfile will not work in case if rollback134	// is scheduler on next boot (default behavior). On current boot - rollback activator will fail due to135	// unit name conflict in systemd-run136	// This code is tied to rollback.nix137	if !disable_rollback && action.should_create_rollback_marker() {138		let _span = info_span!("preparing").entered();139		info!("preparing for rollback");140		let generation = get_current_generation(host).await?;141		info!(142			"rollback target would be {} {}",143			generation.id, generation.datetime144		);145		{146			let mut cmd = host.cmd("sh").await?;147			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));148			if let Err(e) = cmd.sudo().run().await {149				error!("failed to set rollback marker: {e}");150				failed = true;151			}152		}153		// Activation script also starts rollback-watchdog.timer, however, it is possible that it won't be started.154		// Kicking it on manually will work best.155		//156		// There wouldn't be conflict, because here we trigger start of the primary service, and systemd will157		// only allow one instance of it.158159		// TODO: We should also watch how this process is going.160		// After running this command, we have less than 3 minutes to deploy everything,161		// if we fail to perform generation switch in time, then we will still call the activation script, and this may break something.162		// Anyway, reboot will still help in this case.163		if action.should_schedule_rollback_run() {164			let mut cmd = host.cmd("systemd-run").await?;165			cmd.comparg("--on-active", "3min")166				.comparg("--unit", "rollback-watchdog-run")167				.arg("systemctl")168				.arg("start")169				.arg("rollback-watchdog.service");170			if let Err(e) = cmd.sudo().run().await {171				error!("failed to schedule rollback run: {e}");172				failed = true;173			}174		}175	}176177	if action.should_switch_profile() && !failed {178		info!("switching generation");179		let mut cmd = host.cmd("nix-env").await?;180		cmd.comparg("--profile", "/nix/var/nix/profiles/system")181			.comparg("--set", &built);182		if let Err(e) = cmd.sudo().run().await {183			error!("failed to switch generation: {e}");184			failed = true;185		}186	}187188	// FIXME: Connection might be disconnected after activation run189190	if action.should_activate() && !failed {191		let _span = info_span!("activating").entered();192		info!("executing activation script");193		let specialised = if let Some(specialisation) = specialisation {194			let mut specialised = built.join("specialisation");195			specialised.push(specialisation);196			specialised197		} else {198			built.clone()199		};200		let switch_script = specialised.join("bin/switch-to-configuration");201		let mut cmd = host.cmd(switch_script).in_current_span().await?;202		cmd.arg(action.name().expect("upload.should_activate == false"));203		if let Err(e) = cmd.sudo().run().in_current_span().await {204			error!("failed to activate: {e}");205			failed = true;206		}207	}208	if action.should_create_rollback_marker() {209		if !disable_rollback {210			if failed {211				if action.should_schedule_rollback_run() {212					info!("executing rollback");213					if let Err(e) = host214						.systemctl_start("rollback-watchdog.service")215						.instrument(info_span!("rollback"))216						.await217					{218						error!("failed to trigger rollback: {e}")219					}220				}221			} else {222				info!("trying to mark upgrade as successful");223				if let Err(e) = host224					.rm_file("/etc/fleet_rollback_marker", true)225					.in_current_span()226					.await227				{228					error!("failed to remove rollback marker. This is bad, as the system will be rolled back by watchdog: {e}")229				}230			}231			info!("disarming watchdog, just in case");232			if let Err(_e) = host.systemctl_stop("rollback-watchdog.timer").await {233				// It is ok, if there was no reboot - then timer might not be running.234			}235			if action.should_schedule_rollback_run() {236				if let Err(e) = host.systemctl_stop("rollback-watchdog-run.timer").await {237					error!("failed to disarm rollback run: {e}");238				}239			}240		} else if let Err(_e) = host241			.rm_file("/etc/fleet_rollback_marker", true)242			.in_current_span()243			.await244		{245			// Marker might not exist, yet better try to remove it.246		}247	}248	Ok(())249}250251async fn build_task(config: Config, host: String, build_attr: &str) -> Result<PathBuf> {252	info!("building");253	let host = config.host(&host).await?;254	// let action = Action::from(self.subcommand.clone());255	let nixos = host.nixos_config().await?;256	let drv = nix_go!(nixos.system.build[{ build_attr }]);257	let outputs = drv.build().await.inspect_err(|_| {258			if build_attr == "sdImage" {259				info!("sd-image build failed");260				info!("Make sure you have imported modulesPath/installer/sd-card/sd-image-<arch>[-installer].nix (For installer, you may want to check config)");261			}262		})?;263	let out_output = outputs264		.get("out")265		.ok_or_else(|| anyhow!("system build should produce \"out\" output"))?;266267	Ok(out_output.clone())268}269270impl BuildSystems {271	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {272		let hosts = config.list_hosts().await?;273		let set = LocalSet::new();274		let build_attr = self.build_attr.clone();275		for host in hosts.into_iter() {276			if opts.should_skip(&host).await? {277				continue;278			}279			let config = config.clone();280			let span = info_span!("build", host = field::display(&host.name));281			let hostname = host.name;282			let build_attr = build_attr.clone();283			// FIXME: Since the introduction of better-nix-eval,284			// due to single repl used for builds, hosts are waiting for each other to build,285			// instead of building concurrently.286			//287			// Open multiple repls?288			//289			// Create build batcher, which will behave similar to golangs290			// WaitGroup, and start executing once all the build tasks are scheduled?291			// This also allows to cleanup build output, as there will be no longer292			// "waiting for remote machine" messages in the cases when one package is needed for293			// multiple hosts.294			set.spawn_local(295				(async move {296					let built = match build_task(config, hostname.clone(), &build_attr).await {297						Ok(path) => path,298						Err(e) => {299							error!("failed to deploy host: {}", e);300							return;301						}302					};303					// TODO: Handle error304					let mut out = current_dir().expect("cwd exists");305					out.push(format!("built-{}", hostname));306307					info!("linking iso image to {:?}", out);308					if let Err(e) = symlink(built, out) {309						error!("failed to symlink: {e}")310					}311				})312				.instrument(span),313			);314		}315		set.await;316		Ok(())317	}318}319320impl Deploy {321	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {322		let hosts = config.list_hosts().await?;323		let set = LocalSet::new();324		for host in hosts.into_iter() {325			if opts.should_skip(&host).await? {326				continue;327			}328			let config = config.clone();329			let span = info_span!("deploy", host = field::display(&host.name));330			let hostname = host.name.clone();331			let local_host = config.local_host();332			let opts = opts.clone();333			// FIXME: Fix repl concurrency (see build-systems)334			set.spawn_local(335				(async move {336					let built = match build_task(config.clone(), hostname.clone(), "toplevel").await337					{338						Ok(path) => path,339						Err(e) => {340							error!("failed to deploy host: {}", e);341							return;342						}343					};344					if !opts.is_local(&hostname) {345						info!("uploading system closure");346						{347							// TODO: Move to remote_derivation method.348							// Alternatively, nix store make-content-addressed can be used,349							// at least for the first deployment, to provide trusted store key.350							//351							// It is much slower, yet doesn't require root on the deployer machine.352							let Ok(mut sign) = local_host.cmd("nix").await else {353								error!("failed to setup local");354								return;355							};356							// Private key for host machine is registered in nix-sign.nix357							sign.arg("store")358								.arg("sign")359								.comparg("--key-file", "/etc/nix/private-key")360								.arg("-r")361								.arg(&built);362							if let Err(e) = sign.sudo().run_nix().await {363								warn!("failed to sign store paths: {e}");364							};365						}366						let mut tries = 0;367						loop {368							match host.remote_derivation(&built).await {369								Ok(remote) => {370									assert!(remote == built, "CA derivations aren't implemented");371									break;372								}373								Err(e) if tries < 3 => {374									tries += 1;375									warn!("copy failure ({}/3): {}", tries, e);376									sleep(Duration::from_millis(5000)).await;377								}378								Err(e) => {379									error!("upload failed: {e}");380									return;381								}382							}383						}384					}385					if let Err(e) = deploy_task(386						self.action,387						&host,388						built,389						if let Ok(v) = opts.action_attr(&host, "specialisation").await {390							v391						} else {392							error!("unreachable? failed to get specialization");393							return;394						},395						self.disable_rollback,396					)397					.await398					{399						error!("activation failed: {e}");400					}401				})402				.instrument(span),403			);404		}405		set.await;406		Ok(())407	}408}
modifiedcmds/fleet/src/cmds/info.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/info.rs
+++ b/cmds/fleet/src/cmds/info.rs
@@ -2,9 +2,8 @@
 
 use anyhow::{ensure, Result};
 use clap::Parser;
+use fleet_base::host::Config;
 use nix_eval::nix_go_json;
-
-use crate::host::Config;
 
 #[derive(Parser)]
 pub struct Info {
@@ -39,8 +38,7 @@
 				'host: for host in config.list_hosts().await? {
 					if !tagged.is_empty() {
 						let config = &config.config_field;
-						let tags: Vec<String> =
-							nix_go_json!(config.hosts[{ host.name }].tags);
+						let tags: Vec<String> = nix_go_json!(config.hosts[{ host.name }].tags);
 						for tag in tagged {
 							if !tags.contains(tag) {
 								continue 'host;
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -1,6 +1,5 @@
 use std::{
 	collections::{BTreeMap, BTreeSet, HashSet},
-	ffi::OsString,
 	io::{self, stdin, stdout, Read, Write},
 	path::PathBuf,
 };
@@ -8,21 +7,19 @@
 use anyhow::{anyhow, bail, ensure, Context, Result};
 use chrono::{DateTime, Utc};
 use clap::Parser;
-use crossterm::{terminal, tty::IsTty};
+use fleet_base::{
+	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},
+	host::Config,
+	opts::FleetOpts,
+};
 use fleet_shared::SecretData;
-use itertools::Itertools;
 use nix_eval::{nix_go, nix_go_json, Value};
 use owo_colors::OwoColorize;
 use serde::Deserialize;
 use tabled::{Table, Tabled};
-use tokio::{fs::read, process::Command};
+use tokio::fs::read;
 use tracing::{error, info, info_span, warn, Instrument};
 
-use crate::{
-	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},
-	host::Config,
-};
-
 #[derive(Parser)]
 pub enum Secret {
 	/// Force load host keys for all defined hosts
@@ -432,11 +429,11 @@
 	Ok(target_machines)
 }
 impl Secret {
-	pub async fn run(self, config: &Config) -> Result<()> {
+	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
 		match self {
 			Secret::ForceKeys => {
 				for host in config.list_hosts().await? {
-					if config.should_skip(&host).await? {
+					if opts.should_skip(&host).await? {
 						continue;
 					}
 					config.key(&host.name).await?;
@@ -639,7 +636,7 @@
 					}
 				}
 				for host in config.list_hosts().await? {
-					if config.should_skip(&host).await? {
+					if opts.should_skip(&host).await? {
 						continue;
 					}
 
@@ -757,6 +754,7 @@
 	}
 }
 
+/*
 async fn edit_temp_file(
 	builder: tempfile::Builder<'_, '_>,
 	r: Vec<u8>,
@@ -835,3 +833,4 @@
 
 	// Ok((success, abs_path))
 }
+*/
modifiedcmds/fleet/src/cmds/tf.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/tf.rs
+++ b/cmds/fleet/src/cmds/tf.rs
@@ -1,22 +1,67 @@
-use anyhow::Result;
+use std::{
+	collections::{BTreeMap, HashMap},
+	path::PathBuf,
+};
+
+use anyhow::{bail, Context, Result};
 use clap::Parser;
-use nix_eval::nix_go_json;
+use fleet_base::host::Config;
+use nix_eval::nix_go;
+use serde::Deserialize;
 use serde_json::Value;
-use tokio::fs::write;
-use tracing::info;
+use tokio::{fs::copy, process::Command};
 
-use crate::host::Config;
+#[derive(Deserialize)]
+pub struct TfData {
+	// Dummy
+	#[allow(dead_code)]
+	managed: bool,
+	// Host => Data
+	#[serde(default)]
+	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
+	pub hosts: BTreeMap<String, Value>,
+}
 
 #[derive(Parser)]
-pub struct Tf;
+pub enum Tf {
+	/// Generate fleet.tf.json file for running terraform.
+	Generate,
+	/// Fetch data from terraform to fleet.
+	Refresh,
+}
 impl Tf {
 	pub async fn run(&self, config: &Config) -> Result<()> {
-		let system = &config.local_system;
-		let config = &config.config_field;
-		let data: Value = nix_go_json!(config.tf({ system }).config);
-		let str = serde_json::to_string_pretty(&data)?;
+		match self {
+			Tf::Generate => {
+				let system = &config.local_system;
+				let config = &config.config_field;
+				let data: HashMap<String, PathBuf> = nix_go!(config.tf({ system })).build().await?;
+				let data = &data["out"];
+
+				copy(data, "fleet.tf.json").await?;
+			}
+			Tf::Refresh => {
+				let cmd = Command::new("terraform").arg("refresh").status().await?;
+				if !cmd.success() {
+					bail!("terraform refresh failed")
+				}
 
-		write("fleet.tf.json", str.as_bytes()).await?;
+				let data = Command::new("terraform")
+					.arg("output")
+					.arg("-json")
+					.arg("fleet")
+					.output()
+					.await?;
+				let tf_data: TfData = serde_json::from_slice(&data.stdout)
+					.context("failed to parse terraform fleet output")?;
+
+				let mut data = config.data();
+				data.extra.insert(
+					"terraformHosts".to_owned(),
+					serde_json::to_value(tf_data.hosts).expect("should be valid extra"),
+				);
+			}
+		}
 
 		Ok(())
 	}
deletedcmds/fleet/src/command.rsdiffbeforeafterboth
--- a/cmds/fleet/src/command.rs
+++ /dev/null
@@ -1,430 +0,0 @@
-use std::{ffi::OsStr, pin, process::Stdio, sync::Arc, task::Poll};
-
-use anyhow::{anyhow, Result};
-use better_command::{Handler, NixHandler, PlainHandler};
-use futures::StreamExt;
-use itertools::Either;
-use openssh::{OverSsh, OwningCommand, Session};
-use tokio::{io::AsyncRead, process::Command, select};
-use tokio_util::codec::{BytesCodec, FramedRead, LinesCodec};
-use tracing::debug;
-
-use crate::host::EscalationStrategy;
-
-fn escape_bash(input: &str, out: &mut String) {
-	const TO_ESCAPE: &str = "$ !\"#&'()*,;<>?[\\]^`{|}";
-	if input.chars().all(|c| !TO_ESCAPE.contains(c)) {
-		out.push_str(input);
-		return;
-	}
-	out.push('\'');
-	for (i, v) in input.split('\'').enumerate() {
-		if i != 0 {
-			out.push_str("'\"'\"'");
-		}
-		out.push_str(v);
-	}
-	out.push('\'');
-}
-fn ostoutf8(os: impl AsRef<OsStr>) -> String {
-	os.as_ref().to_str().expect("non-utf8 data").to_owned()
-}
-
-#[derive(Clone, Debug)]
-pub struct MyCommand {
-	command: String,
-	args: Vec<String>,
-	env: Vec<(String, String)>,
-	ssh_session: Option<Arc<Session>>,
-	escalation: EscalationStrategy,
-	escalate: bool,
-}
-impl MyCommand {
-	pub fn new_on(
-		escalation: EscalationStrategy,
-		cmd: impl AsRef<OsStr>,
-		session: Arc<Session>,
-	) -> Self {
-		assert!(!cmd.as_ref().is_empty());
-		Self {
-			command: ostoutf8(cmd),
-			args: vec![],
-			env: vec![],
-			ssh_session: Some(session),
-			escalation,
-			escalate: false,
-		}
-	}
-	pub fn new(escalation: EscalationStrategy, cmd: impl AsRef<OsStr>) -> Self {
-		assert!(!cmd.as_ref().is_empty());
-		Self {
-			command: ostoutf8(cmd),
-			args: vec![],
-			env: vec![],
-			ssh_session: None,
-			escalation,
-			escalate: false,
-		}
-	}
-	fn new_here(&self, cmd: impl AsRef<OsStr>) -> Self {
-		if let Some(ssh_session) = self.ssh_session.clone() {
-			Self::new_on(self.escalation, cmd, ssh_session)
-		} else {
-			Self::new(self.escalation, cmd)
-		}
-	}
-
-	fn into_args(self) -> Vec<String> {
-		let mut out = Vec::new();
-		if !self.env.is_empty() {
-			out.push("env".to_owned());
-			for (k, v) in self.env {
-				assert!(!k.contains('='));
-				out.push(format!("{k}={v}"));
-			}
-		}
-		out.push(self.command);
-		out.extend(self.args);
-		out
-	}
-
-	/// Translates environment variables into env command execution.
-	/// Required for ssh, as ssh don't allow to send environment variables (at least by default).
-	///
-	/// FIXME: Insecure, as arguments might be seen by other users on the same machine.
-	/// Figure out some way to transfer environment using stdio?
-	fn translate_env_into_env(self) -> Self {
-		if self.env.is_empty() {
-			return self;
-		}
-		let mut out = self.new_here("env");
-		for (k, v) in self.env {
-			assert!(!k.contains('='));
-			out.arg(format!("{k}={v}"));
-		}
-		out.arg(self.command);
-		out.args(self.args);
-
-		out
-	}
-	fn into_string(self) -> String {
-		let mut out = String::new();
-		if !self.env.is_empty() {
-			out.push_str("env");
-			for (k, v) in self.env {
-				out.push(' ');
-				assert!(!k.contains('='));
-				escape_bash(&k, &mut out);
-				out.push('=');
-				escape_bash(&v, &mut out);
-			}
-		}
-		if !out.is_empty() {
-			out.push(' ');
-		}
-		escape_bash(&self.command, &mut out);
-		for arg in self.args {
-			out.push(' ');
-			escape_bash(&arg, &mut out);
-		}
-		out
-	}
-	fn into_command(self) -> Command {
-		let mut out = Command::new(self.command);
-		out.args(self.args);
-		for (k, v) in self.env {
-			out.env(k, v);
-		}
-		out
-	}
-	fn into_command_new(self) -> Result<Either<Command, openssh::OwningCommand<Arc<Session>>>> {
-		Ok(if let Some(session) = self.ssh_session.clone() {
-			let cmd = self.translate_env_into_env().into_command();
-			Either::Right(
-				cmd.over_ssh(session)
-					.map_err(|e| anyhow!("ssh error: {e}"))?,
-			)
-		} else {
-			let cmd = self.into_command();
-			Either::Left(cmd)
-		})
-	}
-	pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
-		let arg = arg.as_ref();
-		self.args.push(ostoutf8(arg));
-		self
-	}
-	pub fn eqarg(&mut self, arg: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
-		let arg = arg.as_ref();
-		let value = value.as_ref();
-		let arg = ostoutf8(arg);
-		let value = ostoutf8(value);
-		self.arg(format!("{arg}={value}"));
-		self
-	}
-	pub fn comparg(&mut self, arg: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
-		self.arg(arg);
-		self.arg(value);
-		self
-	}
-	pub fn env(&mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> &mut Self {
-		self.env
-			.push((name.as_ref().to_owned(), value.as_ref().to_owned()));
-		self
-	}
-	pub fn args<V: AsRef<OsStr>>(&mut self, args: impl IntoIterator<Item = V>) -> &mut Self {
-		for arg in args.into_iter() {
-			let arg = arg.as_ref();
-			self.args.push(ostoutf8(arg));
-		}
-		self
-	}
-	pub fn sudo(mut self) -> Self {
-		self.escalate = true;
-		self
-	}
-	fn wrap_sudo_if_needed(self) -> Self {
-		if !self.escalate {
-			return self;
-		}
-		match self.escalation {
-			EscalationStrategy::Su => {
-				let mut out = self.new_here("su");
-				out.arg("-c").arg(self.into_string());
-				out
-			}
-			EscalationStrategy::Sudo => {
-				let mut out = self.new_here("sudo");
-				out.args(self.into_args());
-				out
-			}
-			EscalationStrategy::Run0 => {
-				// run0 wants interactive authentication by default.
-				let mut run0 = self.new_here("run0");
-				let mut out = self.new_here("script");
-
-				// Red backgrounds messes with fleet formatting
-				run0.arg("--background=");
-				run0.args(self.into_args());
-
-				out.arg("-q");
-				out.arg("/dev/null");
-				out.arg("-c");
-				out.arg(run0.into_string());
-				dbg!(&out);
-				out
-			}
-		}
-	}
-
-	pub async fn run(self) -> Result<()> {
-		let str = self.clone().into_string();
-		let cmd = self.wrap_sudo_if_needed().into_command_new()?;
-		match cmd {
-			Either::Left(cmd) => run_nix_inner(str, cmd, &mut PlainHandler).await?,
-			Either::Right(cmd) => run_nix_inner_ssh(str, cmd, &mut PlainHandler).await?,
-		};
-		Ok(())
-	}
-	pub async fn run_string(self) -> Result<String> {
-		let bytes = self.run_bytes().await?;
-		Ok(String::from_utf8(bytes)?)
-	}
-	pub async fn run_bytes(self) -> Result<Vec<u8>> {
-		let str = self.clone().into_string();
-		let cmd = self.wrap_sudo_if_needed().into_command_new()?;
-		let v = match cmd {
-			Either::Left(cmd) => run_nix_inner_stdout(str, cmd, &mut PlainHandler).await?,
-			Either::Right(cmd) => run_nix_inner_stdout_ssh(str, cmd, &mut PlainHandler).await?,
-		};
-		Ok(v)
-	}
-
-	pub async fn run_nix_string(mut self) -> Result<String> {
-		let str = self.clone().into_string();
-		self.arg("--log-format").arg("internal-json");
-		let mut cmd = self.wrap_sudo_if_needed().into_command();
-		let bytes = run_nix_inner_stdout(str, cmd, &mut NixHandler::default()).await?;
-		Ok(String::from_utf8(bytes)?)
-	}
-	pub async fn run_nix(mut self) -> Result<()> {
-		let str = self.clone().into_string();
-		self.arg("--log-format").arg("internal-json");
-		let mut cmd = self.wrap_sudo_if_needed().into_command();
-		cmd.stdout(Stdio::inherit());
-		run_nix_inner(str, cmd, &mut NixHandler::default()).await
-	}
-}
-
-struct EmptyAsyncRead;
-impl AsyncRead for EmptyAsyncRead {
-	fn poll_read(
-		self: std::pin::Pin<&mut Self>,
-		_cx: &mut std::task::Context<'_>,
-		_buf: &mut tokio::io::ReadBuf<'_>,
-	) -> Poll<std::io::Result<()>> {
-		Poll::Pending
-	}
-}
-
-async fn run_nix_inner_stdout(
-	str: String,
-	cmd: Command,
-	handler: &mut dyn Handler,
-) -> Result<Vec<u8>> {
-	Ok(run_nix_inner_raw(str, cmd, true, handler, None)
-		.await?
-		.expect("has out"))
-}
-async fn run_nix_inner(str: String, cmd: Command, handler: &mut dyn Handler) -> Result<()> {
-	let v = run_nix_inner_raw(str, cmd, false, handler, None).await?;
-	assert!(v.is_none());
-	Ok(())
-}
-async fn run_nix_inner_stdout_ssh(
-	str: String,
-	cmd: OwningCommand<Arc<Session>>,
-	handler: &mut dyn Handler,
-) -> Result<Vec<u8>> {
-	Ok(run_nix_inner_raw_ssh(str, cmd, true, handler, None)
-		.await?
-		.expect("has out"))
-}
-async fn run_nix_inner_ssh(
-	str: String,
-	cmd: OwningCommand<Arc<Session>>,
-	handler: &mut dyn Handler,
-) -> Result<()> {
-	let v = run_nix_inner_raw_ssh(str, cmd, false, handler, None).await?;
-	assert!(v.is_none());
-	Ok(())
-}
-
-async fn run_nix_inner_raw(
-	str: String,
-	mut cmd: Command,
-	want_stdout: bool,
-	err_handler: &mut dyn Handler,
-	mut out_handler: Option<&mut dyn Handler>,
-) -> Result<Option<Vec<u8>>> {
-	cmd.stderr(Stdio::piped());
-	cmd.stdout(Stdio::piped());
-	debug!("running command {str:?} on local");
-	let mut child = cmd.spawn()?;
-	let mut stderr = child.stderr.take().unwrap();
-	let stdout = child.stdout.take().unwrap();
-	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
-	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
-	let mut ob = want_stdout
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ol = (!want_stdout)
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ob = FramedRead::new(&mut ob, BytesCodec::new());
-	let mut ol = FramedRead::new(&mut ol, LinesCodec::new());
-
-	// while let Some(line) = read.next().await? {}
-
-	let mut out_buf = if want_stdout { Some(vec![]) } else { None };
-	loop {
-		select! {
-			e = err.next() => {
-				if let Some(e) = e {
-					let e = e?;
-					err_handler.handle_line(&e);
-				}
-			},
-			o = ob.next() => {
-				if let Some(o) = o {
-					out_buf.as_mut().expect("stdout == wants_stdout").extend_from_slice(&o?);
-				}
-			},
-			o = ol.next() => {
-				if let Some(o) = o {
-					let o = o?;
-					if let Some(out) = out_handler.as_mut() {
-						out.handle_line(&o)
-					} else {
-						err_handler.handle_line(&o)
-					}
-					// out_handler.handle_info(&o);
-				}
-			},
-			code = child.wait() => {
-				let code = code?;
-				if !code.success() {
-					anyhow::bail!("command '{str}' failed with status {}", code);
-				}
-				break;
-			}
-		}
-	}
-
-	Ok(out_buf)
-}
-async fn run_nix_inner_raw_ssh(
-	str: String,
-	mut cmd: OwningCommand<Arc<Session>>,
-	want_stdout: bool,
-	err_handler: &mut dyn Handler,
-	mut out_handler: Option<&mut dyn Handler>,
-) -> Result<Option<Vec<u8>>> {
-	debug!("running command {str:?} over ssh");
-	cmd.stderr(openssh::Stdio::piped());
-	cmd.stdout(openssh::Stdio::piped());
-	let mut child = cmd.spawn().await?;
-	let mut stderr = child.stderr().take().unwrap();
-	let stdout = child.stdout().take().unwrap();
-	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
-	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
-	let mut ob = want_stdout
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ol = (!want_stdout)
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ob = FramedRead::new(&mut ob, BytesCodec::new());
-	let mut ol = FramedRead::new(&mut ol, LinesCodec::new());
-
-	// while let Some(line) = read.next().await? {}
-
-	let mut out_buf = if want_stdout { Some(vec![]) } else { None };
-
-	let mut wait_future = pin::pin!(child.wait());
-	loop {
-		select! {
-			e = err.next() => {
-				if let Some(e) = e {
-					let e = e?;
-					err_handler.handle_line(&e);
-				}
-			},
-			o = ob.next() => {
-				if let Some(o) = o {
-					out_buf.as_mut().expect("stdout == wants_stdout").extend_from_slice(&o?);
-				}
-			},
-			o = ol.next() => {
-				if let Some(o) = o {
-					let o = o?;
-					if let Some(out) = out_handler.as_mut() {
-						out.handle_line(&o)
-					} else {
-						err_handler.handle_line(&o)
-					}
-					// out_handler.handle_info(&o);
-				}
-			},
-			code = &mut wait_future => {
-				let code = code?;
-				if !code.success() {
-					anyhow::bail!("command '{str}' failed with status {}", code);
-				}
-				break;
-			}
-		}
-	}
-
-	Ok(out_buf)
-}
deletedcmds/fleet/src/fleetdata.rsdiffbeforeafterboth
--- a/cmds/fleet/src/fleetdata.rs
+++ /dev/null
@@ -1,107 +0,0 @@
-use std::{
-	collections::BTreeMap,
-	io::{self, Cursor},
-};
-
-use age::Recipient;
-use chrono::{DateTime, Utc};
-use fleet_shared::SecretData;
-use itertools::Itertools;
-use serde::{de::Error, Deserialize, Serialize};
-
-#[derive(Serialize, Deserialize, Default)]
-#[serde(rename_all = "camelCase")]
-pub struct HostData {
-	#[serde(default)]
-	#[serde(skip_serializing_if = "String::is_empty")]
-	pub encryption_key: String,
-}
-
-const VERSION: &str = "0.1.0";
-pub struct FleetDataVersion;
-impl Serialize for FleetDataVersion {
-	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-	where
-		S: serde::Serializer,
-	{
-		VERSION.serialize(serializer)
-	}
-}
-impl<'de> Deserialize<'de> for FleetDataVersion {
-	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-	where
-		D: serde::Deserializer<'de>,
-	{
-		let version = String::deserialize(deserializer)?;
-		if version != VERSION {
-			return Err(D::Error::custom(format!(
-				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"
-			)));
-		}
-		Ok(Self)
-	}
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct FleetData {
-	pub version: FleetDataVersion,
-
-	#[serde(default)]
-	pub hosts: BTreeMap<String, HostData>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub shared_secrets: BTreeMap<String, FleetSharedSecret>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub host_secrets: BTreeMap<String, BTreeMap<String, FleetSecret>>,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-#[serde(rename_all = "camelCase")]
-#[must_use]
-pub struct FleetSharedSecret {
-	pub owners: Vec<String>,
-	#[serde(flatten)]
-	pub secret: FleetSecret,
-}
-
-/// Returns None if recipients.is_empty()
-pub fn encrypt_secret_data(
-	recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
-	data: Vec<u8>,
-) -> Option<SecretData> {
-	let mut encrypted = vec![];
-	let recipients = recipients
-		.into_iter()
-		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
-		.collect_vec();
-	let mut encryptor = age::Encryptor::with_recipients(recipients)?
-		.wrap_output(&mut encrypted)
-		.expect("in memory write");
-	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
-	encryptor.finish().expect("in memory flush");
-	Some(SecretData {
-		data: encrypted,
-		encrypted: true,
-	})
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-pub struct FleetSecretPart {
-	pub raw: SecretData,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-#[serde(rename_all = "camelCase")]
-#[must_use]
-pub struct FleetSecret {
-	#[serde(default = "Utc::now")]
-	pub created_at: DateTime<Utc>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]
-	pub expires_at: Option<DateTime<Utc>>,
-
-	#[serde(flatten)]
-	pub parts: BTreeMap<String, FleetSecretPart>,
-}
deletedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ /dev/null
@@ -1,630 +0,0 @@
-use std::{
-	cell::{LazyCell, OnceCell},
-	collections::BTreeMap,
-	env::current_dir,
-	ffi::{OsStr, OsString},
-	fmt::Display,
-	io::Write,
-	ops::Deref,
-	path::PathBuf,
-	str::FromStr,
-	sync::{Arc, Mutex, MutexGuard, OnceLock},
-};
-
-use anyhow::{anyhow, bail, ensure, Context, Result};
-use clap::Parser;
-use fleet_shared::SecretData;
-use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSessionPool, Value};
-use nom::{
-	bytes::complete::take_while1,
-	character::complete::char,
-	combinator::{map, opt},
-	multi::separated_list1,
-	sequence::{preceded, separated_pair},
-};
-use openssh::SessionBuilder;
-use serde::de::DeserializeOwned;
-use tempfile::NamedTempFile;
-use tracing::error;
-
-use crate::{
-	command::MyCommand,
-	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
-};
-
-pub struct FleetConfigInternals {
-	pub local_system: String,
-	pub directory: PathBuf,
-	pub opts: FleetOpts,
-	pub data: Mutex<FleetData>,
-	pub nix_args: Vec<OsString>,
-	/// fleet_config.config
-	pub config_field: Value,
-
-	/// import nixpkgs {system = local};
-	pub default_pkgs: Value,
-}
-
-#[derive(Clone)]
-pub struct Config(Arc<FleetConfigInternals>);
-
-impl Deref for Config {
-	type Target = FleetConfigInternals;
-
-	fn deref(&self) -> &Self::Target {
-		&self.0
-	}
-}
-
-#[derive(Clone, Copy, Debug)]
-pub enum EscalationStrategy {
-	Sudo,
-	Run0,
-	Su,
-}
-
-pub struct ConfigHost {
-	config: Config,
-	pub name: String,
-	pub local: bool,
-	pub session: OnceLock<Arc<openssh::Session>>,
-	groups: OnceCell<Vec<String>>,
-
-	pub host_config: Option<Value>,
-	pub nixos_config: OnceCell<Value>,
-}
-impl ConfigHost {
-	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {
-		// Prefer sudo, as run0 has some gotchas with polkit
-		// and too many repeating prompts.
-		if let Ok(_) = self.find_in_path("sudo").await {
-			return Ok(EscalationStrategy::Sudo);
-		}
-		if let Ok(_) = self.find_in_path("run0").await {
-			return Ok(EscalationStrategy::Run0);
-		}
-		Ok(EscalationStrategy::Su)
-	}
-	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,
-	// assuming getting tags always returns the same value.
-	pub async fn tags(&self) -> Result<Vec<String>> {
-		if let Some(v) = self.groups.get() {
-			return Ok(v.clone());
-		}
-		let Some(host_config) = &self.host_config else {
-			return Ok(vec![]);
-		};
-		let tags: Vec<String> = nix_go_json!(host_config.tags);
-
-		let _ = self.groups.set(tags.clone());
-
-		Ok(tags)
-	}
-	pub async fn nixos_config(&self) -> Result<Value> {
-		if let Some(v) = self.nixos_config.get() {
-			return Ok(v.clone());
-		}
-		let Some(host_config) = &self.host_config else {
-			bail!("local host has no nixos_config");
-		};
-		let nixos_config = nix_go!(host_config.nixos.config);
-		assert_warn("nixos config evaluation", &nixos_config).await?;
-
-		let _ = self.nixos_config.set(nixos_config.clone());
-
-		Ok(nixos_config)
-	}
-	async fn open_session(&self) -> Result<Arc<openssh::Session>> {
-		assert!(!self.local, "do not open ssh connection to local session");
-		// FIXME: TOCTOU
-		if let Some(session) = &self.session.get() {
-			return Ok((*session).clone());
-		};
-		let mut session = SessionBuilder::default();
-		let session = session
-			.connect(&self.name)
-			.await
-			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;
-		let session = Arc::new(session);
-		self.session.set(session.clone()).expect("TOCTOU happened");
-		Ok(session)
-	}
-	pub async fn mktemp_dir(&self) -> Result<String> {
-		let mut cmd = self.cmd("mktemp").await?;
-		cmd.arg("-d");
-		let path = cmd.run_string().await?;
-		Ok(path.trim_end().to_owned())
-	}
-	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {
-		let mut cmd = self.cmd("cat").await?;
-		cmd.arg(path);
-		cmd.run_bytes().await
-	}
-	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {
-		let mut cmd = self.cmd("cat").await?;
-		cmd.arg(path);
-		cmd.run_string().await
-	}
-	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {
-		let mut cmd = self.cmd("ls").await?;
-		cmd.arg(path);
-		let out = cmd.run_string().await?;
-		let mut lines = out.split('\n');
-		if let Some(last) = lines.next_back() {
-			ensure!(last.is_empty(), "output of ls should end with newline");
-		}
-		Ok(lines.map(ToOwned::to_owned).collect())
-	}
-	#[allow(dead_code)]
-	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {
-		let text = self.read_file_text(path).await?;
-		Ok(serde_json::from_str(&text)?)
-	}
-	pub async fn read_env(&self, env: &str) -> Result<String> {
-		let mut cmd = self.cmd("printenv").await?;
-		cmd.arg(env);
-		Ok(cmd.run_string().await?)
-	}
-	pub async fn find_in_path(&self, command: &str) -> Result<String> {
-		// // `which` is not a part of coreutils, and it might not exist on machine.
-		// let path = self.read_env("PATH").await?;
-		// // Assuming delimiter is :, we don't work with windows host, this check will be much
-		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)
-		// for ele in path.split(':') {
-		// 	let test_path = format!("{ele}/{cmd}");
-		// 	test -x etc
-		// }
-		// let mut cmd = self.cmd("printenv").await?;
-		// cmd.arg(env);
-		// Ok(cmd.run_string().await?)
-		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.
-		let mut cmd = self
-			.cmd_escalation(
-				// Not used
-				EscalationStrategy::Su,
-				"which",
-			)
-			.await?;
-		cmd.arg(command);
-		cmd.run_string().await
-	}
-	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>
-	where
-		<D as FromStr>::Err: Display,
-	{
-		let text = self.read_file_text(path).await?;
-		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))
-	}
-	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {
-		self.cmd_escalation(self.escalation_strategy().await?, cmd)
-			.await
-	}
-	pub async fn cmd_escalation(
-		&self,
-		escalation: EscalationStrategy,
-		cmd: impl AsRef<OsStr>,
-	) -> Result<MyCommand> {
-		if self.local {
-			Ok(MyCommand::new(escalation, cmd))
-		} else {
-			let session = self.open_session().await?;
-			Ok(MyCommand::new_on(escalation, cmd, session))
-		}
-	}
-
-	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {
-		ensure!(data.encrypted, "secret is not encrypted");
-		let mut cmd = self.cmd("fleet-install-secrets").await?;
-		cmd.arg("decrypt").eqarg("--secret", data.to_string());
-		let encoded = cmd
-			.sudo()
-			.run_string()
-			.await
-			.context("failed to call remote host for decrypt")?;
-		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
-		ensure!(!data.encrypted, "secret came out encrypted");
-		Ok(data.data)
-	}
-	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {
-		ensure!(data.encrypted, "secret is not encrypted");
-		let mut cmd = self.cmd("fleet-install-secrets").await?;
-		cmd.arg("reencrypt").eqarg("--secret", data.to_string());
-		for target in targets {
-			let key = self.config.key(&target).await?;
-			cmd.eqarg("--targets", key);
-		}
-		let encoded = cmd
-			.sudo()
-			.run_string()
-			.await
-			.context("failed to call remote host for decrypt")?;
-		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
-		ensure!(data.encrypted, "secret came out not encrypted");
-		Ok(data)
-	}
-	/// Returns path for futureproofing, as path might change i.e on conversion to CA
-	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {
-		if self.local {
-			// Path is located locally, thus already trusted.
-			return Ok(path.to_owned());
-		}
-		let mut nix = MyCommand::new(
-			// Not used
-			EscalationStrategy::Su,
-			"nix",
-		);
-		nix.arg("copy")
-			.arg("--substitute-on-destination")
-			.comparg("--to", format!("ssh-ng://{}", self.name))
-			.arg(path);
-		nix.run_nix().await.context("nix copy")?;
-		Ok(path.to_owned())
-	}
-	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {
-		let mut cmd = self.cmd("systemctl").await?;
-		cmd.arg("stop").arg(name);
-		cmd.sudo().run().await
-	}
-	pub async fn systemctl_start(&self, name: &str) -> Result<()> {
-		let mut cmd = self.cmd("systemctl").await?;
-		cmd.arg("start").arg(name);
-		cmd.sudo().run().await
-	}
-
-	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {
-		let mut cmd = self.cmd("rm").await?;
-		cmd.arg("-f").arg(path);
-		if sudo {
-			cmd = cmd.sudo()
-		}
-		cmd.run().await
-	}
-
-	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {
-		let nixos = self.nixos_config().await?;
-		let secrets = nix_go!(nixos.secrets);
-		let mut out = Vec::new();
-		for name in secrets.list_fields().await? {
-			let secret = nix_go!(secrets[{ name }]);
-			let is_shared: bool = nix_go_json!(secret.shared);
-			if is_shared {
-				continue;
-			}
-			out.push(name);
-		}
-		Ok(out)
-	}
-	pub async fn secret_field(&self, name: &str) -> Result<Value> {
-		let nixos = self.nixos_config().await?;
-		Ok(nix_go!(nixos.secrets[{ name }]))
-	}
-
-	/// Packages for this host, resolved with nixpkgs overlays
-	pub async fn pkgs(&self) -> Result<Value> {
-		let Some(host_config) = &self.host_config else {
-			bail!("local host has no host_config");
-		};
-		// TODO: Should nixos.options be cached?
-		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))
-	}
-}
-
-impl Config {
-	pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {
-		if !self.opts.skip.is_empty() && self.opts.skip.iter().any(|h| h as &str == host.name) {
-			return Ok(true);
-		}
-		if self.opts.only.is_empty() {
-			return Ok(false);
-		}
-		let mut have_group_matches = false;
-		for item in self.opts.only.iter() {
-			match item {
-				HostItem::Host { name, .. } if *name == host.name => {
-					return Ok(false);
-				}
-				HostItem::Tag { .. } => {
-					have_group_matches = true;
-				}
-				_ => {}
-			}
-		}
-		if have_group_matches {
-			let host_tags = host.tags().await?;
-			for item in self.opts.only.iter() {
-				match item {
-					HostItem::Tag { name, .. } if host_tags.contains(name) => {
-						return Ok(false);
-					}
-					_ => {}
-				}
-			}
-		}
-		Ok(true)
-	}
-	pub async fn action_attr(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {
-		if self.opts.only.is_empty() {
-			return Ok(None);
-		}
-		let mut have_group_matches = false;
-		for item in self.opts.only.iter() {
-			match item {
-				HostItem::Host { name, attrs }
-					if *name == host.name && attrs.contains_key(attr) =>
-				{
-					return Ok(attrs.get(attr).cloned());
-				}
-				HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {
-					have_group_matches = true;
-				}
-				_ => {}
-			}
-		}
-		if have_group_matches {
-			let host_tags = host.tags().await?;
-			for item in self.opts.only.iter() {
-				match item {
-					HostItem::Tag { name, attrs }
-						if host_tags.contains(name) && attrs.contains_key(attr) =>
-					{
-						return Ok(attrs.get(attr).cloned());
-					}
-					_ => {}
-				}
-			}
-		}
-		Ok(None)
-	}
-	pub fn is_local(&self, host: &str) -> bool {
-		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)
-	}
-
-	pub fn local_host(&self) -> ConfigHost {
-		ConfigHost {
-			config: self.clone(),
-			name: "<virtual localhost>".to_owned(),
-			local: true,
-			session: OnceLock::new(),
-			host_config: None,
-			nixos_config: OnceCell::new(),
-			groups: {
-				let cell = OnceCell::new();
-				let _ = cell.set(vec![]);
-				cell
-			},
-		}
-	}
-
-	pub async fn host(&self, name: &str) -> Result<ConfigHost> {
-		let config = &self.config_field;
-		let host_config = nix_go!(config.hosts[{ name }]);
-
-		Ok(ConfigHost {
-			config: self.clone(),
-			name: name.to_owned(),
-			local: self.is_local(name),
-			session: OnceLock::new(),
-			host_config: Some(host_config),
-			nixos_config: OnceCell::new(),
-			groups: OnceCell::new(),
-		})
-	}
-	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {
-		let config = &self.config_field;
-		let names = nix_go!(config.hosts).list_fields().await?;
-		let mut out = vec![];
-		for name in names {
-			out.push(self.host(&name).await?);
-		}
-		Ok(out)
-	}
-	pub async fn system_config(&self, host: &str) -> Result<Value> {
-		let fleet_field = &self.config_field;
-		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))
-	}
-
-	pub(super) fn data(&self) -> MutexGuard<FleetData> {
-		self.data.lock().unwrap()
-	}
-	pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {
-		self.data.lock().unwrap()
-	}
-	/// Shared secrets configured in fleet.nix or in flake
-	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {
-		let config_field = &self.config_field;
-		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)
-	}
-	/// Shared secrets configured in fleet.nix
-	pub fn list_shared(&self) -> Vec<String> {
-		let data = self.data();
-		data.shared_secrets.keys().cloned().collect()
-	}
-	pub fn has_shared(&self, name: &str) -> bool {
-		let data = self.data();
-		data.shared_secrets.contains_key(name)
-	}
-	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {
-		let mut data = self.data_mut();
-		data.shared_secrets.insert(name.to_owned(), shared);
-	}
-	pub fn remove_shared(&self, secret: &str) {
-		let mut data = self.data_mut();
-		data.shared_secrets.remove(secret);
-	}
-
-	pub fn list_secrets(&self, host: &str) -> Vec<String> {
-		let data = self.data();
-		let Some(secrets) = data.host_secrets.get(host) else {
-			return Vec::new();
-		};
-		secrets.keys().cloned().collect()
-	}
-
-	pub fn has_secret(&self, host: &str, secret: &str) -> bool {
-		let data = self.data();
-		let Some(host_secrets) = data.host_secrets.get(host) else {
-			return false;
-		};
-		host_secrets.contains_key(secret)
-	}
-	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {
-		let mut data = self.data_mut();
-		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();
-		host_secrets.insert(secret, value);
-	}
-
-	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {
-		let data = self.data();
-		let Some(host_secrets) = data.host_secrets.get(host) else {
-			bail!("no secrets for machine {host}");
-		};
-		let Some(secret) = host_secrets.get(secret) else {
-			bail!("machine {host} has no secret {secret}");
-		};
-		Ok(secret.clone())
-	}
-	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {
-		let data = self.data();
-		let Some(secret) = data.shared_secrets.get(secret) else {
-			bail!("no shared secret {secret}");
-		};
-		Ok(secret.clone())
-	}
-	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {
-		let config_field = &self.config_field;
-		Ok(nix_go_json!(
-			config_field.sharedSecrets[{ secret }].expectedOwners
-		))
-	}
-
-	pub fn save(&self) -> Result<()> {
-		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.")?;
-		let data = nixlike::serialize(&self.data() as &FleetData)?;
-		tempfile.write_all(
-			format!(
-				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",
-				data
-			)
-			.as_bytes(),
-		)?;
-		let mut fleet_data_path = self.directory.clone();
-		fleet_data_path.push("fleet.nix");
-		tempfile.persist(fleet_data_path)?;
-		Ok(())
-	}
-}
-
-#[derive(Clone)]
-enum HostItem {
-	Host {
-		name: String,
-		attrs: BTreeMap<String, String>,
-	},
-	Tag {
-		name: String,
-		attrs: BTreeMap<String, String>,
-	},
-}
-fn host_item_parser(input: &str) -> Result<HostItem, String> {
-	fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {
-		err.to_string()
-	}
-
-	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;
-	let (input, name) = map(
-		take_while1(|v| v != ',' && v != '?' && v != '@'),
-		str::to_owned,
-	)(input)
-	.map_err(err_to_string)?;
-
-	let kw_item = separated_pair(
-		map(take_while1(|v| v != '&' && v != '='), str::to_owned),
-		char('='),
-		map(take_while1(|v| v != '&'), str::to_owned),
-	);
-	let kw = map(separated_list1(char('&'), kw_item), |vec| {
-		vec.into_iter().collect::<BTreeMap<_, _>>()
-	});
-	let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);
-
-	let (input, attrs) = opt_kw(input).map_err(err_to_string)?;
-
-	if !input.is_empty() {
-		return Err(format!("unexpected trailing input: {input:?}"));
-	}
-	Ok(if is_tag {
-		HostItem::Tag { name, attrs }
-	} else {
-		HostItem::Host { name, attrs }
-	})
-}
-
-#[derive(Parser, Clone)]
-pub struct FleetOpts {
-	/// All hosts except those would be skipped
-	#[clap(long, number_of_values = 1, value_parser = host_item_parser)]
-	only: Vec<HostItem>,
-
-	/// Hosts to skip
-	#[clap(long, number_of_values = 1)]
-	skip: Vec<String>,
-
-	/// Host, which should be threaten as current machine
-	#[clap(long)]
-	pub localhost: Option<String>,
-
-	/// Override detected system for host, to perform builds via
-	/// binfmt-declared qemu instead of trying to crosscompile
-	#[clap(long, default_value = "detect")]
-	pub local_system: String,
-}
-
-impl FleetOpts {
-	pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {
-		if self.localhost.is_none() {
-			self.localhost
-				.replace(hostname::get().unwrap().to_str().unwrap().to_owned());
-		}
-		let directory = current_dir()?;
-
-		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;
-		let root_field = pool.get().await?;
-
-		let builtins_field = Value::binding(root_field.clone(), "builtins").await?;
-		if self.local_system == "detect" {
-			self.local_system = nix_go_json!(builtins_field.currentSystem);
-		}
-		let local_system = self.local_system.clone();
-
-		let mut fleet_data_path = directory.clone();
-		fleet_data_path.push("fleet.nix");
-		let bytes = std::fs::read_to_string(fleet_data_path)?;
-		let data: Mutex<FleetData> = nixlike::parse_str(&bytes)?;
-
-		let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;
-		let fleet_field = nix_go!(fleet_root.default({ data }));
-
-		let config_field = nix_go!(fleet_field.config);
-
-		assert_warn("fleet config evaluation", &config_field).await?;
-
-		let import = nix_go!(builtins_field.import);
-		let overlays = nix_go!(config_field.nixpkgs.overlays);
-		let nixpkgs = nix_go!(fleet_field.nixpkgs.buildUsing | import);
-
-		let default_pkgs = nix_go!(nixpkgs(Obj {
-			overlays,
-			system: { self.local_system.clone() },
-		}));
-
-		Ok(Config(Arc::new(FleetConfigInternals {
-			opts: self,
-			directory,
-			data,
-			local_system,
-			nix_args,
-			config_field,
-			default_pkgs,
-		})))
-	}
-}
deletedcmds/fleet/src/keys.rsdiffbeforeafterboth
--- a/cmds/fleet/src/keys.rs
+++ /dev/null
@@ -1,77 +0,0 @@
-use std::str::FromStr;
-
-use age::Recipient;
-use anyhow::{anyhow, Result};
-use futures::{StreamExt, TryStreamExt};
-use itertools::Itertools;
-use tracing::warn;
-
-use crate::host::Config;
-
-impl Config {
-	pub fn cached_key(&self, host: &str) -> Option<String> {
-		let data = self.data();
-		let key = data.hosts.get(host).map(|h| &h.encryption_key);
-		if let Some(key) = key {
-			if key.is_empty() {
-				return None;
-			}
-		}
-		key.cloned()
-	}
-	pub fn update_key(&self, host: &str, key: String) {
-		let mut data = self.data_mut();
-		let host = data.hosts.entry(host.to_string()).or_default();
-		host.encryption_key = key.trim().to_string();
-	}
-
-	pub async fn key(&self, host: &str) -> anyhow::Result<String> {
-		if let Some(key) = self.cached_key(host) {
-			Ok(key)
-		} else {
-			warn!("Loading key for {}", host);
-			let host = self.host(host).await?;
-			let mut cmd = host.cmd("cat").await?;
-			cmd.arg("/etc/ssh/ssh_host_ed25519_key.pub");
-			let key = cmd.run_string().await?;
-			self.update_key(&host.name, key.clone());
-			Ok(key)
-		}
-	}
-	/// Insecure, requires root
-	pub async fn recipient(&self, host: &str) -> anyhow::Result<impl Recipient> {
-		let key = self.key(host).await?;
-		age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e))
-	}
-
-	pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<impl Recipient>> {
-		futures::stream::iter(hosts.iter())
-			.then(|m| self.recipient(m.as_ref()))
-			.try_collect::<Vec<_>>()
-			.await
-	}
-
-	#[allow(dead_code)]
-	pub async fn orphaned_data(&self) -> Result<Vec<String>> {
-		let mut out = Vec::new();
-		let host_names = self
-			.list_hosts()
-			.await?
-			.into_iter()
-			.map(|h| h.name)
-			.collect_vec();
-		for hostname in self
-			.data()
-			.hosts
-			.iter()
-			.filter(|(_, host)| !host.encryption_key.is_empty())
-			.map(|(n, _)| n)
-		{
-			if !host_names.contains(hostname) {
-				out.push(hostname.to_owned())
-			}
-		}
-
-		Ok(out)
-	}
-}
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -2,13 +2,8 @@
 #![feature(try_blocks)]
 
 pub(crate) mod cmds;
-pub(crate) mod command;
-pub(crate) mod host;
-pub(crate) mod keys;
-
+// pub(crate) mod command;
 pub(crate) mod extra_args;
-
-mod fleetdata;
 
 use std::{ffi::OsString, process::ExitCode};
 
@@ -21,8 +16,9 @@
 	secrets::Secret,
 	tf::Tf,
 };
+use fleet_base::{host::Config, opts::FleetOpts};
 use futures::{future::LocalBoxFuture, stream::FuturesUnordered, TryStreamExt};
-use host::{Config, FleetOpts};
+// use host::Config;
 #[cfg(feature = "indicatif")]
 use human_repr::HumanCount;
 #[cfg(feature = "indicatif")]
@@ -31,8 +27,6 @@
 #[cfg(feature = "indicatif")]
 use tracing_indicatif::IndicatifLayer;
 use tracing_subscriber::{prelude::*, EnvFilter};
-
-use crate::command::MyCommand;
 
 #[derive(Parser)]
 struct Prefetch {}
@@ -88,6 +82,7 @@
 	#[clap(hide(true))]
 	Complete(Complete),
 	/// Compile and evaluate terranix configuration
+	#[clap(subcommand)]
 	Tf(Tf),
 }
 
@@ -100,11 +95,11 @@
 	command: Opts,
 }
 
-async fn run_command(config: &Config, command: Opts) -> Result<()> {
+async fn run_command(config: &Config, opts: FleetOpts, command: Opts) -> Result<()> {
 	match command {
-		Opts::BuildSystems(c) => c.run(config).await?,
-		Opts::Deploy(d) => d.run(config).await?,
-		Opts::Secret(s) => s.run(config).await?,
+		Opts::BuildSystems(c) => c.run(config, &opts).await?,
+		Opts::Deploy(d) => d.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?,
 		Opts::Tf(t) => t.run(config).await?,
@@ -211,7 +206,7 @@
 		.unwrap_or_default();
 	let config = opts.fleet_opts.build(nix_args).await?;
 
-	match run_command(&config, opts.command).await {
+	match run_command(&config, opts.fleet_opts, opts.command).await {
 		Ok(()) => {
 			config.save()?;
 			Ok(())
addedcrates/fleet-base/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "fleet-base"
+edition = "2021"
+version.workspace = true
+
+[dependencies]
+age.workspace = true
+anyhow.workspace = true
+better-command.workspace = true
+chrono = "0.4.38"
+clap = { workspace = true, features = ["derive"] }
+fleet-shared.workspace = true
+futures = "0.3.30"
+hostname = "0.4.0"
+itertools = "0.13.0"
+nix-eval.workspace = true
+nixlike.workspace = true
+nom = "7.1.3"
+openssh = "0.11.0"
+serde.workspace = true
+serde_json = "1.0.127"
+tempfile.workspace = true
+tokio.workspace = true
+tokio-util = "0.7.11"
+tracing.workspace = true
addedcrates/fleet-base/src/command.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/command.rs
@@ -0,0 +1,430 @@
+use std::{ffi::OsStr, pin, process::Stdio, sync::Arc, task::Poll};
+
+use anyhow::{anyhow, Result};
+use better_command::{Handler, NixHandler, PlainHandler};
+use futures::StreamExt;
+use itertools::Either;
+use openssh::{OverSsh, OwningCommand, Session};
+use tokio::{io::AsyncRead, process::Command, select};
+use tokio_util::codec::{BytesCodec, FramedRead, LinesCodec};
+use tracing::debug;
+
+use crate::host::EscalationStrategy;
+
+fn escape_bash(input: &str, out: &mut String) {
+	const TO_ESCAPE: &str = "$ !\"#&'()*,;<>?[\\]^`{|}";
+	if input.chars().all(|c| !TO_ESCAPE.contains(c)) {
+		out.push_str(input);
+		return;
+	}
+	out.push('\'');
+	for (i, v) in input.split('\'').enumerate() {
+		if i != 0 {
+			out.push_str("'\"'\"'");
+		}
+		out.push_str(v);
+	}
+	out.push('\'');
+}
+fn ostoutf8(os: impl AsRef<OsStr>) -> String {
+	os.as_ref().to_str().expect("non-utf8 data").to_owned()
+}
+
+#[derive(Clone, Debug)]
+pub struct MyCommand {
+	command: String,
+	args: Vec<String>,
+	env: Vec<(String, String)>,
+	ssh_session: Option<Arc<Session>>,
+	escalation: EscalationStrategy,
+	escalate: bool,
+}
+impl MyCommand {
+	pub fn new_on(
+		escalation: EscalationStrategy,
+		cmd: impl AsRef<OsStr>,
+		session: Arc<Session>,
+	) -> Self {
+		assert!(!cmd.as_ref().is_empty());
+		Self {
+			command: ostoutf8(cmd),
+			args: vec![],
+			env: vec![],
+			ssh_session: Some(session),
+			escalation,
+			escalate: false,
+		}
+	}
+	pub fn new(escalation: EscalationStrategy, cmd: impl AsRef<OsStr>) -> Self {
+		assert!(!cmd.as_ref().is_empty());
+		Self {
+			command: ostoutf8(cmd),
+			args: vec![],
+			env: vec![],
+			ssh_session: None,
+			escalation,
+			escalate: false,
+		}
+	}
+	fn new_here(&self, cmd: impl AsRef<OsStr>) -> Self {
+		if let Some(ssh_session) = self.ssh_session.clone() {
+			Self::new_on(self.escalation, cmd, ssh_session)
+		} else {
+			Self::new(self.escalation, cmd)
+		}
+	}
+
+	fn into_args(self) -> Vec<String> {
+		let mut out = Vec::new();
+		if !self.env.is_empty() {
+			out.push("env".to_owned());
+			for (k, v) in self.env {
+				assert!(!k.contains('='));
+				out.push(format!("{k}={v}"));
+			}
+		}
+		out.push(self.command);
+		out.extend(self.args);
+		out
+	}
+
+	/// Translates environment variables into env command execution.
+	/// Required for ssh, as ssh don't allow to send environment variables (at least by default).
+	///
+	/// FIXME: Insecure, as arguments might be seen by other users on the same machine.
+	/// Figure out some way to transfer environment using stdio?
+	fn translate_env_into_env(self) -> Self {
+		if self.env.is_empty() {
+			return self;
+		}
+		let mut out = self.new_here("env");
+		for (k, v) in self.env {
+			assert!(!k.contains('='));
+			out.arg(format!("{k}={v}"));
+		}
+		out.arg(self.command);
+		out.args(self.args);
+
+		out
+	}
+	fn into_string(self) -> String {
+		let mut out = String::new();
+		if !self.env.is_empty() {
+			out.push_str("env");
+			for (k, v) in self.env {
+				out.push(' ');
+				assert!(!k.contains('='));
+				escape_bash(&k, &mut out);
+				out.push('=');
+				escape_bash(&v, &mut out);
+			}
+		}
+		if !out.is_empty() {
+			out.push(' ');
+		}
+		escape_bash(&self.command, &mut out);
+		for arg in self.args {
+			out.push(' ');
+			escape_bash(&arg, &mut out);
+		}
+		out
+	}
+	fn into_command(self) -> Command {
+		let mut out = Command::new(self.command);
+		out.args(self.args);
+		for (k, v) in self.env {
+			out.env(k, v);
+		}
+		out
+	}
+	fn into_command_new(self) -> Result<Either<Command, openssh::OwningCommand<Arc<Session>>>> {
+		Ok(if let Some(session) = self.ssh_session.clone() {
+			let cmd = self.translate_env_into_env().into_command();
+			Either::Right(
+				cmd.over_ssh(session)
+					.map_err(|e| anyhow!("ssh error: {e}"))?,
+			)
+		} else {
+			let cmd = self.into_command();
+			Either::Left(cmd)
+		})
+	}
+	pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
+		let arg = arg.as_ref();
+		self.args.push(ostoutf8(arg));
+		self
+	}
+	pub fn eqarg(&mut self, arg: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
+		let arg = arg.as_ref();
+		let value = value.as_ref();
+		let arg = ostoutf8(arg);
+		let value = ostoutf8(value);
+		self.arg(format!("{arg}={value}"));
+		self
+	}
+	pub fn comparg(&mut self, arg: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
+		self.arg(arg);
+		self.arg(value);
+		self
+	}
+	pub fn env(&mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> &mut Self {
+		self.env
+			.push((name.as_ref().to_owned(), value.as_ref().to_owned()));
+		self
+	}
+	pub fn args<V: AsRef<OsStr>>(&mut self, args: impl IntoIterator<Item = V>) -> &mut Self {
+		for arg in args.into_iter() {
+			let arg = arg.as_ref();
+			self.args.push(ostoutf8(arg));
+		}
+		self
+	}
+	pub fn sudo(mut self) -> Self {
+		self.escalate = true;
+		self
+	}
+	fn wrap_sudo_if_needed(self) -> Self {
+		if !self.escalate {
+			return self;
+		}
+		match self.escalation {
+			EscalationStrategy::Su => {
+				let mut out = self.new_here("su");
+				out.arg("-c").arg(self.into_string());
+				out
+			}
+			EscalationStrategy::Sudo => {
+				let mut out = self.new_here("sudo");
+				out.args(self.into_args());
+				out
+			}
+			EscalationStrategy::Run0 => {
+				// run0 wants interactive authentication by default.
+				let mut run0 = self.new_here("run0");
+				let mut out = self.new_here("script");
+
+				// Red backgrounds messes with fleet formatting
+				run0.arg("--background=");
+				run0.args(self.into_args());
+
+				out.arg("-q");
+				out.arg("/dev/null");
+				out.arg("-c");
+				out.arg(run0.into_string());
+				dbg!(&out);
+				out
+			}
+		}
+	}
+
+	pub async fn run(self) -> Result<()> {
+		let str = self.clone().into_string();
+		let cmd = self.wrap_sudo_if_needed().into_command_new()?;
+		match cmd {
+			Either::Left(cmd) => run_nix_inner(str, cmd, &mut PlainHandler).await?,
+			Either::Right(cmd) => run_nix_inner_ssh(str, cmd, &mut PlainHandler).await?,
+		};
+		Ok(())
+	}
+	pub async fn run_string(self) -> Result<String> {
+		let bytes = self.run_bytes().await?;
+		Ok(String::from_utf8(bytes)?)
+	}
+	pub async fn run_bytes(self) -> Result<Vec<u8>> {
+		let str = self.clone().into_string();
+		let cmd = self.wrap_sudo_if_needed().into_command_new()?;
+		let v = match cmd {
+			Either::Left(cmd) => run_nix_inner_stdout(str, cmd, &mut PlainHandler).await?,
+			Either::Right(cmd) => run_nix_inner_stdout_ssh(str, cmd, &mut PlainHandler).await?,
+		};
+		Ok(v)
+	}
+
+	pub async fn run_nix_string(mut self) -> Result<String> {
+		let str = self.clone().into_string();
+		self.arg("--log-format").arg("internal-json");
+		let cmd = self.wrap_sudo_if_needed().into_command();
+		let bytes = run_nix_inner_stdout(str, cmd, &mut NixHandler::default()).await?;
+		Ok(String::from_utf8(bytes)?)
+	}
+	pub async fn run_nix(mut self) -> Result<()> {
+		let str = self.clone().into_string();
+		self.arg("--log-format").arg("internal-json");
+		let mut cmd = self.wrap_sudo_if_needed().into_command();
+		cmd.stdout(Stdio::inherit());
+		run_nix_inner(str, cmd, &mut NixHandler::default()).await
+	}
+}
+
+struct EmptyAsyncRead;
+impl AsyncRead for EmptyAsyncRead {
+	fn poll_read(
+		self: std::pin::Pin<&mut Self>,
+		_cx: &mut std::task::Context<'_>,
+		_buf: &mut tokio::io::ReadBuf<'_>,
+	) -> Poll<std::io::Result<()>> {
+		Poll::Pending
+	}
+}
+
+async fn run_nix_inner_stdout(
+	str: String,
+	cmd: Command,
+	handler: &mut dyn Handler,
+) -> Result<Vec<u8>> {
+	Ok(run_nix_inner_raw(str, cmd, true, handler, None)
+		.await?
+		.expect("has out"))
+}
+async fn run_nix_inner(str: String, cmd: Command, handler: &mut dyn Handler) -> Result<()> {
+	let v = run_nix_inner_raw(str, cmd, false, handler, None).await?;
+	assert!(v.is_none());
+	Ok(())
+}
+async fn run_nix_inner_stdout_ssh(
+	str: String,
+	cmd: OwningCommand<Arc<Session>>,
+	handler: &mut dyn Handler,
+) -> Result<Vec<u8>> {
+	Ok(run_nix_inner_raw_ssh(str, cmd, true, handler, None)
+		.await?
+		.expect("has out"))
+}
+async fn run_nix_inner_ssh(
+	str: String,
+	cmd: OwningCommand<Arc<Session>>,
+	handler: &mut dyn Handler,
+) -> Result<()> {
+	let v = run_nix_inner_raw_ssh(str, cmd, false, handler, None).await?;
+	assert!(v.is_none());
+	Ok(())
+}
+
+async fn run_nix_inner_raw(
+	str: String,
+	mut cmd: Command,
+	want_stdout: bool,
+	err_handler: &mut dyn Handler,
+	mut out_handler: Option<&mut dyn Handler>,
+) -> Result<Option<Vec<u8>>> {
+	cmd.stderr(Stdio::piped());
+	cmd.stdout(Stdio::piped());
+	debug!("running command {str:?} on local");
+	let mut child = cmd.spawn()?;
+	let mut stderr = child.stderr.take().unwrap();
+	let stdout = child.stdout.take().unwrap();
+	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
+	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
+	let mut ob = want_stdout
+		.then(|| out.take().unwrap())
+		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
+	let mut ol = (!want_stdout)
+		.then(|| out.take().unwrap())
+		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
+	let mut ob = FramedRead::new(&mut ob, BytesCodec::new());
+	let mut ol = FramedRead::new(&mut ol, LinesCodec::new());
+
+	// while let Some(line) = read.next().await? {}
+
+	let mut out_buf = if want_stdout { Some(vec![]) } else { None };
+	loop {
+		select! {
+			e = err.next() => {
+				if let Some(e) = e {
+					let e = e?;
+					err_handler.handle_line(&e);
+				}
+			},
+			o = ob.next() => {
+				if let Some(o) = o {
+					out_buf.as_mut().expect("stdout == wants_stdout").extend_from_slice(&o?);
+				}
+			},
+			o = ol.next() => {
+				if let Some(o) = o {
+					let o = o?;
+					if let Some(out) = out_handler.as_mut() {
+						out.handle_line(&o)
+					} else {
+						err_handler.handle_line(&o)
+					}
+					// out_handler.handle_info(&o);
+				}
+			},
+			code = child.wait() => {
+				let code = code?;
+				if !code.success() {
+					anyhow::bail!("command '{str}' failed with status {}", code);
+				}
+				break;
+			}
+		}
+	}
+
+	Ok(out_buf)
+}
+async fn run_nix_inner_raw_ssh(
+	str: String,
+	mut cmd: OwningCommand<Arc<Session>>,
+	want_stdout: bool,
+	err_handler: &mut dyn Handler,
+	mut out_handler: Option<&mut dyn Handler>,
+) -> Result<Option<Vec<u8>>> {
+	debug!("running command {str:?} over ssh");
+	cmd.stderr(openssh::Stdio::piped());
+	cmd.stdout(openssh::Stdio::piped());
+	let mut child = cmd.spawn().await?;
+	let mut stderr = child.stderr().take().unwrap();
+	let stdout = child.stdout().take().unwrap();
+	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
+	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
+	let mut ob = want_stdout
+		.then(|| out.take().unwrap())
+		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
+	let mut ol = (!want_stdout)
+		.then(|| out.take().unwrap())
+		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
+	let mut ob = FramedRead::new(&mut ob, BytesCodec::new());
+	let mut ol = FramedRead::new(&mut ol, LinesCodec::new());
+
+	// while let Some(line) = read.next().await? {}
+
+	let mut out_buf = if want_stdout { Some(vec![]) } else { None };
+
+	let mut wait_future = pin::pin!(child.wait());
+	loop {
+		select! {
+			e = err.next() => {
+				if let Some(e) = e {
+					let e = e?;
+					err_handler.handle_line(&e);
+				}
+			},
+			o = ob.next() => {
+				if let Some(o) = o {
+					out_buf.as_mut().expect("stdout == wants_stdout").extend_from_slice(&o?);
+				}
+			},
+			o = ol.next() => {
+				if let Some(o) = o {
+					let o = o?;
+					if let Some(out) = out_handler.as_mut() {
+						out.handle_line(&o)
+					} else {
+						err_handler.handle_line(&o)
+					}
+					// out_handler.handle_info(&o);
+				}
+			},
+			code = &mut wait_future => {
+				let code = code?;
+				if !code.success() {
+					anyhow::bail!("command '{str}' failed with status {}", code);
+				}
+				break;
+			}
+		}
+	}
+
+	Ok(out_buf)
+}
addedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -0,0 +1,113 @@
+use std::{
+	collections::BTreeMap,
+	io::{self, Cursor},
+};
+
+use age::Recipient;
+use chrono::{DateTime, Utc};
+use fleet_shared::SecretData;
+use itertools::Itertools;
+use serde::{de::Error, Deserialize, Serialize};
+use serde_json::Value;
+
+#[derive(Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct HostData {
+	#[serde(default)]
+	#[serde(skip_serializing_if = "String::is_empty")]
+	pub encryption_key: String,
+}
+
+const VERSION: &str = "0.1.0";
+pub struct FleetDataVersion;
+impl Serialize for FleetDataVersion {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		VERSION.serialize(serializer)
+	}
+}
+impl<'de> Deserialize<'de> for FleetDataVersion {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: serde::Deserializer<'de>,
+	{
+		let version = String::deserialize(deserializer)?;
+		if version != VERSION {
+			return Err(D::Error::custom(format!(
+				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"
+			)));
+		}
+		Ok(Self)
+	}
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FleetData {
+	pub version: FleetDataVersion,
+
+	#[serde(default)]
+	pub hosts: BTreeMap<String, HostData>,
+	#[serde(default)]
+	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
+	pub shared_secrets: BTreeMap<String, FleetSharedSecret>,
+	#[serde(default)]
+	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
+	pub host_secrets: BTreeMap<String, BTreeMap<String, FleetSecret>>,
+
+	// extra_name => anything
+	#[serde(default)]
+	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
+	pub extra: BTreeMap<String, Value>,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+#[serde(rename_all = "camelCase")]
+#[must_use]
+pub struct FleetSharedSecret {
+	pub owners: Vec<String>,
+	#[serde(flatten)]
+	pub secret: FleetSecret,
+}
+
+/// Returns None if recipients.is_empty()
+pub fn encrypt_secret_data(
+	recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
+	data: Vec<u8>,
+) -> Option<SecretData> {
+	let mut encrypted = vec![];
+	let recipients = recipients
+		.into_iter()
+		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
+		.collect_vec();
+	let mut encryptor = age::Encryptor::with_recipients(recipients)?
+		.wrap_output(&mut encrypted)
+		.expect("in memory write");
+	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
+	encryptor.finish().expect("in memory flush");
+	Some(SecretData {
+		data: encrypted,
+		encrypted: true,
+	})
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct FleetSecretPart {
+	pub raw: SecretData,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+#[serde(rename_all = "camelCase")]
+#[must_use]
+pub struct FleetSecret {
+	#[serde(default = "Utc::now")]
+	pub created_at: DateTime<Utc>,
+	#[serde(default)]
+	#[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]
+	pub expires_at: Option<DateTime<Utc>>,
+
+	#[serde(flatten)]
+	pub parts: BTreeMap<String, FleetSecretPart>,
+}
addedcrates/fleet-base/src/host.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/host.rs
@@ -0,0 +1,452 @@
+use std::{
+	cell::OnceCell,
+	ffi::{OsStr, OsString},
+	fmt::Display,
+	io::Write,
+	ops::Deref,
+	path::PathBuf,
+	str::FromStr,
+	sync::{Arc, Mutex, MutexGuard, OnceLock},
+};
+
+use anyhow::{anyhow, bail, ensure, Context, Result};
+use fleet_shared::SecretData;
+use nix_eval::{nix_go, nix_go_json, util::assert_warn, Value};
+use openssh::SessionBuilder;
+use serde::de::DeserializeOwned;
+use tempfile::NamedTempFile;
+
+use crate::{
+	command::MyCommand,
+	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
+};
+
+pub struct FleetConfigInternals {
+	pub local_system: String,
+	pub directory: PathBuf,
+	pub data: Mutex<FleetData>,
+	pub nix_args: Vec<OsString>,
+	/// fleet_config.config
+	pub config_field: Value,
+	// TODO: Remove with connectivity refactor
+	pub localhost: String,
+
+	/// import nixpkgs {system = local};
+	pub default_pkgs: Value,
+}
+
+// TODO: Make field not pub
+#[derive(Clone)]
+pub struct Config(pub Arc<FleetConfigInternals>);
+
+impl Deref for Config {
+	type Target = FleetConfigInternals;
+
+	fn deref(&self) -> &Self::Target {
+		&self.0
+	}
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum EscalationStrategy {
+	Sudo,
+	Run0,
+	Su,
+}
+
+pub struct ConfigHost {
+	config: Config,
+	pub name: String,
+	groups: OnceCell<Vec<String>>,
+
+	pub host_config: Option<Value>,
+	pub nixos_config: OnceCell<Value>,
+
+	// TODO: Move command helpers away with connectivity refactor
+	pub local: bool,
+	pub session: OnceLock<Arc<openssh::Session>>,
+}
+// TODO: Move command helpers away with connectivity refactor
+impl ConfigHost {
+	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {
+		// Prefer sudo, as run0 has some gotchas with polkit
+		// and too many repeating prompts.
+		if (self.find_in_path("sudo").await).is_ok() {
+			return Ok(EscalationStrategy::Sudo);
+		}
+		if (self.find_in_path("run0").await).is_ok() {
+			return Ok(EscalationStrategy::Run0);
+		}
+		Ok(EscalationStrategy::Su)
+	}
+	async fn open_session(&self) -> Result<Arc<openssh::Session>> {
+		assert!(!self.local, "do not open ssh connection to local session");
+		// FIXME: TOCTOU
+		if let Some(session) = &self.session.get() {
+			return Ok((*session).clone());
+		};
+		let session = SessionBuilder::default();
+		let session = session
+			.connect(&self.name)
+			.await
+			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;
+		let session = Arc::new(session);
+		self.session.set(session.clone()).expect("TOCTOU happened");
+		Ok(session)
+	}
+	pub async fn mktemp_dir(&self) -> Result<String> {
+		let mut cmd = self.cmd("mktemp").await?;
+		cmd.arg("-d");
+		let path = cmd.run_string().await?;
+		Ok(path.trim_end().to_owned())
+	}
+	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {
+		let mut cmd = self.cmd("cat").await?;
+		cmd.arg(path);
+		cmd.run_bytes().await
+	}
+	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {
+		let mut cmd = self.cmd("cat").await?;
+		cmd.arg(path);
+		cmd.run_string().await
+	}
+	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {
+		let mut cmd = self.cmd("ls").await?;
+		cmd.arg(path);
+		let out = cmd.run_string().await?;
+		let mut lines = out.split('\n');
+		if let Some(last) = lines.next_back() {
+			ensure!(last.is_empty(), "output of ls should end with newline");
+		}
+		Ok(lines.map(ToOwned::to_owned).collect())
+	}
+	#[allow(dead_code)]
+	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {
+		let text = self.read_file_text(path).await?;
+		Ok(serde_json::from_str(&text)?)
+	}
+	pub async fn read_env(&self, env: &str) -> Result<String> {
+		let mut cmd = self.cmd("printenv").await?;
+		cmd.arg(env);
+		cmd.run_string().await
+	}
+	pub async fn find_in_path(&self, command: &str) -> Result<String> {
+		// // `which` is not a part of coreutils, and it might not exist on machine.
+		// let path = self.read_env("PATH").await?;
+		// // Assuming delimiter is :, we don't work with windows host, this check will be much
+		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)
+		// for ele in path.split(':') {
+		// 	let test_path = format!("{ele}/{cmd}");
+		// 	test -x etc
+		// }
+		// let mut cmd = self.cmd("printenv").await?;
+		// cmd.arg(env);
+		// Ok(cmd.run_string().await?)
+		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.
+		let mut cmd = self
+			.cmd_escalation(
+				// Not used
+				EscalationStrategy::Su,
+				"which",
+			)
+			.await?;
+		cmd.arg(command);
+		cmd.run_string().await
+	}
+	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>
+	where
+		<D as FromStr>::Err: Display,
+	{
+		let text = self.read_file_text(path).await?;
+		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))
+	}
+	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {
+		self.cmd_escalation(self.escalation_strategy().await?, cmd)
+			.await
+	}
+	pub async fn cmd_escalation(
+		&self,
+		escalation: EscalationStrategy,
+		cmd: impl AsRef<OsStr>,
+	) -> Result<MyCommand> {
+		if self.local {
+			Ok(MyCommand::new(escalation, cmd))
+		} else {
+			let session = self.open_session().await?;
+			Ok(MyCommand::new_on(escalation, cmd, session))
+		}
+	}
+
+	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {
+		ensure!(data.encrypted, "secret is not encrypted");
+		let mut cmd = self.cmd("fleet-install-secrets").await?;
+		cmd.arg("decrypt").eqarg("--secret", data.to_string());
+		let encoded = cmd
+			.sudo()
+			.run_string()
+			.await
+			.context("failed to call remote host for decrypt")?;
+		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
+		ensure!(!data.encrypted, "secret came out encrypted");
+		Ok(data.data)
+	}
+	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {
+		ensure!(data.encrypted, "secret is not encrypted");
+		let mut cmd = self.cmd("fleet-install-secrets").await?;
+		cmd.arg("reencrypt").eqarg("--secret", data.to_string());
+		for target in targets {
+			let key = self.config.key(&target).await?;
+			cmd.eqarg("--targets", key);
+		}
+		let encoded = cmd
+			.sudo()
+			.run_string()
+			.await
+			.context("failed to call remote host for decrypt")?;
+		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;
+		ensure!(data.encrypted, "secret came out not encrypted");
+		Ok(data)
+	}
+	/// Returns path for futureproofing, as path might change i.e on conversion to CA
+	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {
+		if self.local {
+			// Path is located locally, thus already trusted.
+			return Ok(path.to_owned());
+		}
+		let mut nix = MyCommand::new(
+			// Not used
+			EscalationStrategy::Su,
+			"nix",
+		);
+		nix.arg("copy")
+			.arg("--substitute-on-destination")
+			.comparg("--to", format!("ssh-ng://{}", self.name))
+			.arg(path);
+		nix.run_nix().await.context("nix copy")?;
+		Ok(path.to_owned())
+	}
+	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {
+		let mut cmd = self.cmd("systemctl").await?;
+		cmd.arg("stop").arg(name);
+		cmd.sudo().run().await
+	}
+	pub async fn systemctl_start(&self, name: &str) -> Result<()> {
+		let mut cmd = self.cmd("systemctl").await?;
+		cmd.arg("start").arg(name);
+		cmd.sudo().run().await
+	}
+
+	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {
+		let mut cmd = self.cmd("rm").await?;
+		cmd.arg("-f").arg(path);
+		if sudo {
+			cmd = cmd.sudo()
+		}
+		cmd.run().await
+	}
+}
+impl ConfigHost {
+	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,
+	// assuming getting tags always returns the same value.
+	pub async fn tags(&self) -> Result<Vec<String>> {
+		if let Some(v) = self.groups.get() {
+			return Ok(v.clone());
+		}
+		let Some(host_config) = &self.host_config else {
+			return Ok(vec![]);
+		};
+		let tags: Vec<String> = nix_go_json!(host_config.tags);
+
+		let _ = self.groups.set(tags.clone());
+
+		Ok(tags)
+	}
+	pub async fn nixos_config(&self) -> Result<Value> {
+		if let Some(v) = self.nixos_config.get() {
+			return Ok(v.clone());
+		}
+		let Some(host_config) = &self.host_config else {
+			bail!("local host has no nixos_config");
+		};
+		let nixos_config = nix_go!(host_config.nixos.config);
+		assert_warn("nixos config evaluation", &nixos_config).await?;
+
+		let _ = self.nixos_config.set(nixos_config.clone());
+
+		Ok(nixos_config)
+	}
+
+	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {
+		let nixos = self.nixos_config().await?;
+		let secrets = nix_go!(nixos.secrets);
+		let mut out = Vec::new();
+		for name in secrets.list_fields().await? {
+			let secret = nix_go!(secrets[{ name }]);
+			let is_shared: bool = nix_go_json!(secret.shared);
+			if is_shared {
+				continue;
+			}
+			out.push(name);
+		}
+		Ok(out)
+	}
+	pub async fn secret_field(&self, name: &str) -> Result<Value> {
+		let nixos = self.nixos_config().await?;
+		Ok(nix_go!(nixos.secrets[{ name }]))
+	}
+
+	/// Packages for this host, resolved with nixpkgs overlays
+	pub async fn pkgs(&self) -> Result<Value> {
+		let Some(host_config) = &self.host_config else {
+			bail!("local host has no host_config");
+		};
+		// TODO: Should nixos.options be cached?
+		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))
+	}
+}
+
+impl Config {
+	pub fn local_host(&self) -> ConfigHost {
+		ConfigHost {
+			config: self.clone(),
+			name: "<virtual localhost>".to_owned(),
+			local: true,
+			session: OnceLock::new(),
+			host_config: None,
+			nixos_config: OnceCell::new(),
+			groups: {
+				let cell = OnceCell::new();
+				let _ = cell.set(vec![]);
+				cell
+			},
+		}
+	}
+
+	pub async fn host(&self, name: &str) -> Result<ConfigHost> {
+		let config = &self.config_field;
+		let host_config = nix_go!(config.hosts[{ name }]);
+
+		Ok(ConfigHost {
+			config: self.clone(),
+			name: name.to_owned(),
+			host_config: Some(host_config),
+			nixos_config: OnceCell::new(),
+			groups: OnceCell::new(),
+			
+			// TODO: Remove with connectivit refactor
+			local: self.localhost == name,
+			session: OnceLock::new(),
+		})
+	}
+	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {
+		let config = &self.config_field;
+		let names = nix_go!(config.hosts).list_fields().await?;
+		let mut out = vec![];
+		for name in names {
+			out.push(self.host(&name).await?);
+		}
+		Ok(out)
+	}
+	// TODO: Replace usages with .host().nixos_config
+	pub async fn system_config(&self, host: &str) -> Result<Value> {
+		let fleet_field = &self.config_field;
+		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))
+	}
+
+	/// Shared secrets configured in fleet.nix or in flake
+	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {
+		let config_field = &self.config_field;
+		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)
+	}
+	/// Shared secrets configured in fleet.nix
+	pub fn list_shared(&self) -> Vec<String> {
+		let data = self.data();
+		data.shared_secrets.keys().cloned().collect()
+	}
+	pub fn has_shared(&self, name: &str) -> bool {
+		let data = self.data();
+		data.shared_secrets.contains_key(name)
+	}
+	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {
+		let mut data = self.data_mut();
+		data.shared_secrets.insert(name.to_owned(), shared);
+	}
+	pub fn remove_shared(&self, secret: &str) {
+		let mut data = self.data_mut();
+		data.shared_secrets.remove(secret);
+	}
+
+	pub fn list_secrets(&self, host: &str) -> Vec<String> {
+		let data = self.data();
+		let Some(secrets) = data.host_secrets.get(host) else {
+			return Vec::new();
+		};
+		secrets.keys().cloned().collect()
+	}
+
+	pub fn has_secret(&self, host: &str, secret: &str) -> bool {
+		let data = self.data();
+		let Some(host_secrets) = data.host_secrets.get(host) else {
+			return false;
+		};
+		host_secrets.contains_key(secret)
+	}
+	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {
+		let mut data = self.data_mut();
+		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();
+		host_secrets.insert(secret, value);
+	}
+
+	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {
+		let data = self.data();
+		let Some(host_secrets) = data.host_secrets.get(host) else {
+			bail!("no secrets for machine {host}");
+		};
+		let Some(secret) = host_secrets.get(secret) else {
+			bail!("machine {host} has no secret {secret}");
+		};
+		Ok(secret.clone())
+	}
+	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {
+		let data = self.data();
+		let Some(secret) = data.shared_secrets.get(secret) else {
+			bail!("no shared secret {secret}");
+		};
+		Ok(secret.clone())
+	}
+	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {
+		let config_field = &self.config_field;
+		Ok(nix_go_json!(
+			config_field.sharedSecrets[{ secret }].expectedOwners
+		))
+	}
+
+	// TODO: Should this be something modifiable from other processes?
+	// E.g terraform provider might want to update FleetData (e.g secrets),
+	// and current implementation assumes only one process holds current fleet.nix
+	// Given that it is no longer needs to be a file for nix evaluation,
+	// maybe it can be a .nix file for persistence, but accessible only
+	// thru some shared state controller? Might it be stored in terraform
+	// state provider?
+	pub fn data(&self) -> MutexGuard<FleetData> {
+		self.data.lock().unwrap()
+	}
+	pub fn data_mut(&self) -> MutexGuard<FleetData> {
+		self.data.lock().unwrap()
+	}
+	pub fn save(&self) -> Result<()> {
+		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.")?;
+		let data = nixlike::serialize(&self.data() as &FleetData)?;
+		tempfile.write_all(
+			format!(
+				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",
+				data
+			)
+			.as_bytes(),
+		)?;
+		let mut fleet_data_path = self.directory.clone();
+		fleet_data_path.push("fleet.nix");
+		tempfile.persist(fleet_data_path)?;
+		Ok(())
+	}
+}
addedcrates/fleet-base/src/keys.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/keys.rs
@@ -0,0 +1,77 @@
+use std::str::FromStr as _;
+
+use age::Recipient;
+use anyhow::{anyhow, Result};
+use futures::{StreamExt as _, TryStreamExt as _};
+use itertools::Itertools as _;
+use tracing::warn;
+
+use crate::host::Config;
+
+impl Config {
+	pub fn cached_key(&self, host: &str) -> Option<String> {
+		let data = self.data();
+		let key = data.hosts.get(host).map(|h| &h.encryption_key);
+		if let Some(key) = key {
+			if key.is_empty() {
+				return None;
+			}
+		}
+		key.cloned()
+	}
+	pub fn update_key(&self, host: &str, key: String) {
+		let mut data = self.data_mut();
+		let host = data.hosts.entry(host.to_string()).or_default();
+		host.encryption_key = key.trim().to_string();
+	}
+
+	pub async fn key(&self, host: &str) -> anyhow::Result<String> {
+		if let Some(key) = self.cached_key(host) {
+			Ok(key)
+		} else {
+			warn!("Loading key for {}", host);
+			let host = self.host(host).await?;
+			let mut cmd = host.cmd("cat").await?;
+			cmd.arg("/etc/ssh/ssh_host_ed25519_key.pub");
+			let key = cmd.run_string().await?;
+			self.update_key(&host.name, key.clone());
+			Ok(key)
+		}
+	}
+	/// Insecure, requires root
+	pub async fn recipient(&self, host: &str) -> anyhow::Result<impl Recipient> {
+		let key = self.key(host).await?;
+		age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e))
+	}
+
+	pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<impl Recipient>> {
+		futures::stream::iter(hosts.iter())
+			.then(|m| self.recipient(m.as_ref()))
+			.try_collect::<Vec<_>>()
+			.await
+	}
+
+	#[allow(dead_code)]
+	pub async fn orphaned_data(&self) -> Result<Vec<String>> {
+		let mut out = Vec::new();
+		let host_names = self
+			.list_hosts()
+			.await?
+			.into_iter()
+			.map(|h| h.name)
+			.collect_vec();
+		for hostname in self
+			.data()
+			.hosts
+			.iter()
+			.filter(|(_, host)| !host.encryption_key.is_empty())
+			.map(|(n, _)| n)
+		{
+			if !host_names.contains(hostname) {
+				out.push(hostname.to_owned())
+			}
+		}
+
+		Ok(out)
+	}
+}
addedcrates/fleet-base/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/lib.rs
@@ -0,0 +1,5 @@
+pub mod fleetdata;
+pub mod host;
+pub mod command;
+pub mod opts;
+mod keys;
addedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/opts.rs
@@ -0,0 +1,216 @@
+use std::{
+	collections::BTreeMap,
+	env::current_dir,
+	ffi::OsString,
+	str::FromStr,
+	sync::{Arc, Mutex},
+};
+
+use anyhow::Result;
+use clap::Parser;
+use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSessionPool, Value};
+use nom::{
+	bytes::complete::take_while1,
+	character::complete::char,
+	combinator::{map, opt},
+	multi::separated_list1,
+	sequence::{preceded, separated_pair},
+};
+
+use crate::{
+	fleetdata::FleetData,
+	host::{Config, ConfigHost, FleetConfigInternals},
+};
+
+#[derive(Clone)]
+pub enum HostItem {
+	Host {
+		name: String,
+		attrs: BTreeMap<String, String>,
+	},
+	Tag {
+		name: String,
+		attrs: BTreeMap<String, String>,
+	},
+}
+fn host_item_parser(input: &str) -> Result<HostItem, String> {
+	fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {
+		err.to_string()
+	}
+
+	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;
+	let (input, name) = map(
+		take_while1(|v| v != ',' && v != '?' && v != '@'),
+		str::to_owned,
+	)(input)
+	.map_err(err_to_string)?;
+
+	let kw_item = separated_pair(
+		map(take_while1(|v| v != '&' && v != '='), str::to_owned),
+		char('='),
+		map(take_while1(|v| v != '&'), str::to_owned),
+	);
+	let kw = map(separated_list1(char('&'), kw_item), |vec| {
+		vec.into_iter().collect::<BTreeMap<_, _>>()
+	});
+	let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);
+
+	let (input, attrs) = opt_kw(input).map_err(err_to_string)?;
+
+	if !input.is_empty() {
+		return Err(format!("unexpected trailing input: {input:?}"));
+	}
+	Ok(if is_tag {
+		HostItem::Tag { name, attrs }
+	} else {
+		HostItem::Host { name, attrs }
+	})
+}
+
+// TODO: Rename to HostSelector
+#[derive(Parser, Clone)]
+pub struct FleetOpts {
+	/// All hosts except those would be skipped
+	#[clap(long, number_of_values = 1, value_parser = host_item_parser)]
+	pub only: Vec<HostItem>,
+
+	/// Hosts to skip
+	#[clap(long, number_of_values = 1)]
+	pub skip: Vec<String>,
+
+	/// Host, which should be threaten as current machine
+	// TODO: Replace with connectivity refactor
+	#[clap(long, default_value_t = hostname::get().expect("unknown hostname").to_str().expect("hostname is not utf-8").to_owned())]
+	pub localhost: String,
+
+	/// Override detected system for host, to perform builds via
+	/// binfmt-declared qemu instead of trying to crosscompile
+	// TODO: Remove, as it is not used anymore.
+	#[clap(long, default_value = "detect")]
+	pub local_system: String,
+}
+
+impl FleetOpts {
+	pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {
+		if self.skip.iter().any(|h| h as &str == host.name) {
+			return Ok(true);
+		}
+		if self.only.is_empty() {
+			return Ok(false);
+		}
+		let mut have_group_matches = false;
+		for item in self.only.iter() {
+			match item {
+				HostItem::Host { name, .. } if *name == host.name => {
+					return Ok(false);
+				}
+				HostItem::Tag { .. } => {
+					have_group_matches = true;
+				}
+				_ => {}
+			}
+		}
+		if have_group_matches {
+			let host_tags = host.tags().await?;
+			for item in self.only.iter() {
+				match item {
+					HostItem::Tag { name, .. } if host_tags.contains(name) => {
+						return Ok(false);
+					}
+					_ => {}
+				}
+			}
+		}
+		Ok(true)
+	}
+	pub async fn action_attr<T: FromStr>(&self, host: &ConfigHost, attr: &str) -> Result<Option<T>>
+	where
+		T::Err: Sync,
+		anyhow::Error: From<T::Err>,
+	{
+		let str = self.action_attr_str(host, attr).await?;
+		Ok(str.map(|v| T::from_str(&v)).transpose()?)
+	}
+	pub async fn action_attr_str(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {
+		if self.only.is_empty() {
+			return Ok(None);
+		}
+		let mut have_group_matches = false;
+		for item in self.only.iter() {
+			match item {
+				HostItem::Host { name, attrs }
+					if *name == host.name && attrs.contains_key(attr) =>
+				{
+					return Ok(attrs.get(attr).cloned());
+				}
+				HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {
+					have_group_matches = true;
+				}
+				_ => {}
+			}
+		}
+		if have_group_matches {
+			let host_tags = host.tags().await?;
+			for item in self.only.iter() {
+				match item {
+					HostItem::Tag { name, attrs }
+						if host_tags.contains(name) && attrs.contains_key(attr) =>
+					{
+						return Ok(attrs.get(attr).cloned());
+					}
+					_ => {}
+				}
+			}
+		}
+		Ok(None)
+	}
+	pub fn is_local(&self, host: &str) -> bool {
+		self.localhost == host
+	}
+
+	// TODO: Config should be detached from opts.
+	pub async fn build(&self, nix_args: Vec<OsString>) -> Result<Config> {
+		let directory = current_dir()?;
+
+		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;
+		let root_field = pool.get().await?;
+
+		let builtins_field = Value::binding(root_field.clone(), "builtins").await?;
+		let local_system = if self.local_system == "detect" {
+			nix_go_json!(builtins_field.currentSystem)
+		} else {
+			self.local_system.clone()
+		};
+
+		let mut fleet_data_path = directory.clone();
+		fleet_data_path.push("fleet.nix");
+		let bytes = std::fs::read_to_string(fleet_data_path)?;
+		let data: Mutex<FleetData> = nixlike::parse_str(&bytes)?;
+
+		let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;
+		let fleet_field = nix_go!(fleet_root.default({ data }));
+
+		let config_field = nix_go!(fleet_field.config);
+
+		assert_warn("fleet config evaluation", &config_field).await?;
+
+		let import = nix_go!(builtins_field.import);
+		let overlays = nix_go!(config_field.nixpkgs.overlays);
+		let nixpkgs = nix_go!(fleet_field.nixpkgs.buildUsing | import);
+
+		let default_pkgs = nix_go!(nixpkgs(Obj {
+			overlays,
+			system: { self.local_system.clone() },
+		}));
+
+		Ok(Config(Arc::new(FleetConfigInternals {
+			directory,
+			data,
+			local_system,
+			nix_args,
+			config_field,
+			default_pkgs,
+			localhost: self.localhost.to_owned(),
+		})))
+	}
+}
addedcrates/fleet-shared/src/encoding.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-shared/src/encoding.rs
@@ -0,0 +1,156 @@
+use std::{
+	fmt::{self, Display},
+	str::FromStr,
+};
+
+use base64::engine::{general_purpose::STANDARD_NO_PAD, Engine};
+use serde::{de::Error, Deserialize, Deserializer, Serialize};
+use unicode_categories::UnicodeCategories;
+
+#[derive(Debug, PartialEq, Clone)]
+pub struct SecretData {
+	pub data: Vec<u8>,
+	pub encrypted: bool,
+}
+
+const BASE64_ENCODED_PREFIX: &str = "<BASE64-ENCODED>\n";
+const Z85_ENCODED_PREFIX: &str = "<Z85-ENCODED>\n";
+// Multiline text in Nix can only end with \n, which is not cool for actual single-line strings.
+const PLAINTEXT_NEWLINE_PREFIX: &str = "<PLAINTEXT-NL>\n";
+const PLAINTEXT_PREFIX: &str = "<PLAINTEXT>";
+
+const SECRET_PREFIX: &str = "<ENCRYPTED>";
+
+impl<'de> Deserialize<'de> for SecretData {
+	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+	where
+		D: Deserializer<'de>,
+	{
+		let string = String::deserialize(deserializer)?;
+		string.parse().map_err(D::Error::custom)
+	}
+}
+
+impl Serialize for SecretData {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		self.to_string().serialize(serializer)
+	}
+}
+
+impl FromStr for SecretData {
+	type Err = String;
+
+	fn from_str(string: &str) -> Result<Self, Self::Err> {
+		let (encrypted, string) = if let Some(unprefixed) = string.strip_prefix(SECRET_PREFIX) {
+			(true, unprefixed)
+		} else {
+			(false, string)
+		};
+		let data = if let Some(unprefixed) = string.strip_prefix(BASE64_ENCODED_PREFIX) {
+			STANDARD_NO_PAD
+				.decode(unprefixed.replace(|v| matches!(v, '\n' | '\t' | ' '), ""))
+				.map_err(|e| format!("base64-encoded failed: {e}"))?
+		} else if let Some(unprefixed) = string.strip_prefix(Z85_ENCODED_PREFIX) {
+			z85::decode(unprefixed.replace(|v| matches!(v, '\n' | '\t' | ' '), ""))
+				.map_err(|e| format!("z85-encoded failed: {e}"))?
+		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_NEWLINE_PREFIX) {
+			unprefixed.as_bytes().to_owned()
+		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_PREFIX) {
+			unprefixed.as_bytes().to_owned()
+		} else {
+			let secret_prefix = format!("{SECRET_PREFIX}{Z85_ENCODED_PREFIX}");
+			return Err(format!(
+				"unknown secret encoding. If you're migrating from old version of fleet, prefix public secret fields with {PLAINTEXT_PREFIX:?}, and encrypted data with {secret_prefix:?}: {string}"
+			));
+		};
+		Ok(Self { data, encrypted })
+	}
+}
+
+impl Display for SecretData {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		let mut readable = std::str::from_utf8(&self.data).ok();
+		if self.encrypted {
+			write!(f, "{SECRET_PREFIX}")?;
+			// Always base64-encode encrypted fields.
+			readable = None;
+		}
+		if Some(false) == readable.map(is_printable) {
+			readable = None
+		};
+		// TODO: Check if text is readable, and has no unprintable characters?..
+		if let Some(plaintext) = readable {
+			if plaintext.ends_with('\n') {
+				write!(f, "{PLAINTEXT_NEWLINE_PREFIX}")?;
+			} else {
+				write!(f, "{PLAINTEXT_PREFIX}")?;
+			}
+			write!(f, "{plaintext}")?;
+		} else {
+			write!(f, "{BASE64_ENCODED_PREFIX}")?;
+			let encoded = STANDARD_NO_PAD.encode(&self.data);
+			for ele in encoded.as_bytes().chunks(64) {
+				let chunk = std::str::from_utf8(ele).expect(
+					"any slice of base64-encoded text is utf-8 compatible, as it is ascii-based",
+				);
+				writeln!(f, "{chunk}")?;
+			}
+		};
+		Ok(())
+	}
+}
+
+fn is_printable(text: &str) -> bool {
+	text.chars().all(|c| {
+		c.is_letter()
+			|| c.is_mark()
+			|| c.is_number()
+			|| c.is_punctuation()
+			|| c.is_separator()
+			|| c == '\n' || c == '\t'
+			// Complete base64 alphabet
+			|| c == '/' || c == '+'
+			|| c == '='
+	})
+}
+
+#[test]
+fn test() {
+	fn check_roundtrip(data: SecretData, expected: &str) {
+		let string = data.to_string();
+		assert_eq!(string, expected, "unexpected encoding");
+		let roundtrip: SecretData = string.parse().expect("roundtrip parse");
+		assert_eq!(data, roundtrip, "roundtrip didn't match");
+	}
+	check_roundtrip(
+		SecretData {
+			data: vec![1, 2, 3, 4, 5, 6],
+			encrypted: false,
+		},
+		"<BASE64-ENCODED>\nAQIDBAUG\n",
+	);
+	check_roundtrip(
+		SecretData {
+			data: vec![1, 2, 3, 4, 5, 6],
+			encrypted: true,
+		},
+		"<ENCRYPTED><BASE64-ENCODED>\nAQIDBAUG\n",
+	);
+	check_roundtrip(
+		SecretData {
+			data: "Привет, мир!\n".to_owned().into(),
+			encrypted: false,
+		},
+		"<PLAINTEXT-NL>\nПривет, мир!\n",
+	);
+	check_roundtrip(
+		SecretData {
+			data: "Привет, мир!".to_owned().into(),
+			encrypted: false,
+		},
+		"<PLAINTEXT>Привет, мир!",
+	);
+}
modifiedcrates/fleet-shared/src/lib.rsdiffbeforeafterboth
--- a/crates/fleet-shared/src/lib.rs
+++ b/crates/fleet-shared/src/lib.rs
@@ -1,156 +1,2 @@
-use std::{
-	fmt::{self, Display},
-	str::FromStr,
-};
-
-use base64::engine::{general_purpose::STANDARD_NO_PAD, Engine};
-use serde::{de::Error, Deserialize, Deserializer, Serialize};
-use unicode_categories::UnicodeCategories;
-
-#[derive(Debug, PartialEq, Clone)]
-pub struct SecretData {
-	pub data: Vec<u8>,
-	pub encrypted: bool,
-}
-
-const BASE64_ENCODED_PREFIX: &str = "<BASE64-ENCODED>\n";
-const Z85_ENCODED_PREFIX: &str = "<Z85-ENCODED>\n";
-// Multiline text in Nix can only end with \n, which is not cool for actual single-line strings.
-const PLAINTEXT_NEWLINE_PREFIX: &str = "<PLAINTEXT-NL>\n";
-const PLAINTEXT_PREFIX: &str = "<PLAINTEXT>";
-
-const SECRET_PREFIX: &str = "<ENCRYPTED>";
-
-impl<'de> Deserialize<'de> for SecretData {
-	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-	where
-		D: Deserializer<'de>,
-	{
-		let string = String::deserialize(deserializer)?;
-		string.parse().map_err(D::Error::custom)
-	}
-}
-
-impl Serialize for SecretData {
-	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-	where
-		S: serde::Serializer,
-	{
-		self.to_string().serialize(serializer)
-	}
-}
-
-impl FromStr for SecretData {
-	type Err = String;
-
-	fn from_str(string: &str) -> Result<Self, Self::Err> {
-		let (encrypted, string) = if let Some(unprefixed) = string.strip_prefix(SECRET_PREFIX) {
-			(true, unprefixed)
-		} else {
-			(false, string)
-		};
-		let data = if let Some(unprefixed) = string.strip_prefix(BASE64_ENCODED_PREFIX) {
-			STANDARD_NO_PAD
-				.decode(unprefixed.replace(|v| matches!(v, '\n' | '\t' | ' '), ""))
-				.map_err(|e| format!("base64-encoded failed: {e}"))?
-		} else if let Some(unprefixed) = string.strip_prefix(Z85_ENCODED_PREFIX) {
-			z85::decode(unprefixed.replace(|v| matches!(v, '\n' | '\t' | ' '), ""))
-				.map_err(|e| format!("z85-encoded failed: {e}"))?
-		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_NEWLINE_PREFIX) {
-			unprefixed.as_bytes().to_owned()
-		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_PREFIX) {
-			unprefixed.as_bytes().to_owned()
-		} else {
-			let secret_prefix = format!("{SECRET_PREFIX}{Z85_ENCODED_PREFIX}");
-			return Err(format!(
-				"unknown secret encoding. If you're migrating from old version of fleet, prefix public secret fields with {PLAINTEXT_PREFIX:?}, and encrypted data with {secret_prefix:?}: {string}"
-			));
-		};
-		Ok(Self { data, encrypted })
-	}
-}
-
-impl Display for SecretData {
-	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-		let mut readable = std::str::from_utf8(&self.data).ok();
-		if self.encrypted {
-			write!(f, "{SECRET_PREFIX}")?;
-			// Always base64-encode encrypted fields.
-			readable = None;
-		}
-		if Some(false) == readable.map(is_printable) {
-			readable = None
-		};
-		// TODO: Check if text is readable, and has no unprintable characters?..
-		if let Some(plaintext) = readable {
-			if plaintext.ends_with('\n') {
-				write!(f, "{PLAINTEXT_NEWLINE_PREFIX}")?;
-			} else {
-				write!(f, "{PLAINTEXT_PREFIX}")?;
-			}
-			write!(f, "{plaintext}")?;
-		} else {
-			write!(f, "{BASE64_ENCODED_PREFIX}")?;
-			let encoded = STANDARD_NO_PAD.encode(&self.data);
-			for ele in encoded.as_bytes().chunks(64) {
-				let chunk = std::str::from_utf8(ele).expect(
-					"any slice of base64-encoded text is utf-8 compatible, as it is ascii-based",
-				);
-				writeln!(f, "{chunk}")?;
-			}
-		};
-		Ok(())
-	}
-}
-
-fn is_printable(text: &str) -> bool {
-	text.chars().all(|c| {
-		c.is_letter()
-			|| c.is_mark()
-			|| c.is_number()
-			|| c.is_punctuation()
-			|| c.is_separator()
-			|| c == '\n' || c == '\t'
-			// Complete base64 alphabet
-			|| c == '/' || c == '+'
-			|| c == '='
-	})
-}
-
-#[test]
-fn test() {
-	fn check_roundtrip(data: SecretData, expected: &str) {
-		let string = data.to_string();
-		assert_eq!(string, expected, "unexpected encoding");
-		let roundtrip: SecretData = string.parse().expect("roundtrip parse");
-		assert_eq!(data, roundtrip, "roundtrip didn't match");
-	}
-	check_roundtrip(
-		SecretData {
-			data: vec![1, 2, 3, 4, 5, 6],
-			encrypted: false,
-		},
-		"<BASE64-ENCODED>\nAQIDBAUG\n",
-	);
-	check_roundtrip(
-		SecretData {
-			data: vec![1, 2, 3, 4, 5, 6],
-			encrypted: true,
-		},
-		"<ENCRYPTED><BASE64-ENCODED>\nAQIDBAUG\n",
-	);
-	check_roundtrip(
-		SecretData {
-			data: "Привет, мир!\n".to_owned().into(),
-			encrypted: false,
-		},
-		"<PLAINTEXT-NL>\nПривет, мир!\n",
-	);
-	check_roundtrip(
-		SecretData {
-			data: "Привет, мир!".to_owned().into(),
-			encrypted: false,
-		},
-		"<PLAINTEXT>Привет, мир!",
-	);
-}
+mod encoding;
+pub use encoding::SecretData;
modifiedcrates/nix-eval/src/session.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/session.rs
+++ b/crates/nix-eval/src/session.rs
@@ -12,7 +12,7 @@
 	sync::{mpsc, oneshot, Mutex},
 };
 use tokio_util::codec::{FramedRead, LinesCodec};
-use tracing::{debug, error, info, warn, Level};
+use tracing::{debug, error, warn, Level};
 
 #[derive(Error, Debug)]
 pub enum Error {
@@ -147,8 +147,7 @@
 		// 	s.split('\n').filter(|s| !s.trim().is_empty()).map(|v| v.)
 		// }
 		if !self.collected.is_empty() {
-			return Err(Error::NixError(format!(
-				"{}",
+			return Err(Error::NixError(
 				self.collected
 					.iter()
 					.map(|v| {
@@ -159,8 +158,9 @@
 							v.to_owned()
 						}
 					})
-					.join("\n"),
-			)));
+					.join("\n")
+					.to_string(),
+			));
 		}
 		Ok(())
 	}
@@ -316,7 +316,7 @@
 			}
 			out.push_str(&line);
 		}
-		return Err(Error::MissingDelimiter);
+		Err(Error::MissingDelimiter)
 	}
 	pub(crate) async fn execute_expression_number(
 		&mut self,
@@ -347,9 +347,10 @@
 		let mut fexpr = b"builtins.toJSON (".to_vec();
 		fexpr.extend_from_slice(expr.as_ref());
 		fexpr.push(b')');
-		let s = String::from_utf8_lossy(expr.as_ref());
-		let v = self.execute_expression_string(fexpr).await?;
-		Ok(serde_json::from_str(&v)?)
+
+		Ok(serde_json::from_str(
+			&self.execute_expression_string(fexpr).await?,
+		)?)
 	}
 	async fn execute_expression_wrapping(
 		&mut self,
deletedcrates/remowt-fs/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-fs/Cargo.toml
+++ /dev/null
@@ -1,8 +0,0 @@
-[package]
-name = "remowt-fs"
-version = "0.1.0"
-edition = "2021"
-
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
-[dependencies]
deletedcrates/remowt-fs/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-fs/src/lib.rs
+++ /dev/null
@@ -1 +0,0 @@
-trait RemowtFS {}
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -116,6 +116,7 @@
               bacon
               nil
             ];
+            env.PROTOC = "${pkgs.protobuf}/bin/protoc";
           };
         };
         # fleet-install-secrets will not be built normally, because they are not ran directly by user most of the time.
modifiedmodules/extras/tf.nixdiffbeforeafterboth
--- a/modules/extras/tf.nix
+++ b/modules/extras/tf.nix
@@ -1,26 +1,45 @@
 {
   config,
   lib,
+  fleetLib,
   inputs,
   ...
 }: let
-  inherit (lib) mkOption;
-  inherit (lib.types) deferredModule;
+  inherit (lib.options) mkOption;
+  inherit (lib.types) deferredModule attrsOf unspecified;
+  inherit (fleetLib.options) mkDataOption;
 in {
-  options.tf = mkOption {
-    type = deferredModule;
-    apply = module: system:
-      inputs.terranix.lib.terranixConfigurationAst {
-        inherit system;
-        pkgs = config.nixpkgs.buildUsing.legacyPackages.${system};
-        modules = [module];
+  options = {
+    tf = mkOption {
+      type = deferredModule;
+      apply = module: system:
+        inputs.terranix.lib.terranixConfiguration {
+          inherit system;
+          pkgs = config.nixpkgs.buildUsing.legacyPackages.${system};
+          modules = [
+            module
+          ];
+        };
+    };
+    data = mkDataOption {
+      # host => hostData
+      options.extra.terraformHosts = mkOption {
+        default = {};
+        type = attrsOf (attrsOf unspecified);
+        description = "Hosts data provided by fleet tf";
       };
+    };
   };
-  config.tf.output.fleet = {
-    value = {
-      managed = true;
+
+  config = {
+    tf.output.fleet = {
+      value = {
+        managed = true;
+      };
+      # Just to avoid printing this attribute on every apply, the whole fleet attribute
+      # will be somehow processed by fleet tf.
+      sensitive = true;
     };
-    # Just to avoid printing this attribute on every apply.
-    sensitive = true;
+    hosts = config.data.extra.terraformHosts;
   };
 }
modifiedmodules/secrets-data.nixdiffbeforeafterboth
--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -6,8 +6,8 @@
 }: let
   inherit (fleetLib.options) mkDataOption;
   inherit (lib.options) mkOption;
-  inherit (lib.types) lazyAttrsOf nullOr listOf str attrsOf submodule bool;
-  inherit (lib.attrsets) mapAttrsToList mapAttrs catAttrs filterAttrs genAttrs;
+  inherit (lib.types) nullOr listOf str attrsOf submodule bool;
+  inherit (lib.attrsets) mapAttrsToList mapAttrs filterAttrs genAttrs;
   inherit (lib.lists) sort unique concatLists;
   inherit (lib.strings) toJSON;