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
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -2,15 +2,14 @@
 
 use anyhow::{anyhow, Result};
 use clap::{Parser, ValueEnum};
+use fleet_base::{
+	host::{Config, ConfigHost},
+	opts::FleetOpts,
+};
 use itertools::Itertools as _;
 use nix_eval::nix_go;
 use tokio::{task::LocalSet, time::sleep};
 use tracing::{error, field, info, info_span, warn, Instrument};
-
-use crate::{
-	command::MyCommand,
-	host::{Config, ConfigHost},
-};
 
 #[derive(Parser)]
 pub struct Deploy {
@@ -253,7 +252,6 @@
 	info!("building");
 	let host = config.host(&host).await?;
 	// let action = Action::from(self.subcommand.clone());
-	let fleet_config = &config.config_field;
 	let nixos = host.nixos_config().await?;
 	let drv = nix_go!(nixos.system.build[{ build_attr }]);
 	let outputs = drv.build().await.inspect_err(|_| {
@@ -270,12 +268,12 @@
 }
 
 impl BuildSystems {
-	pub async fn run(self, config: &Config) -> Result<()> {
+	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
 		let hosts = config.list_hosts().await?;
 		let set = LocalSet::new();
 		let build_attr = self.build_attr.clone();
 		for host in hosts.into_iter() {
-			if config.should_skip(&host).await? {
+			if opts.should_skip(&host).await? {
 				continue;
 			}
 			let config = config.clone();
@@ -320,17 +318,18 @@
 }
 
 impl Deploy {
-	pub async fn run(self, config: &Config) -> Result<()> {
+	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
 		let hosts = config.list_hosts().await?;
 		let set = LocalSet::new();
 		for host in hosts.into_iter() {
-			if config.should_skip(&host).await? {
+			if opts.should_skip(&host).await? {
 				continue;
 			}
 			let config = config.clone();
 			let span = info_span!("deploy", host = field::display(&host.name));
 			let hostname = host.name.clone();
 			let local_host = config.local_host();
+			let opts = opts.clone();
 			// FIXME: Fix repl concurrency (see build-systems)
 			set.spawn_local(
 				(async move {
@@ -342,7 +341,7 @@
 							return;
 						}
 					};
-					if !config.is_local(&hostname) {
+					if !opts.is_local(&hostname) {
 						info!("uploading system closure");
 						{
 							// TODO: Move to remote_derivation method.
@@ -387,7 +386,7 @@
 						self.action,
 						&host,
 						built,
-						if let Ok(v) = config.action_attr(&host, "specialisation").await {
+						if let Ok(v) = opts.action_attr(&host, "specialisation").await {
 							v
 						} else {
 							error!("unreachable? failed to get specialization");
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
before · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	ffi::OsString,4	io::{self, stdin, stdout, Read, Write},5	path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use nix_eval::{nix_go, nix_go_json, Value};15use owo_colors::OwoColorize;16use serde::Deserialize;17use tabled::{Table, Tabled};18use tokio::{fs::read, process::Command};19use tracing::{error, info, info_span, warn, Instrument};2021use crate::{22	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23	host::Config,24};2526#[derive(Parser)]27pub enum Secret {28	/// Force load host keys for all defined hosts29	ForceKeys,30	/// Add secret, data should be provided in stdin31	AddShared {32		/// Secret name33		name: String,34		/// Secret owners35		#[clap(long, short)]36		machines: Vec<String>,37		/// Override secret if already present38		#[clap(long)]39		force: bool,40		/// Secret public part41		#[clap(long)]42		public: Option<String>,43		/// Load public part from specified file44		#[clap(long)]45		public_file: Option<PathBuf>,4647		/// Create a notification on secret expiration48		#[clap(long)]49		expires_at: Option<DateTime<Utc>>,5051		/// Secret with this name already exists, override its value while keeping the same owners.52		#[clap(long)]53		re_add: bool,5455		/// How to name public secret part56		#[clap(long, short = 'p', default_value = "public")]57		public_part: String,58		/// How to name private secret part59		#[clap(short = 's', long, default_value = "secret")]60		part: String,61	},62	/// Add secret, data should be provided in stdin63	Add {64		/// Secret name65		name: String,66		/// Secret owner67		#[clap(short = 'm', long)]68		machine: String,69		/// Replace secret if already present70		#[clap(long)]71		replace: bool,72		/// Add new parts to existing secret73		#[clap(long)]74		merge: bool,75		/// Secret public part76		#[clap(long)]77		public: Option<String>,78		/// Load public part from specified file79		#[clap(long)]80		public_file: Option<PathBuf>,8182		/// How to name public secret part83		#[clap(short = 'p', long, default_value = "public")]84		public_part: String,85		/// How to name private secret part86		#[clap(short = 's', long, default_value = "secret")]87		part: String,88	},89	/// Read secret from remote host, requires sudo on said host90	Read {91		name: String,92		#[clap(short = 'm', long)]93		machine: String,9495		/// Which private secret part to read96		#[clap(short = 'p', long, default_value = "secret")]97		part: String,98	},99	UpdateShared {100		name: String,101102		#[clap(short = 'm', long)]103		machine: Option<Vec<String>>,104105		#[clap(long)]106		add_machine: Vec<String>,107		#[clap(long)]108		remove_machine: Vec<String>,109110		/// Which host should we use to decrypt111		#[clap(long)]112		prefer_identities: Vec<String>,113	},114	Regenerate {115		/// Which host should we use to decrypt, in case if reencryption is required, without116		/// regeneration117		#[clap(long)]118		prefer_identities: Vec<String>,119	},120	List {},121	Edit {122		name: String,123		#[clap(short = 'm', long)]124		machine: String,125126		#[clap(long)]127		add: bool,128129		/// Which private secret part to read130		#[clap(short = 'p', long, default_value = "secret")]131		part: String,132	},133}134135#[tracing::instrument(skip(config, secret, field, prefer_identities))]136async fn update_owner_set(137	secret_name: &str,138	config: &Config,139	mut secret: FleetSharedSecret,140	field: Value,141	updated_set: &[String],142	prefer_identities: &[String],143) -> Result<FleetSharedSecret> {144	let original_set = secret.owners.clone();145146	let set = original_set.iter().collect::<BTreeSet<_>>();147	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();148149	if set == expected_set {150		info!("no need to update owner list, it is already correct");151		return Ok(secret);152	}153154	let should_regenerate = if set.difference(&expected_set).next().is_some() {155		// TODO: Remove this warning for revokable secrets.156		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");157		nix_go_json!(field.regenerateOnOwnerRemoved)158	} else if expected_set.difference(&set).next().is_some() {159		nix_go_json!(field.regenerateOnOwnerAdded)160	} else {161		false162	};163164	if should_regenerate {165		info!("secret is owner-dependent, will regenerate");166		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;167		Ok(generated)168	} else {169		let identity_holder = if !prefer_identities.is_empty() {170			prefer_identities171				.iter()172				.find(|i| original_set.iter().any(|s| s == *i))173		} else {174			secret.owners.first()175		};176		let Some(identity_holder) = identity_holder else {177			bail!("no available holder found");178		};179180		for (part_name, part) in secret.secret.parts.iter_mut() {181			let _span = info_span!("part reencryption", part_name);182			if !part.raw.encrypted {183				continue;184			}185			let host = config.host(identity_holder).await?;186			let encrypted = host187				.reencrypt(part.raw.clone(), updated_set.to_vec())188				.await?;189			part.raw = encrypted;190		}191192		secret.owners = updated_set.to_vec();193		Ok(secret)194	}195}196197#[derive(Deserialize)]198#[serde(rename_all = "camelCase")]199enum GeneratorKind {200	Impure,201	Pure,202}203204async fn generate_pure(205	_config: &Config,206	_display_name: &str,207	_secret: Value,208	_default_generator: Value,209	_owners: &[String],210) -> Result<FleetSecret> {211	bail!("pure generators are broken for now")212}213async fn generate_impure(214	config: &Config,215	_display_name: &str,216	secret: Value,217	default_generator: Value,218	owners: &[String],219) -> Result<FleetSecret> {220	let generator = nix_go!(secret.generator);221	let on: Option<String> = nix_go_json!(default_generator.impureOn);222223	let host = if let Some(on) = &on {224		config.host(on).await?225	} else {226		config.local_host()227	};228	let on_pkgs = host.pkgs().await?;229	let call_package = nix_go!(on_pkgs.callPackage);230	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);231232	let mut recipients = Vec::new();233	for owner in owners {234		let key = config.key(owner).await?;235		recipients.push(key);236	}237	let generators = nix_go!(mk_secret_generators(Obj {238		recipients: { recipients },239	}));240241	let generator = nix_go!(call_package(generator)(generators));242243	let generator = generator.build().await?;244	let generator = generator245		.get("out")246		.ok_or_else(|| anyhow!("missing generateImpure out"))?;247	let generator = host.remote_derivation(generator).await?;248249	let out_parent = host.mktemp_dir().await?;250	let out = format!("{out_parent}/out");251252	let mut gen = host.cmd(generator).await?;253	gen.env("out", &out);254	if on.is_none() {255		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.256		let project_path: String = config257			.directory258			.clone()259			.into_os_string()260			.into_string()261			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;262		gen.env("FLEET_PROJECT", project_path);263	}264	gen.run().await.context("impure generator")?;265266	{267		let marker = host.read_file_text(format!("{out}/marker")).await?;268		ensure!(marker == "SUCCESS", "generation not succeeded");269	}270271	let mut parts = BTreeMap::new();272	for part in host.read_dir(&out).await? {273		if part == "created_at" || part == "expired_at" || part == "marker" {274			continue;275		}276		let contents: SecretData = host277			.read_file_text(format!("{out}/{part}"))278			.await?279			.parse()280			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;281		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });282	}283284	let created_at = host.read_file_value(format!("{out}/created_at")).await?;285	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();286287	Ok(FleetSecret {288		created_at,289		expires_at,290		parts,291	})292}293async fn generate(294	config: &Config,295	display_name: &str,296	secret: Value,297	owners: &[String],298) -> Result<FleetSecret> {299	let generator = nix_go!(secret.generator);300	// Can't properly check on nix module system level301	{302		let gen_ty = generator.type_of().await?;303		if gen_ty == "null" {304			bail!("secret has no generator defined, can't automatically generate it.");305		}306		if gen_ty != "lambda" {307			bail!("generator should be lambda, got {gen_ty}");308		}309	}310	let default_pkgs = &config.default_pkgs;311	let default_call_package = nix_go!(default_pkgs.callPackage);312	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);313	// Generators provide additional information in passthru, to access314	// passthru we should call generator, but information about where this generator is supposed to build315	// is located in passthru... Thus evaluating generator on host.316	//317	// Maybe it is also possible to do some magic with __functor?318	//319	// I don't want to make modules always responsible for additional secret data anyway,320	// so it should be in derivation, and not in the secret data itself.321	let generators = nix_go!(default_mk_secret_generators(Obj {322		recipients: { <Vec<String>>::new() },323	}));324	let default_generator = nix_go!(default_call_package(generator)(generators));325326	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);327328	match kind {329		GeneratorKind::Impure => {330			generate_impure(config, display_name, secret, default_generator, owners).await331		}332		GeneratorKind::Pure => {333			generate_pure(config, display_name, secret, default_generator, owners).await334		}335	}336}337async fn generate_shared(338	config: &Config,339	display_name: &str,340	secret: Value,341	expected_owners: Vec<String>,342) -> Result<FleetSharedSecret> {343	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);344	Ok(FleetSharedSecret {345		secret: generate(config, display_name, secret, &expected_owners).await?,346		owners: expected_owners,347	})348}349350async fn parse_public(351	public: Option<String>,352	public_file: Option<PathBuf>,353) -> Result<Option<SecretData>> {354	Ok(match (public, public_file) {355		(Some(v), None) => Some(SecretData {356			data: v.into(),357			encrypted: false,358		}),359		(None, Some(v)) => Some(SecretData {360			data: read(v).await?,361			encrypted: false,362		}),363		(Some(_), Some(_)) => {364			bail!("only public or public_file should be set")365		}366		(None, None) => None,367	})368}369370async fn parse_secret() -> Result<Option<Vec<u8>>> {371	let mut input = vec![];372	stdin().read_to_end(&mut input)?;373	if input.is_empty() {374		Ok(None)375	} else {376		Ok(Some(input))377	}378}379380fn parse_machines(381	initial: Vec<String>,382	machines: Option<Vec<String>>,383	mut add_machines: Vec<String>,384	mut remove_machines: Vec<String>,385) -> Result<Vec<String>> {386	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {387		bail!("no operation");388	}389390	let initial_machines = initial.clone();391	let mut target_machines = initial;392	info!("Currently encrypted for {initial_machines:?}");393394	// ensure!(machines.is_some() || !add_machines.is_empty() || )395	if let Some(machines) = machines {396		ensure!(397			add_machines.is_empty() && remove_machines.is_empty(),398			"can't combine --machines and --add-machines/--remove-machines"399		);400		let target = initial_machines.iter().collect::<HashSet<_>>();401		let source = machines.iter().collect::<HashSet<_>>();402		for removed in target.difference(&source) {403			remove_machines.push((*removed).clone());404		}405		for added in source.difference(&target) {406			add_machines.push((*added).clone());407		}408	}409410	for machine in &remove_machines {411		let mut removed = false;412		while let Some(pos) = target_machines.iter().position(|m| m == machine) {413			target_machines.swap_remove(pos);414			removed = true;415		}416		if !removed {417			warn!("secret is not enabled for {machine}");418		}419	}420	for machine in &add_machines {421		if target_machines.iter().any(|m| m == machine) {422			warn!("secret is already added to {machine}");423		} else {424			target_machines.push(machine.to_owned());425		}426	}427	if !remove_machines.is_empty() {428		// TODO: maybe force secret regeneration?429		// Not that useful without revokation.430		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");431	}432	Ok(target_machines)433}434impl Secret {435	pub async fn run(self, config: &Config) -> Result<()> {436		match self {437			Secret::ForceKeys => {438				for host in config.list_hosts().await? {439					if config.should_skip(&host).await? {440						continue;441					}442					config.key(&host.name).await?;443				}444			}445			Secret::AddShared {446				mut machines,447				name,448				force,449				public,450				public_part: public_name,451				public_file,452				expires_at,453				re_add,454				part: part_name,455			} => {456				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).457458				let exists = config.has_shared(&name);459				if exists && !force && !re_add {460					bail!("secret already defined");461				}462				if re_add {463					// Fixme: use clap to limit this usage464					ensure!(!force, "--force and --readd are not compatible");465					ensure!(exists, "secret doesn't exists");466					ensure!(467						machines.is_empty(),468						"you can't use machines argument for --readd"469					);470					let shared = config.shared_secret(&name)?;471					machines = shared.owners;472				}473474				let recipients = config.recipients(machines.clone()).await?;475476				let mut parts = BTreeMap::new();477478				let mut input = vec![];479				io::stdin().read_to_end(&mut input)?;480481				if !input.is_empty() {482					let encrypted = encrypt_secret_data(recipients, input)483						.ok_or_else(|| anyhow!("no recipients provided"))?;484					parts.insert(part_name, FleetSecretPart { raw: encrypted });485				}486487				if let Some(public) = parse_public(public, public_file).await? {488					parts.insert(public_name, FleetSecretPart { raw: public });489				}490491				config.replace_shared(492					name,493					FleetSharedSecret {494						owners: machines,495						secret: FleetSecret {496							created_at: Utc::now(),497							expires_at,498							parts,499						},500					},501				);502			}503			Secret::Add {504				machine,505				name,506				replace,507				merge,508				public,509				public_part: public_name,510				public_file,511				part: part_name,512			} => {513				if config.has_secret(&machine, &name) && !replace && !merge {514					bail!("secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret");515				}516517				let mut out = if merge && !replace {518					config519						.host_secret(&machine, &name)520						.context("failed to read existing secret for --merge")?521				} else {522					FleetSecret {523						created_at: Utc::now(),524						expires_at: None,525						parts: BTreeMap::new(),526					}527				};528529				if let Some(secret) = parse_secret().await? {530					let recipient = config.recipient(&machine).await?;531					let encrypted =532						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");533					if out534						.parts535						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })536						.is_some() && !replace537					{538						bail!("part {part_name:?} is already defined");539					}540				}541542				if let Some(public) = parse_public(public, public_file).await? {543					if out544						.parts545						.insert(public_name.clone(), FleetSecretPart { raw: public })546						.is_some() && !replace547					{548						bail!("part {public_name:?} is already defined");549					}550				};551552				config.insert_secret(&machine, name, out);553			}554			#[allow(clippy::await_holding_refcell_ref)]555			Secret::Read {556				name,557				machine,558				part: part_name,559			} => {560				let secret = config.host_secret(&machine, &name)?;561				let Some(secret) = secret.parts.get(&part_name) else {562					bail!("no part {part_name} in secret {name}");563				};564				let data = if secret.raw.encrypted {565					let host = config.host(&machine).await?;566					host.decrypt(secret.raw.clone()).await?567				} else {568					secret.raw.data.clone()569				};570571				stdout().write_all(&data)?;572			}573			Secret::UpdateShared {574				name,575				machine,576				add_machine,577				remove_machine,578				prefer_identities,579			} => {580				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).581582				let secret = config.shared_secret(&name)?;583				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {584					bail!("no secret");585				}586587				let initial_machines = secret.owners.clone();588				let target_machines = parse_machines(589					initial_machines.clone(),590					machine,591					add_machine,592					remove_machine,593				)?;594595				if target_machines.is_empty() {596					info!("no machines left for secret, removing it");597					config.remove_shared(&name);598					return Ok(());599				}600601				let config_field = &config.config_field;602				let field = nix_go!(config_field.sharedSecrets[{ name }]);603604				let updated = update_owner_set(605					&name,606					config,607					secret,608					field,609					&target_machines,610					&prefer_identities,611				)612				.await?;613				config.replace_shared(name, updated);614			}615			Secret::Regenerate { prefer_identities } => {616				info!("checking for secrets to regenerate");617				{618					let _span = info_span!("shared").entered();619					let expected_shared_set = config620						.list_configured_shared()621						.await?622						.into_iter()623						.collect::<HashSet<_>>();624					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();625					for missing in expected_shared_set.difference(&shared_set) {626						let config_field = &config.config_field;627						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);628						let expected_owners: Option<Vec<String>> =629							nix_go_json!(secret.expectedOwners);630						let Some(expected_owners) = expected_owners else {631							// TODO: Might still need to regenerate632							continue;633						};634						info!("generating secret: {missing}");635						let shared = generate_shared(config, missing, secret, expected_owners)636							.in_current_span()637							.await?;638						config.replace_shared(missing.to_string(), shared)639					}640				}641				for host in config.list_hosts().await? {642					if config.should_skip(&host).await? {643						continue;644					}645646					let _span = info_span!("host", host = host.name).entered();647					let expected_set = host648						.list_configured_secrets()649						.in_current_span()650						.await?651						.into_iter()652						.collect::<HashSet<_>>();653					let stored_set = config654						.list_secrets(&host.name)655						.into_iter()656						.collect::<HashSet<_>>();657					for missing in expected_set.difference(&stored_set) {658						info!("generating secret: {missing}");659						let secret = host.secret_field(missing).in_current_span().await?;660						let generated =661							match generate(config, missing, secret, &[host.name.clone()])662								.in_current_span()663								.await664							{665								Ok(v) => v,666								Err(e) => {667									error!("{e:?}");668									continue;669								}670							};671						config.insert_secret(&host.name, missing.to_string(), generated)672					}673				}674				let mut to_remove = Vec::new();675				for name in &config.list_shared() {676					info!("updating secret: {name}");677					let data = config.shared_secret(name)?;678					let config_field = &config.config_field;679					let expected_owners: Vec<String> =680						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);681					if expected_owners.is_empty() {682						warn!("secret was removed from fleet config: {name}, removing from data");683						to_remove.push(name.to_string());684						continue;685					}686687					let secret = nix_go!(config_field.sharedSecrets[{ name }]);688					config.replace_shared(689						name.to_owned(),690						update_owner_set(691							name,692							config,693							data,694							secret,695							&expected_owners,696							&prefer_identities,697						)698						.await?,699					);700				}701				for k in to_remove {702					config.remove_shared(&k);703				}704			}705			Secret::List {} => {706				let _span = info_span!("loading secrets").entered();707				let configured = config.list_configured_shared().await?;708				#[derive(Tabled)]709				struct SecretDisplay {710					#[tabled(rename = "Name")]711					name: String,712					#[tabled(rename = "Owners")]713					owners: String,714				}715				let mut table = vec![];716				for name in configured.iter().cloned() {717					let config = config.clone();718					let expected_owners = config.shared_secret_expected_owners(&name).await?;719					let data = config.shared_secret(&name)?;720					let owners = data721						.owners722						.iter()723						.map(|o| {724							if expected_owners.contains(o) {725								o.green().to_string()726							} else {727								o.red().to_string()728							}729						})730						.collect::<Vec<_>>();731					table.push(SecretDisplay {732						owners: owners.join(", "),733						name,734					})735				}736				info!("loaded\n{}", Table::new(table).to_string())737			}738			Secret::Edit {739				name,740				machine,741				part,742				add,743			} => {744				let secret = config.host_secret(&machine, &name)?;745				if let Some(data) = secret.parts.get(&part) {746					let host = config.host(&machine).await?;747					let secret = host.decrypt(data.raw.clone()).await?;748					String::from_utf8(secret).context("secret is not utf8")?749				} else if add {750					String::new()751				} else {752					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");753				};754			}755		}756		Ok(())757	}758}759760async fn edit_temp_file(761	builder: tempfile::Builder<'_, '_>,762	r: Vec<u8>,763	header: &str,764	comment: &str,765) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {766	if !stdin().is_tty() {767		// TODO: Also try to open /dev/tty directly?768		bail!("stdin is not tty, can't open editor");769	}770771	use std::fmt::Write;772	let mut file = builder.tempfile()?;773774	let mut full_header = String::new();775	let mut had = false;776	for line in header.trim_end().lines() {777		had = true;778		writeln!(&mut full_header, "{comment}{line}")?;779	}780	if had {781		writeln!(&mut full_header, "{}", comment.trim_end())?;782	}783	writeln!(784		&mut full_header,785		"{comment}Do not touch this header! It will be removed automatically"786	)?;787788	file.write_all(full_header.as_bytes())?;789	file.write_all(&r)?;790791	let abs_path = file.into_temp_path();792	let editor = std::env::var_os("VISUAL")793		.or_else(|| std::env::var_os("EDITOR"))794		.unwrap_or_else(|| "vi".into());795	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())796		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;797	let editor_args = editor_args798		.into_iter()799		.map(|v| {800			// Only ASCII subsequences are replaced801			unsafe { OsString::from_encoded_bytes_unchecked(v) }802		})803		.collect_vec();804	let Some((editor, args)) = editor_args.split_first() else {805		bail!("EDITOR env var has no command");806	};807	let mut command = Command::new(editor);808	command.args(args);809810	let path_arg = abs_path.canonicalize()?;811812	// TODO: Save full state, using tcget/_getmode/_setmode813	let was_raw = terminal::is_raw_mode_enabled()?;814	terminal::enable_raw_mode()?;815816	let status = command.arg(path_arg).status().await;817818	if !was_raw {819		terminal::disable_raw_mode()?;820	}821822	let success = match status {823		Ok(s) => s.success(),824		Err(e) if e.kind() == io::ErrorKind::NotFound => {825			bail!("editor not found")826		}827		Err(e) => bail!("editor spawn error: {e}"),828	};829830	let mut file = std::fs::read(&abs_path).context("read editor output")?;831	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {832		todo!();833	};834	todo!();835836	// Ok((success, abs_path))837}
after · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	io::{self, stdin, stdout, Read, Write},4	path::PathBuf,5};67use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},12	host::Config,13	opts::FleetOpts,14};15use fleet_shared::SecretData;16use nix_eval::{nix_go, nix_go_json, Value};17use owo_colors::OwoColorize;18use serde::Deserialize;19use tabled::{Table, Tabled};20use tokio::fs::read;21use tracing::{error, info, info_span, warn, Instrument};2223#[derive(Parser)]24pub enum Secret {25	/// Force load host keys for all defined hosts26	ForceKeys,27	/// Add secret, data should be provided in stdin28	AddShared {29		/// Secret name30		name: String,31		/// Secret owners32		#[clap(long, short)]33		machines: Vec<String>,34		/// Override secret if already present35		#[clap(long)]36		force: bool,37		/// Secret public part38		#[clap(long)]39		public: Option<String>,40		/// Load public part from specified file41		#[clap(long)]42		public_file: Option<PathBuf>,4344		/// Create a notification on secret expiration45		#[clap(long)]46		expires_at: Option<DateTime<Utc>>,4748		/// Secret with this name already exists, override its value while keeping the same owners.49		#[clap(long)]50		re_add: bool,5152		/// How to name public secret part53		#[clap(long, short = 'p', default_value = "public")]54		public_part: String,55		/// How to name private secret part56		#[clap(short = 's', long, default_value = "secret")]57		part: String,58	},59	/// Add secret, data should be provided in stdin60	Add {61		/// Secret name62		name: String,63		/// Secret owner64		#[clap(short = 'm', long)]65		machine: String,66		/// Replace secret if already present67		#[clap(long)]68		replace: bool,69		/// Add new parts to existing secret70		#[clap(long)]71		merge: bool,72		/// Secret public part73		#[clap(long)]74		public: Option<String>,75		/// Load public part from specified file76		#[clap(long)]77		public_file: Option<PathBuf>,7879		/// How to name public secret part80		#[clap(short = 'p', long, default_value = "public")]81		public_part: String,82		/// How to name private secret part83		#[clap(short = 's', long, default_value = "secret")]84		part: String,85	},86	/// Read secret from remote host, requires sudo on said host87	Read {88		name: String,89		#[clap(short = 'm', long)]90		machine: String,9192		/// Which private secret part to read93		#[clap(short = 'p', long, default_value = "secret")]94		part: String,95	},96	UpdateShared {97		name: String,9899		#[clap(short = 'm', long)]100		machine: Option<Vec<String>>,101102		#[clap(long)]103		add_machine: Vec<String>,104		#[clap(long)]105		remove_machine: Vec<String>,106107		/// Which host should we use to decrypt108		#[clap(long)]109		prefer_identities: Vec<String>,110	},111	Regenerate {112		/// Which host should we use to decrypt, in case if reencryption is required, without113		/// regeneration114		#[clap(long)]115		prefer_identities: Vec<String>,116	},117	List {},118	Edit {119		name: String,120		#[clap(short = 'm', long)]121		machine: String,122123		#[clap(long)]124		add: bool,125126		/// Which private secret part to read127		#[clap(short = 'p', long, default_value = "secret")]128		part: String,129	},130}131132#[tracing::instrument(skip(config, secret, field, prefer_identities))]133async fn update_owner_set(134	secret_name: &str,135	config: &Config,136	mut secret: FleetSharedSecret,137	field: Value,138	updated_set: &[String],139	prefer_identities: &[String],140) -> Result<FleetSharedSecret> {141	let original_set = secret.owners.clone();142143	let set = original_set.iter().collect::<BTreeSet<_>>();144	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();145146	if set == expected_set {147		info!("no need to update owner list, it is already correct");148		return Ok(secret);149	}150151	let should_regenerate = if set.difference(&expected_set).next().is_some() {152		// TODO: Remove this warning for revokable secrets.153		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");154		nix_go_json!(field.regenerateOnOwnerRemoved)155	} else if expected_set.difference(&set).next().is_some() {156		nix_go_json!(field.regenerateOnOwnerAdded)157	} else {158		false159	};160161	if should_regenerate {162		info!("secret is owner-dependent, will regenerate");163		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;164		Ok(generated)165	} else {166		let identity_holder = if !prefer_identities.is_empty() {167			prefer_identities168				.iter()169				.find(|i| original_set.iter().any(|s| s == *i))170		} else {171			secret.owners.first()172		};173		let Some(identity_holder) = identity_holder else {174			bail!("no available holder found");175		};176177		for (part_name, part) in secret.secret.parts.iter_mut() {178			let _span = info_span!("part reencryption", part_name);179			if !part.raw.encrypted {180				continue;181			}182			let host = config.host(identity_holder).await?;183			let encrypted = host184				.reencrypt(part.raw.clone(), updated_set.to_vec())185				.await?;186			part.raw = encrypted;187		}188189		secret.owners = updated_set.to_vec();190		Ok(secret)191	}192}193194#[derive(Deserialize)]195#[serde(rename_all = "camelCase")]196enum GeneratorKind {197	Impure,198	Pure,199}200201async fn generate_pure(202	_config: &Config,203	_display_name: &str,204	_secret: Value,205	_default_generator: Value,206	_owners: &[String],207) -> Result<FleetSecret> {208	bail!("pure generators are broken for now")209}210async fn generate_impure(211	config: &Config,212	_display_name: &str,213	secret: Value,214	default_generator: Value,215	owners: &[String],216) -> Result<FleetSecret> {217	let generator = nix_go!(secret.generator);218	let on: Option<String> = nix_go_json!(default_generator.impureOn);219220	let host = if let Some(on) = &on {221		config.host(on).await?222	} else {223		config.local_host()224	};225	let on_pkgs = host.pkgs().await?;226	let call_package = nix_go!(on_pkgs.callPackage);227	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);228229	let mut recipients = Vec::new();230	for owner in owners {231		let key = config.key(owner).await?;232		recipients.push(key);233	}234	let generators = nix_go!(mk_secret_generators(Obj {235		recipients: { recipients },236	}));237238	let generator = nix_go!(call_package(generator)(generators));239240	let generator = generator.build().await?;241	let generator = generator242		.get("out")243		.ok_or_else(|| anyhow!("missing generateImpure out"))?;244	let generator = host.remote_derivation(generator).await?;245246	let out_parent = host.mktemp_dir().await?;247	let out = format!("{out_parent}/out");248249	let mut gen = host.cmd(generator).await?;250	gen.env("out", &out);251	if on.is_none() {252		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.253		let project_path: String = config254			.directory255			.clone()256			.into_os_string()257			.into_string()258			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;259		gen.env("FLEET_PROJECT", project_path);260	}261	gen.run().await.context("impure generator")?;262263	{264		let marker = host.read_file_text(format!("{out}/marker")).await?;265		ensure!(marker == "SUCCESS", "generation not succeeded");266	}267268	let mut parts = BTreeMap::new();269	for part in host.read_dir(&out).await? {270		if part == "created_at" || part == "expired_at" || part == "marker" {271			continue;272		}273		let contents: SecretData = host274			.read_file_text(format!("{out}/{part}"))275			.await?276			.parse()277			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;278		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });279	}280281	let created_at = host.read_file_value(format!("{out}/created_at")).await?;282	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();283284	Ok(FleetSecret {285		created_at,286		expires_at,287		parts,288	})289}290async fn generate(291	config: &Config,292	display_name: &str,293	secret: Value,294	owners: &[String],295) -> Result<FleetSecret> {296	let generator = nix_go!(secret.generator);297	// Can't properly check on nix module system level298	{299		let gen_ty = generator.type_of().await?;300		if gen_ty == "null" {301			bail!("secret has no generator defined, can't automatically generate it.");302		}303		if gen_ty != "lambda" {304			bail!("generator should be lambda, got {gen_ty}");305		}306	}307	let default_pkgs = &config.default_pkgs;308	let default_call_package = nix_go!(default_pkgs.callPackage);309	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);310	// Generators provide additional information in passthru, to access311	// passthru we should call generator, but information about where this generator is supposed to build312	// is located in passthru... Thus evaluating generator on host.313	//314	// Maybe it is also possible to do some magic with __functor?315	//316	// I don't want to make modules always responsible for additional secret data anyway,317	// so it should be in derivation, and not in the secret data itself.318	let generators = nix_go!(default_mk_secret_generators(Obj {319		recipients: { <Vec<String>>::new() },320	}));321	let default_generator = nix_go!(default_call_package(generator)(generators));322323	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);324325	match kind {326		GeneratorKind::Impure => {327			generate_impure(config, display_name, secret, default_generator, owners).await328		}329		GeneratorKind::Pure => {330			generate_pure(config, display_name, secret, default_generator, owners).await331		}332	}333}334async fn generate_shared(335	config: &Config,336	display_name: &str,337	secret: Value,338	expected_owners: Vec<String>,339) -> Result<FleetSharedSecret> {340	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);341	Ok(FleetSharedSecret {342		secret: generate(config, display_name, secret, &expected_owners).await?,343		owners: expected_owners,344	})345}346347async fn parse_public(348	public: Option<String>,349	public_file: Option<PathBuf>,350) -> Result<Option<SecretData>> {351	Ok(match (public, public_file) {352		(Some(v), None) => Some(SecretData {353			data: v.into(),354			encrypted: false,355		}),356		(None, Some(v)) => Some(SecretData {357			data: read(v).await?,358			encrypted: false,359		}),360		(Some(_), Some(_)) => {361			bail!("only public or public_file should be set")362		}363		(None, None) => None,364	})365}366367async fn parse_secret() -> Result<Option<Vec<u8>>> {368	let mut input = vec![];369	stdin().read_to_end(&mut input)?;370	if input.is_empty() {371		Ok(None)372	} else {373		Ok(Some(input))374	}375}376377fn parse_machines(378	initial: Vec<String>,379	machines: Option<Vec<String>>,380	mut add_machines: Vec<String>,381	mut remove_machines: Vec<String>,382) -> Result<Vec<String>> {383	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {384		bail!("no operation");385	}386387	let initial_machines = initial.clone();388	let mut target_machines = initial;389	info!("Currently encrypted for {initial_machines:?}");390391	// ensure!(machines.is_some() || !add_machines.is_empty() || )392	if let Some(machines) = machines {393		ensure!(394			add_machines.is_empty() && remove_machines.is_empty(),395			"can't combine --machines and --add-machines/--remove-machines"396		);397		let target = initial_machines.iter().collect::<HashSet<_>>();398		let source = machines.iter().collect::<HashSet<_>>();399		for removed in target.difference(&source) {400			remove_machines.push((*removed).clone());401		}402		for added in source.difference(&target) {403			add_machines.push((*added).clone());404		}405	}406407	for machine in &remove_machines {408		let mut removed = false;409		while let Some(pos) = target_machines.iter().position(|m| m == machine) {410			target_machines.swap_remove(pos);411			removed = true;412		}413		if !removed {414			warn!("secret is not enabled for {machine}");415		}416	}417	for machine in &add_machines {418		if target_machines.iter().any(|m| m == machine) {419			warn!("secret is already added to {machine}");420		} else {421			target_machines.push(machine.to_owned());422		}423	}424	if !remove_machines.is_empty() {425		// TODO: maybe force secret regeneration?426		// Not that useful without revokation.427		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");428	}429	Ok(target_machines)430}431impl Secret {432	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {433		match self {434			Secret::ForceKeys => {435				for host in config.list_hosts().await? {436					if opts.should_skip(&host).await? {437						continue;438					}439					config.key(&host.name).await?;440				}441			}442			Secret::AddShared {443				mut machines,444				name,445				force,446				public,447				public_part: public_name,448				public_file,449				expires_at,450				re_add,451				part: part_name,452			} => {453				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).454455				let exists = config.has_shared(&name);456				if exists && !force && !re_add {457					bail!("secret already defined");458				}459				if re_add {460					// Fixme: use clap to limit this usage461					ensure!(!force, "--force and --readd are not compatible");462					ensure!(exists, "secret doesn't exists");463					ensure!(464						machines.is_empty(),465						"you can't use machines argument for --readd"466					);467					let shared = config.shared_secret(&name)?;468					machines = shared.owners;469				}470471				let recipients = config.recipients(machines.clone()).await?;472473				let mut parts = BTreeMap::new();474475				let mut input = vec![];476				io::stdin().read_to_end(&mut input)?;477478				if !input.is_empty() {479					let encrypted = encrypt_secret_data(recipients, input)480						.ok_or_else(|| anyhow!("no recipients provided"))?;481					parts.insert(part_name, FleetSecretPart { raw: encrypted });482				}483484				if let Some(public) = parse_public(public, public_file).await? {485					parts.insert(public_name, FleetSecretPart { raw: public });486				}487488				config.replace_shared(489					name,490					FleetSharedSecret {491						owners: machines,492						secret: FleetSecret {493							created_at: Utc::now(),494							expires_at,495							parts,496						},497					},498				);499			}500			Secret::Add {501				machine,502				name,503				replace,504				merge,505				public,506				public_part: public_name,507				public_file,508				part: part_name,509			} => {510				if config.has_secret(&machine, &name) && !replace && !merge {511					bail!("secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret");512				}513514				let mut out = if merge && !replace {515					config516						.host_secret(&machine, &name)517						.context("failed to read existing secret for --merge")?518				} else {519					FleetSecret {520						created_at: Utc::now(),521						expires_at: None,522						parts: BTreeMap::new(),523					}524				};525526				if let Some(secret) = parse_secret().await? {527					let recipient = config.recipient(&machine).await?;528					let encrypted =529						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");530					if out531						.parts532						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })533						.is_some() && !replace534					{535						bail!("part {part_name:?} is already defined");536					}537				}538539				if let Some(public) = parse_public(public, public_file).await? {540					if out541						.parts542						.insert(public_name.clone(), FleetSecretPart { raw: public })543						.is_some() && !replace544					{545						bail!("part {public_name:?} is already defined");546					}547				};548549				config.insert_secret(&machine, name, out);550			}551			#[allow(clippy::await_holding_refcell_ref)]552			Secret::Read {553				name,554				machine,555				part: part_name,556			} => {557				let secret = config.host_secret(&machine, &name)?;558				let Some(secret) = secret.parts.get(&part_name) else {559					bail!("no part {part_name} in secret {name}");560				};561				let data = if secret.raw.encrypted {562					let host = config.host(&machine).await?;563					host.decrypt(secret.raw.clone()).await?564				} else {565					secret.raw.data.clone()566				};567568				stdout().write_all(&data)?;569			}570			Secret::UpdateShared {571				name,572				machine,573				add_machine,574				remove_machine,575				prefer_identities,576			} => {577				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).578579				let secret = config.shared_secret(&name)?;580				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {581					bail!("no secret");582				}583584				let initial_machines = secret.owners.clone();585				let target_machines = parse_machines(586					initial_machines.clone(),587					machine,588					add_machine,589					remove_machine,590				)?;591592				if target_machines.is_empty() {593					info!("no machines left for secret, removing it");594					config.remove_shared(&name);595					return Ok(());596				}597598				let config_field = &config.config_field;599				let field = nix_go!(config_field.sharedSecrets[{ name }]);600601				let updated = update_owner_set(602					&name,603					config,604					secret,605					field,606					&target_machines,607					&prefer_identities,608				)609				.await?;610				config.replace_shared(name, updated);611			}612			Secret::Regenerate { prefer_identities } => {613				info!("checking for secrets to regenerate");614				{615					let _span = info_span!("shared").entered();616					let expected_shared_set = config617						.list_configured_shared()618						.await?619						.into_iter()620						.collect::<HashSet<_>>();621					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();622					for missing in expected_shared_set.difference(&shared_set) {623						let config_field = &config.config_field;624						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);625						let expected_owners: Option<Vec<String>> =626							nix_go_json!(secret.expectedOwners);627						let Some(expected_owners) = expected_owners else {628							// TODO: Might still need to regenerate629							continue;630						};631						info!("generating secret: {missing}");632						let shared = generate_shared(config, missing, secret, expected_owners)633							.in_current_span()634							.await?;635						config.replace_shared(missing.to_string(), shared)636					}637				}638				for host in config.list_hosts().await? {639					if opts.should_skip(&host).await? {640						continue;641					}642643					let _span = info_span!("host", host = host.name).entered();644					let expected_set = host645						.list_configured_secrets()646						.in_current_span()647						.await?648						.into_iter()649						.collect::<HashSet<_>>();650					let stored_set = config651						.list_secrets(&host.name)652						.into_iter()653						.collect::<HashSet<_>>();654					for missing in expected_set.difference(&stored_set) {655						info!("generating secret: {missing}");656						let secret = host.secret_field(missing).in_current_span().await?;657						let generated =658							match generate(config, missing, secret, &[host.name.clone()])659								.in_current_span()660								.await661							{662								Ok(v) => v,663								Err(e) => {664									error!("{e:?}");665									continue;666								}667							};668						config.insert_secret(&host.name, missing.to_string(), generated)669					}670				}671				let mut to_remove = Vec::new();672				for name in &config.list_shared() {673					info!("updating secret: {name}");674					let data = config.shared_secret(name)?;675					let config_field = &config.config_field;676					let expected_owners: Vec<String> =677						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);678					if expected_owners.is_empty() {679						warn!("secret was removed from fleet config: {name}, removing from data");680						to_remove.push(name.to_string());681						continue;682					}683684					let secret = nix_go!(config_field.sharedSecrets[{ name }]);685					config.replace_shared(686						name.to_owned(),687						update_owner_set(688							name,689							config,690							data,691							secret,692							&expected_owners,693							&prefer_identities,694						)695						.await?,696					);697				}698				for k in to_remove {699					config.remove_shared(&k);700				}701			}702			Secret::List {} => {703				let _span = info_span!("loading secrets").entered();704				let configured = config.list_configured_shared().await?;705				#[derive(Tabled)]706				struct SecretDisplay {707					#[tabled(rename = "Name")]708					name: String,709					#[tabled(rename = "Owners")]710					owners: String,711				}712				let mut table = vec![];713				for name in configured.iter().cloned() {714					let config = config.clone();715					let expected_owners = config.shared_secret_expected_owners(&name).await?;716					let data = config.shared_secret(&name)?;717					let owners = data718						.owners719						.iter()720						.map(|o| {721							if expected_owners.contains(o) {722								o.green().to_string()723							} else {724								o.red().to_string()725							}726						})727						.collect::<Vec<_>>();728					table.push(SecretDisplay {729						owners: owners.join(", "),730						name,731					})732				}733				info!("loaded\n{}", Table::new(table).to_string())734			}735			Secret::Edit {736				name,737				machine,738				part,739				add,740			} => {741				let secret = config.host_secret(&machine, &name)?;742				if let Some(data) = secret.parts.get(&part) {743					let host = config.host(&machine).await?;744					let secret = host.decrypt(data.raw.clone()).await?;745					String::from_utf8(secret).context("secret is not utf8")?746				} else if add {747					String::new()748				} else {749					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");750				};751			}752		}753		Ok(())754	}755}756757/*758async fn edit_temp_file(759	builder: tempfile::Builder<'_, '_>,760	r: Vec<u8>,761	header: &str,762	comment: &str,763) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {764	if !stdin().is_tty() {765		// TODO: Also try to open /dev/tty directly?766		bail!("stdin is not tty, can't open editor");767	}768769	use std::fmt::Write;770	let mut file = builder.tempfile()?;771772	let mut full_header = String::new();773	let mut had = false;774	for line in header.trim_end().lines() {775		had = true;776		writeln!(&mut full_header, "{comment}{line}")?;777	}778	if had {779		writeln!(&mut full_header, "{}", comment.trim_end())?;780	}781	writeln!(782		&mut full_header,783		"{comment}Do not touch this header! It will be removed automatically"784	)?;785786	file.write_all(full_header.as_bytes())?;787	file.write_all(&r)?;788789	let abs_path = file.into_temp_path();790	let editor = std::env::var_os("VISUAL")791		.or_else(|| std::env::var_os("EDITOR"))792		.unwrap_or_else(|| "vi".into());793	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())794		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;795	let editor_args = editor_args796		.into_iter()797		.map(|v| {798			// Only ASCII subsequences are replaced799			unsafe { OsString::from_encoded_bytes_unchecked(v) }800		})801		.collect_vec();802	let Some((editor, args)) = editor_args.split_first() else {803		bail!("EDITOR env var has no command");804	};805	let mut command = Command::new(editor);806	command.args(args);807808	let path_arg = abs_path.canonicalize()?;809810	// TODO: Save full state, using tcget/_getmode/_setmode811	let was_raw = terminal::is_raw_mode_enabled()?;812	terminal::enable_raw_mode()?;813814	let status = command.arg(path_arg).status().await;815816	if !was_raw {817		terminal::disable_raw_mode()?;818	}819820	let success = match status {821		Ok(s) => s.success(),822		Err(e) if e.kind() == io::ErrorKind::NotFound => {823			bail!("editor not found")824		}825		Err(e) => bail!("editor spawn error: {e}"),826	};827828	let mut file = std::fs::read(&abs_path).context("read editor output")?;829	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {830		todo!();831	};832	todo!();833834	// Ok((success, abs_path))835}836*/
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;