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
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -1,6 +1,5 @@
 use std::{
 	collections::{BTreeMap, BTreeSet, HashSet},
-	ffi::OsString,
 	io::{self, stdin, stdout, Read, Write},
 	path::PathBuf,
 };
@@ -8,21 +7,19 @@
 use anyhow::{anyhow, bail, ensure, Context, Result};
 use chrono::{DateTime, Utc};
 use clap::Parser;
-use crossterm::{terminal, tty::IsTty};
+use fleet_base::{
+	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},
+	host::Config,
+	opts::FleetOpts,
+};
 use fleet_shared::SecretData;
-use itertools::Itertools;
 use nix_eval::{nix_go, nix_go_json, Value};
 use owo_colors::OwoColorize;
 use serde::Deserialize;
 use tabled::{Table, Tabled};
-use tokio::{fs::read, process::Command};
+use tokio::fs::read;
 use tracing::{error, info, info_span, warn, Instrument};
 
-use crate::{
-	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},
-	host::Config,
-};
-
 #[derive(Parser)]
 pub enum Secret {
 	/// Force load host keys for all defined hosts
@@ -432,11 +429,11 @@
 	Ok(target_machines)
 }
 impl Secret {
-	pub async fn run(self, config: &Config) -> Result<()> {
+	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
 		match self {
 			Secret::ForceKeys => {
 				for host in config.list_hosts().await? {
-					if config.should_skip(&host).await? {
+					if opts.should_skip(&host).await? {
 						continue;
 					}
 					config.key(&host.name).await?;
@@ -639,7 +636,7 @@
 					}
 				}
 				for host in config.list_hosts().await? {
-					if config.should_skip(&host).await? {
+					if opts.should_skip(&host).await? {
 						continue;
 					}
 
@@ -757,6 +754,7 @@
 	}
 }
 
+/*
 async fn edit_temp_file(
 	builder: tempfile::Builder<'_, '_>,
 	r: Vec<u8>,
@@ -835,3 +833,4 @@
 
 	// Ok((success, abs_path))
 }
+*/
modifiedcmds/fleet/src/cmds/tf.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/tf.rs
+++ b/cmds/fleet/src/cmds/tf.rs
@@ -1,22 +1,67 @@
-use anyhow::Result;
+use std::{
+	collections::{BTreeMap, HashMap},
+	path::PathBuf,
+};
+
+use anyhow::{bail, Context, Result};
 use clap::Parser;
-use nix_eval::nix_go_json;
+use fleet_base::host::Config;
+use nix_eval::nix_go;
+use serde::Deserialize;
 use serde_json::Value;
-use tokio::fs::write;
-use tracing::info;
+use tokio::{fs::copy, process::Command};
 
-use crate::host::Config;
+#[derive(Deserialize)]
+pub struct TfData {
+	// Dummy
+	#[allow(dead_code)]
+	managed: bool,
+	// Host => Data
+	#[serde(default)]
+	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
+	pub hosts: BTreeMap<String, Value>,
+}
 
 #[derive(Parser)]
-pub struct Tf;
+pub enum Tf {
+	/// Generate fleet.tf.json file for running terraform.
+	Generate,
+	/// Fetch data from terraform to fleet.
+	Refresh,
+}
 impl Tf {
 	pub async fn run(&self, config: &Config) -> Result<()> {
-		let system = &config.local_system;
-		let config = &config.config_field;
-		let data: Value = nix_go_json!(config.tf({ system }).config);
-		let str = serde_json::to_string_pretty(&data)?;
+		match self {
+			Tf::Generate => {
+				let system = &config.local_system;
+				let config = &config.config_field;
+				let data: HashMap<String, PathBuf> = nix_go!(config.tf({ system })).build().await?;
+				let data = &data["out"];
+
+				copy(data, "fleet.tf.json").await?;
+			}
+			Tf::Refresh => {
+				let cmd = Command::new("terraform").arg("refresh").status().await?;
+				if !cmd.success() {
+					bail!("terraform refresh failed")
+				}
 
-		write("fleet.tf.json", str.as_bytes()).await?;
+				let data = Command::new("terraform")
+					.arg("output")
+					.arg("-json")
+					.arg("fleet")
+					.output()
+					.await?;
+				let tf_data: TfData = serde_json::from_slice(&data.stdout)
+					.context("failed to parse terraform fleet output")?;
+
+				let mut data = config.data();
+				data.extra.insert(
+					"terraformHosts".to_owned(),
+					serde_json::to_value(tf_data.hosts).expect("should be valid extra"),
+				);
+			}
+		}
 
 		Ok(())
 	}
deletedcmds/fleet/src/command.rsdiffbeforeafterboth
--- a/cmds/fleet/src/command.rs
+++ /dev/null
@@ -1,430 +0,0 @@
-use std::{ffi::OsStr, pin, process::Stdio, sync::Arc, task::Poll};
-
-use anyhow::{anyhow, Result};
-use better_command::{Handler, NixHandler, PlainHandler};
-use futures::StreamExt;
-use itertools::Either;
-use openssh::{OverSsh, OwningCommand, Session};
-use tokio::{io::AsyncRead, process::Command, select};
-use tokio_util::codec::{BytesCodec, FramedRead, LinesCodec};
-use tracing::debug;
-
-use crate::host::EscalationStrategy;
-
-fn escape_bash(input: &str, out: &mut String) {
-	const TO_ESCAPE: &str = "$ !\"#&'()*,;<>?[\\]^`{|}";
-	if input.chars().all(|c| !TO_ESCAPE.contains(c)) {
-		out.push_str(input);
-		return;
-	}
-	out.push('\'');
-	for (i, v) in input.split('\'').enumerate() {
-		if i != 0 {
-			out.push_str("'\"'\"'");
-		}
-		out.push_str(v);
-	}
-	out.push('\'');
-}
-fn ostoutf8(os: impl AsRef<OsStr>) -> String {
-	os.as_ref().to_str().expect("non-utf8 data").to_owned()
-}
-
-#[derive(Clone, Debug)]
-pub struct MyCommand {
-	command: String,
-	args: Vec<String>,
-	env: Vec<(String, String)>,
-	ssh_session: Option<Arc<Session>>,
-	escalation: EscalationStrategy,
-	escalate: bool,
-}
-impl MyCommand {
-	pub fn new_on(
-		escalation: EscalationStrategy,
-		cmd: impl AsRef<OsStr>,
-		session: Arc<Session>,
-	) -> Self {
-		assert!(!cmd.as_ref().is_empty());
-		Self {
-			command: ostoutf8(cmd),
-			args: vec![],
-			env: vec![],
-			ssh_session: Some(session),
-			escalation,
-			escalate: false,
-		}
-	}
-	pub fn new(escalation: EscalationStrategy, cmd: impl AsRef<OsStr>) -> Self {
-		assert!(!cmd.as_ref().is_empty());
-		Self {
-			command: ostoutf8(cmd),
-			args: vec![],
-			env: vec![],
-			ssh_session: None,
-			escalation,
-			escalate: false,
-		}
-	}
-	fn new_here(&self, cmd: impl AsRef<OsStr>) -> Self {
-		if let Some(ssh_session) = self.ssh_session.clone() {
-			Self::new_on(self.escalation, cmd, ssh_session)
-		} else {
-			Self::new(self.escalation, cmd)
-		}
-	}
-
-	fn into_args(self) -> Vec<String> {
-		let mut out = Vec::new();
-		if !self.env.is_empty() {
-			out.push("env".to_owned());
-			for (k, v) in self.env {
-				assert!(!k.contains('='));
-				out.push(format!("{k}={v}"));
-			}
-		}
-		out.push(self.command);
-		out.extend(self.args);
-		out
-	}
-
-	/// Translates environment variables into env command execution.
-	/// Required for ssh, as ssh don't allow to send environment variables (at least by default).
-	///
-	/// FIXME: Insecure, as arguments might be seen by other users on the same machine.
-	/// Figure out some way to transfer environment using stdio?
-	fn translate_env_into_env(self) -> Self {
-		if self.env.is_empty() {
-			return self;
-		}
-		let mut out = self.new_here("env");
-		for (k, v) in self.env {
-			assert!(!k.contains('='));
-			out.arg(format!("{k}={v}"));
-		}
-		out.arg(self.command);
-		out.args(self.args);
-
-		out
-	}
-	fn into_string(self) -> String {
-		let mut out = String::new();
-		if !self.env.is_empty() {
-			out.push_str("env");
-			for (k, v) in self.env {
-				out.push(' ');
-				assert!(!k.contains('='));
-				escape_bash(&k, &mut out);
-				out.push('=');
-				escape_bash(&v, &mut out);
-			}
-		}
-		if !out.is_empty() {
-			out.push(' ');
-		}
-		escape_bash(&self.command, &mut out);
-		for arg in self.args {
-			out.push(' ');
-			escape_bash(&arg, &mut out);
-		}
-		out
-	}
-	fn into_command(self) -> Command {
-		let mut out = Command::new(self.command);
-		out.args(self.args);
-		for (k, v) in self.env {
-			out.env(k, v);
-		}
-		out
-	}
-	fn into_command_new(self) -> Result<Either<Command, openssh::OwningCommand<Arc<Session>>>> {
-		Ok(if let Some(session) = self.ssh_session.clone() {
-			let cmd = self.translate_env_into_env().into_command();
-			Either::Right(
-				cmd.over_ssh(session)
-					.map_err(|e| anyhow!("ssh error: {e}"))?,
-			)
-		} else {
-			let cmd = self.into_command();
-			Either::Left(cmd)
-		})
-	}
-	pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
-		let arg = arg.as_ref();
-		self.args.push(ostoutf8(arg));
-		self
-	}
-	pub fn eqarg(&mut self, arg: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
-		let arg = arg.as_ref();
-		let value = value.as_ref();
-		let arg = ostoutf8(arg);
-		let value = ostoutf8(value);
-		self.arg(format!("{arg}={value}"));
-		self
-	}
-	pub fn comparg(&mut self, arg: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
-		self.arg(arg);
-		self.arg(value);
-		self
-	}
-	pub fn env(&mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> &mut Self {
-		self.env
-			.push((name.as_ref().to_owned(), value.as_ref().to_owned()));
-		self
-	}
-	pub fn args<V: AsRef<OsStr>>(&mut self, args: impl IntoIterator<Item = V>) -> &mut Self {
-		for arg in args.into_iter() {
-			let arg = arg.as_ref();
-			self.args.push(ostoutf8(arg));
-		}
-		self
-	}
-	pub fn sudo(mut self) -> Self {
-		self.escalate = true;
-		self
-	}
-	fn wrap_sudo_if_needed(self) -> Self {
-		if !self.escalate {
-			return self;
-		}
-		match self.escalation {
-			EscalationStrategy::Su => {
-				let mut out = self.new_here("su");
-				out.arg("-c").arg(self.into_string());
-				out
-			}
-			EscalationStrategy::Sudo => {
-				let mut out = self.new_here("sudo");
-				out.args(self.into_args());
-				out
-			}
-			EscalationStrategy::Run0 => {
-				// run0 wants interactive authentication by default.
-				let mut run0 = self.new_here("run0");
-				let mut out = self.new_here("script");
-
-				// Red backgrounds messes with fleet formatting
-				run0.arg("--background=");
-				run0.args(self.into_args());
-
-				out.arg("-q");
-				out.arg("/dev/null");
-				out.arg("-c");
-				out.arg(run0.into_string());
-				dbg!(&out);
-				out
-			}
-		}
-	}
-
-	pub async fn run(self) -> Result<()> {
-		let str = self.clone().into_string();
-		let cmd = self.wrap_sudo_if_needed().into_command_new()?;
-		match cmd {
-			Either::Left(cmd) => run_nix_inner(str, cmd, &mut PlainHandler).await?,
-			Either::Right(cmd) => run_nix_inner_ssh(str, cmd, &mut PlainHandler).await?,
-		};
-		Ok(())
-	}
-	pub async fn run_string(self) -> Result<String> {
-		let bytes = self.run_bytes().await?;
-		Ok(String::from_utf8(bytes)?)
-	}
-	pub async fn run_bytes(self) -> Result<Vec<u8>> {
-		let str = self.clone().into_string();
-		let cmd = self.wrap_sudo_if_needed().into_command_new()?;
-		let v = match cmd {
-			Either::Left(cmd) => run_nix_inner_stdout(str, cmd, &mut PlainHandler).await?,
-			Either::Right(cmd) => run_nix_inner_stdout_ssh(str, cmd, &mut PlainHandler).await?,
-		};
-		Ok(v)
-	}
-
-	pub async fn run_nix_string(mut self) -> Result<String> {
-		let str = self.clone().into_string();
-		self.arg("--log-format").arg("internal-json");
-		let mut cmd = self.wrap_sudo_if_needed().into_command();
-		let bytes = run_nix_inner_stdout(str, cmd, &mut NixHandler::default()).await?;
-		Ok(String::from_utf8(bytes)?)
-	}
-	pub async fn run_nix(mut self) -> Result<()> {
-		let str = self.clone().into_string();
-		self.arg("--log-format").arg("internal-json");
-		let mut cmd = self.wrap_sudo_if_needed().into_command();
-		cmd.stdout(Stdio::inherit());
-		run_nix_inner(str, cmd, &mut NixHandler::default()).await
-	}
-}
-
-struct EmptyAsyncRead;
-impl AsyncRead for EmptyAsyncRead {
-	fn poll_read(
-		self: std::pin::Pin<&mut Self>,
-		_cx: &mut std::task::Context<'_>,
-		_buf: &mut tokio::io::ReadBuf<'_>,
-	) -> Poll<std::io::Result<()>> {
-		Poll::Pending
-	}
-}
-
-async fn run_nix_inner_stdout(
-	str: String,
-	cmd: Command,
-	handler: &mut dyn Handler,
-) -> Result<Vec<u8>> {
-	Ok(run_nix_inner_raw(str, cmd, true, handler, None)
-		.await?
-		.expect("has out"))
-}
-async fn run_nix_inner(str: String, cmd: Command, handler: &mut dyn Handler) -> Result<()> {
-	let v = run_nix_inner_raw(str, cmd, false, handler, None).await?;
-	assert!(v.is_none());
-	Ok(())
-}
-async fn run_nix_inner_stdout_ssh(
-	str: String,
-	cmd: OwningCommand<Arc<Session>>,
-	handler: &mut dyn Handler,
-) -> Result<Vec<u8>> {
-	Ok(run_nix_inner_raw_ssh(str, cmd, true, handler, None)
-		.await?
-		.expect("has out"))
-}
-async fn run_nix_inner_ssh(
-	str: String,
-	cmd: OwningCommand<Arc<Session>>,
-	handler: &mut dyn Handler,
-) -> Result<()> {
-	let v = run_nix_inner_raw_ssh(str, cmd, false, handler, None).await?;
-	assert!(v.is_none());
-	Ok(())
-}
-
-async fn run_nix_inner_raw(
-	str: String,
-	mut cmd: Command,
-	want_stdout: bool,
-	err_handler: &mut dyn Handler,
-	mut out_handler: Option<&mut dyn Handler>,
-) -> Result<Option<Vec<u8>>> {
-	cmd.stderr(Stdio::piped());
-	cmd.stdout(Stdio::piped());
-	debug!("running command {str:?} on local");
-	let mut child = cmd.spawn()?;
-	let mut stderr = child.stderr.take().unwrap();
-	let stdout = child.stdout.take().unwrap();
-	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
-	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
-	let mut ob = want_stdout
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ol = (!want_stdout)
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ob = FramedRead::new(&mut ob, BytesCodec::new());
-	let mut ol = FramedRead::new(&mut ol, LinesCodec::new());
-
-	// while let Some(line) = read.next().await? {}
-
-	let mut out_buf = if want_stdout { Some(vec![]) } else { None };
-	loop {
-		select! {
-			e = err.next() => {
-				if let Some(e) = e {
-					let e = e?;
-					err_handler.handle_line(&e);
-				}
-			},
-			o = ob.next() => {
-				if let Some(o) = o {
-					out_buf.as_mut().expect("stdout == wants_stdout").extend_from_slice(&o?);
-				}
-			},
-			o = ol.next() => {
-				if let Some(o) = o {
-					let o = o?;
-					if let Some(out) = out_handler.as_mut() {
-						out.handle_line(&o)
-					} else {
-						err_handler.handle_line(&o)
-					}
-					// out_handler.handle_info(&o);
-				}
-			},
-			code = child.wait() => {
-				let code = code?;
-				if !code.success() {
-					anyhow::bail!("command '{str}' failed with status {}", code);
-				}
-				break;
-			}
-		}
-	}
-
-	Ok(out_buf)
-}
-async fn run_nix_inner_raw_ssh(
-	str: String,
-	mut cmd: OwningCommand<Arc<Session>>,
-	want_stdout: bool,
-	err_handler: &mut dyn Handler,
-	mut out_handler: Option<&mut dyn Handler>,
-) -> Result<Option<Vec<u8>>> {
-	debug!("running command {str:?} over ssh");
-	cmd.stderr(openssh::Stdio::piped());
-	cmd.stdout(openssh::Stdio::piped());
-	let mut child = cmd.spawn().await?;
-	let mut stderr = child.stderr().take().unwrap();
-	let stdout = child.stdout().take().unwrap();
-	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
-	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
-	let mut ob = want_stdout
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ol = (!want_stdout)
-		.then(|| out.take().unwrap())
-		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
-	let mut ob = FramedRead::new(&mut ob, BytesCodec::new());
-	let mut ol = FramedRead::new(&mut ol, LinesCodec::new());
-
-	// while let Some(line) = read.next().await? {}
-
-	let mut out_buf = if want_stdout { Some(vec![]) } else { None };
-
-	let mut wait_future = pin::pin!(child.wait());
-	loop {
-		select! {
-			e = err.next() => {
-				if let Some(e) = e {
-					let e = e?;
-					err_handler.handle_line(&e);
-				}
-			},
-			o = ob.next() => {
-				if let Some(o) = o {
-					out_buf.as_mut().expect("stdout == wants_stdout").extend_from_slice(&o?);
-				}
-			},
-			o = ol.next() => {
-				if let Some(o) = o {
-					let o = o?;
-					if let Some(out) = out_handler.as_mut() {
-						out.handle_line(&o)
-					} else {
-						err_handler.handle_line(&o)
-					}
-					// out_handler.handle_info(&o);
-				}
-			},
-			code = &mut wait_future => {
-				let code = code?;
-				if !code.success() {
-					anyhow::bail!("command '{str}' failed with status {}", code);
-				}
-				break;
-			}
-		}
-	}
-
-	Ok(out_buf)
-}
deletedcmds/fleet/src/fleetdata.rsdiffbeforeafterboth
--- a/cmds/fleet/src/fleetdata.rs
+++ /dev/null
@@ -1,107 +0,0 @@
-use std::{
-	collections::BTreeMap,
-	io::{self, Cursor},
-};
-
-use age::Recipient;
-use chrono::{DateTime, Utc};
-use fleet_shared::SecretData;
-use itertools::Itertools;
-use serde::{de::Error, Deserialize, Serialize};
-
-#[derive(Serialize, Deserialize, Default)]
-#[serde(rename_all = "camelCase")]
-pub struct HostData {
-	#[serde(default)]
-	#[serde(skip_serializing_if = "String::is_empty")]
-	pub encryption_key: String,
-}
-
-const VERSION: &str = "0.1.0";
-pub struct FleetDataVersion;
-impl Serialize for FleetDataVersion {
-	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-	where
-		S: serde::Serializer,
-	{
-		VERSION.serialize(serializer)
-	}
-}
-impl<'de> Deserialize<'de> for FleetDataVersion {
-	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-	where
-		D: serde::Deserializer<'de>,
-	{
-		let version = String::deserialize(deserializer)?;
-		if version != VERSION {
-			return Err(D::Error::custom(format!(
-				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"
-			)));
-		}
-		Ok(Self)
-	}
-}
-
-#[derive(Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct FleetData {
-	pub version: FleetDataVersion,
-
-	#[serde(default)]
-	pub hosts: BTreeMap<String, HostData>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub shared_secrets: BTreeMap<String, FleetSharedSecret>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub host_secrets: BTreeMap<String, BTreeMap<String, FleetSecret>>,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-#[serde(rename_all = "camelCase")]
-#[must_use]
-pub struct FleetSharedSecret {
-	pub owners: Vec<String>,
-	#[serde(flatten)]
-	pub secret: FleetSecret,
-}
-
-/// Returns None if recipients.is_empty()
-pub fn encrypt_secret_data(
-	recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
-	data: Vec<u8>,
-) -> Option<SecretData> {
-	let mut encrypted = vec![];
-	let recipients = recipients
-		.into_iter()
-		.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
-		.collect_vec();
-	let mut encryptor = age::Encryptor::with_recipients(recipients)?
-		.wrap_output(&mut encrypted)
-		.expect("in memory write");
-	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
-	encryptor.finish().expect("in memory flush");
-	Some(SecretData {
-		data: encrypted,
-		encrypted: true,
-	})
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-pub struct FleetSecretPart {
-	pub raw: SecretData,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-#[serde(rename_all = "camelCase")]
-#[must_use]
-pub struct FleetSecret {
-	#[serde(default = "Utc::now")]
-	pub created_at: DateTime<Utc>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]
-	pub expires_at: Option<DateTime<Utc>>,
-
-	#[serde(flatten)]
-	pub parts: BTreeMap<String, FleetSecretPart>,
-}
deletedcmds/fleet/src/host.rsdiffbeforeafterboth
before · cmds/fleet/src/host.rs
1use std::{2	cell::{LazyCell, OnceCell},3	collections::BTreeMap,4	env::current_dir,5	ffi::{OsStr, OsString},6	fmt::Display,7	io::Write,8	ops::Deref,9	path::PathBuf,10	str::FromStr,11	sync::{Arc, Mutex, MutexGuard, OnceLock},12};1314use anyhow::{anyhow, bail, ensure, Context, Result};15use clap::Parser;16use fleet_shared::SecretData;17use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSessionPool, Value};18use nom::{19	bytes::complete::take_while1,20	character::complete::char,21	combinator::{map, opt},22	multi::separated_list1,23	sequence::{preceded, separated_pair},24};25use openssh::SessionBuilder;26use serde::de::DeserializeOwned;27use tempfile::NamedTempFile;28use tracing::error;2930use crate::{31	command::MyCommand,32	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},33};3435pub struct FleetConfigInternals {36	pub local_system: String,37	pub directory: PathBuf,38	pub opts: FleetOpts,39	pub data: Mutex<FleetData>,40	pub nix_args: Vec<OsString>,41	/// fleet_config.config42	pub config_field: Value,4344	/// import nixpkgs {system = local};45	pub default_pkgs: Value,46}4748#[derive(Clone)]49pub struct Config(Arc<FleetConfigInternals>);5051impl Deref for Config {52	type Target = FleetConfigInternals;5354	fn deref(&self) -> &Self::Target {55		&self.056	}57}5859#[derive(Clone, Copy, Debug)]60pub enum EscalationStrategy {61	Sudo,62	Run0,63	Su,64}6566pub struct ConfigHost {67	config: Config,68	pub name: String,69	pub local: bool,70	pub session: OnceLock<Arc<openssh::Session>>,71	groups: OnceCell<Vec<String>>,7273	pub host_config: Option<Value>,74	pub nixos_config: OnceCell<Value>,75}76impl ConfigHost {77	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {78		// Prefer sudo, as run0 has some gotchas with polkit79		// and too many repeating prompts.80		if let Ok(_) = self.find_in_path("sudo").await {81			return Ok(EscalationStrategy::Sudo);82		}83		if let Ok(_) = self.find_in_path("run0").await {84			return Ok(EscalationStrategy::Run0);85		}86		Ok(EscalationStrategy::Su)87	}88	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,89	// assuming getting tags always returns the same value.90	pub async fn tags(&self) -> Result<Vec<String>> {91		if let Some(v) = self.groups.get() {92			return Ok(v.clone());93		}94		let Some(host_config) = &self.host_config else {95			return Ok(vec![]);96		};97		let tags: Vec<String> = nix_go_json!(host_config.tags);9899		let _ = self.groups.set(tags.clone());100101		Ok(tags)102	}103	pub async fn nixos_config(&self) -> Result<Value> {104		if let Some(v) = self.nixos_config.get() {105			return Ok(v.clone());106		}107		let Some(host_config) = &self.host_config else {108			bail!("local host has no nixos_config");109		};110		let nixos_config = nix_go!(host_config.nixos.config);111		assert_warn("nixos config evaluation", &nixos_config).await?;112113		let _ = self.nixos_config.set(nixos_config.clone());114115		Ok(nixos_config)116	}117	async fn open_session(&self) -> Result<Arc<openssh::Session>> {118		assert!(!self.local, "do not open ssh connection to local session");119		// FIXME: TOCTOU120		if let Some(session) = &self.session.get() {121			return Ok((*session).clone());122		};123		let mut session = SessionBuilder::default();124		let session = session125			.connect(&self.name)126			.await127			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;128		let session = Arc::new(session);129		self.session.set(session.clone()).expect("TOCTOU happened");130		Ok(session)131	}132	pub async fn mktemp_dir(&self) -> Result<String> {133		let mut cmd = self.cmd("mktemp").await?;134		cmd.arg("-d");135		let path = cmd.run_string().await?;136		Ok(path.trim_end().to_owned())137	}138	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {139		let mut cmd = self.cmd("cat").await?;140		cmd.arg(path);141		cmd.run_bytes().await142	}143	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {144		let mut cmd = self.cmd("cat").await?;145		cmd.arg(path);146		cmd.run_string().await147	}148	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {149		let mut cmd = self.cmd("ls").await?;150		cmd.arg(path);151		let out = cmd.run_string().await?;152		let mut lines = out.split('\n');153		if let Some(last) = lines.next_back() {154			ensure!(last.is_empty(), "output of ls should end with newline");155		}156		Ok(lines.map(ToOwned::to_owned).collect())157	}158	#[allow(dead_code)]159	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {160		let text = self.read_file_text(path).await?;161		Ok(serde_json::from_str(&text)?)162	}163	pub async fn read_env(&self, env: &str) -> Result<String> {164		let mut cmd = self.cmd("printenv").await?;165		cmd.arg(env);166		Ok(cmd.run_string().await?)167	}168	pub async fn find_in_path(&self, command: &str) -> Result<String> {169		// // `which` is not a part of coreutils, and it might not exist on machine.170		// let path = self.read_env("PATH").await?;171		// // Assuming delimiter is :, we don't work with windows host, this check will be much172		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)173		// for ele in path.split(':') {174		// 	let test_path = format!("{ele}/{cmd}");175		// 	test -x etc176		// }177		// let mut cmd = self.cmd("printenv").await?;178		// cmd.arg(env);179		// Ok(cmd.run_string().await?)180		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.181		let mut cmd = self182			.cmd_escalation(183				// Not used184				EscalationStrategy::Su,185				"which",186			)187			.await?;188		cmd.arg(command);189		cmd.run_string().await190	}191	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>192	where193		<D as FromStr>::Err: Display,194	{195		let text = self.read_file_text(path).await?;196		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))197	}198	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {199		self.cmd_escalation(self.escalation_strategy().await?, cmd)200			.await201	}202	pub async fn cmd_escalation(203		&self,204		escalation: EscalationStrategy,205		cmd: impl AsRef<OsStr>,206	) -> Result<MyCommand> {207		if self.local {208			Ok(MyCommand::new(escalation, cmd))209		} else {210			let session = self.open_session().await?;211			Ok(MyCommand::new_on(escalation, cmd, session))212		}213	}214215	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {216		ensure!(data.encrypted, "secret is not encrypted");217		let mut cmd = self.cmd("fleet-install-secrets").await?;218		cmd.arg("decrypt").eqarg("--secret", data.to_string());219		let encoded = cmd220			.sudo()221			.run_string()222			.await223			.context("failed to call remote host for decrypt")?;224		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;225		ensure!(!data.encrypted, "secret came out encrypted");226		Ok(data.data)227	}228	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {229		ensure!(data.encrypted, "secret is not encrypted");230		let mut cmd = self.cmd("fleet-install-secrets").await?;231		cmd.arg("reencrypt").eqarg("--secret", data.to_string());232		for target in targets {233			let key = self.config.key(&target).await?;234			cmd.eqarg("--targets", key);235		}236		let encoded = cmd237			.sudo()238			.run_string()239			.await240			.context("failed to call remote host for decrypt")?;241		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;242		ensure!(data.encrypted, "secret came out not encrypted");243		Ok(data)244	}245	/// Returns path for futureproofing, as path might change i.e on conversion to CA246	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {247		if self.local {248			// Path is located locally, thus already trusted.249			return Ok(path.to_owned());250		}251		let mut nix = MyCommand::new(252			// Not used253			EscalationStrategy::Su,254			"nix",255		);256		nix.arg("copy")257			.arg("--substitute-on-destination")258			.comparg("--to", format!("ssh-ng://{}", self.name))259			.arg(path);260		nix.run_nix().await.context("nix copy")?;261		Ok(path.to_owned())262	}263	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {264		let mut cmd = self.cmd("systemctl").await?;265		cmd.arg("stop").arg(name);266		cmd.sudo().run().await267	}268	pub async fn systemctl_start(&self, name: &str) -> Result<()> {269		let mut cmd = self.cmd("systemctl").await?;270		cmd.arg("start").arg(name);271		cmd.sudo().run().await272	}273274	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {275		let mut cmd = self.cmd("rm").await?;276		cmd.arg("-f").arg(path);277		if sudo {278			cmd = cmd.sudo()279		}280		cmd.run().await281	}282283	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {284		let nixos = self.nixos_config().await?;285		let secrets = nix_go!(nixos.secrets);286		let mut out = Vec::new();287		for name in secrets.list_fields().await? {288			let secret = nix_go!(secrets[{ name }]);289			let is_shared: bool = nix_go_json!(secret.shared);290			if is_shared {291				continue;292			}293			out.push(name);294		}295		Ok(out)296	}297	pub async fn secret_field(&self, name: &str) -> Result<Value> {298		let nixos = self.nixos_config().await?;299		Ok(nix_go!(nixos.secrets[{ name }]))300	}301302	/// Packages for this host, resolved with nixpkgs overlays303	pub async fn pkgs(&self) -> Result<Value> {304		let Some(host_config) = &self.host_config else {305			bail!("local host has no host_config");306		};307		// TODO: Should nixos.options be cached?308		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))309	}310}311312impl Config {313	pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {314		if !self.opts.skip.is_empty() && self.opts.skip.iter().any(|h| h as &str == host.name) {315			return Ok(true);316		}317		if self.opts.only.is_empty() {318			return Ok(false);319		}320		let mut have_group_matches = false;321		for item in self.opts.only.iter() {322			match item {323				HostItem::Host { name, .. } if *name == host.name => {324					return Ok(false);325				}326				HostItem::Tag { .. } => {327					have_group_matches = true;328				}329				_ => {}330			}331		}332		if have_group_matches {333			let host_tags = host.tags().await?;334			for item in self.opts.only.iter() {335				match item {336					HostItem::Tag { name, .. } if host_tags.contains(name) => {337						return Ok(false);338					}339					_ => {}340				}341			}342		}343		Ok(true)344	}345	pub async fn action_attr(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {346		if self.opts.only.is_empty() {347			return Ok(None);348		}349		let mut have_group_matches = false;350		for item in self.opts.only.iter() {351			match item {352				HostItem::Host { name, attrs }353					if *name == host.name && attrs.contains_key(attr) =>354				{355					return Ok(attrs.get(attr).cloned());356				}357				HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {358					have_group_matches = true;359				}360				_ => {}361			}362		}363		if have_group_matches {364			let host_tags = host.tags().await?;365			for item in self.opts.only.iter() {366				match item {367					HostItem::Tag { name, attrs }368						if host_tags.contains(name) && attrs.contains_key(attr) =>369					{370						return Ok(attrs.get(attr).cloned());371					}372					_ => {}373				}374			}375		}376		Ok(None)377	}378	pub fn is_local(&self, host: &str) -> bool {379		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)380	}381382	pub fn local_host(&self) -> ConfigHost {383		ConfigHost {384			config: self.clone(),385			name: "<virtual localhost>".to_owned(),386			local: true,387			session: OnceLock::new(),388			host_config: None,389			nixos_config: OnceCell::new(),390			groups: {391				let cell = OnceCell::new();392				let _ = cell.set(vec![]);393				cell394			},395		}396	}397398	pub async fn host(&self, name: &str) -> Result<ConfigHost> {399		let config = &self.config_field;400		let host_config = nix_go!(config.hosts[{ name }]);401402		Ok(ConfigHost {403			config: self.clone(),404			name: name.to_owned(),405			local: self.is_local(name),406			session: OnceLock::new(),407			host_config: Some(host_config),408			nixos_config: OnceCell::new(),409			groups: OnceCell::new(),410		})411	}412	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {413		let config = &self.config_field;414		let names = nix_go!(config.hosts).list_fields().await?;415		let mut out = vec![];416		for name in names {417			out.push(self.host(&name).await?);418		}419		Ok(out)420	}421	pub async fn system_config(&self, host: &str) -> Result<Value> {422		let fleet_field = &self.config_field;423		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))424	}425426	pub(super) fn data(&self) -> MutexGuard<FleetData> {427		self.data.lock().unwrap()428	}429	pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {430		self.data.lock().unwrap()431	}432	/// Shared secrets configured in fleet.nix or in flake433	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {434		let config_field = &self.config_field;435		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)436	}437	/// Shared secrets configured in fleet.nix438	pub fn list_shared(&self) -> Vec<String> {439		let data = self.data();440		data.shared_secrets.keys().cloned().collect()441	}442	pub fn has_shared(&self, name: &str) -> bool {443		let data = self.data();444		data.shared_secrets.contains_key(name)445	}446	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {447		let mut data = self.data_mut();448		data.shared_secrets.insert(name.to_owned(), shared);449	}450	pub fn remove_shared(&self, secret: &str) {451		let mut data = self.data_mut();452		data.shared_secrets.remove(secret);453	}454455	pub fn list_secrets(&self, host: &str) -> Vec<String> {456		let data = self.data();457		let Some(secrets) = data.host_secrets.get(host) else {458			return Vec::new();459		};460		secrets.keys().cloned().collect()461	}462463	pub fn has_secret(&self, host: &str, secret: &str) -> bool {464		let data = self.data();465		let Some(host_secrets) = data.host_secrets.get(host) else {466			return false;467		};468		host_secrets.contains_key(secret)469	}470	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {471		let mut data = self.data_mut();472		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();473		host_secrets.insert(secret, value);474	}475476	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {477		let data = self.data();478		let Some(host_secrets) = data.host_secrets.get(host) else {479			bail!("no secrets for machine {host}");480		};481		let Some(secret) = host_secrets.get(secret) else {482			bail!("machine {host} has no secret {secret}");483		};484		Ok(secret.clone())485	}486	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {487		let data = self.data();488		let Some(secret) = data.shared_secrets.get(secret) else {489			bail!("no shared secret {secret}");490		};491		Ok(secret.clone())492	}493	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {494		let config_field = &self.config_field;495		Ok(nix_go_json!(496			config_field.sharedSecrets[{ secret }].expectedOwners497		))498	}499500	pub fn save(&self) -> Result<()> {501		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.")?;502		let data = nixlike::serialize(&self.data() as &FleetData)?;503		tempfile.write_all(504			format!(505				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",506				data507			)508			.as_bytes(),509		)?;510		let mut fleet_data_path = self.directory.clone();511		fleet_data_path.push("fleet.nix");512		tempfile.persist(fleet_data_path)?;513		Ok(())514	}515}516517#[derive(Clone)]518enum HostItem {519	Host {520		name: String,521		attrs: BTreeMap<String, String>,522	},523	Tag {524		name: String,525		attrs: BTreeMap<String, String>,526	},527}528fn host_item_parser(input: &str) -> Result<HostItem, String> {529	fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {530		err.to_string()531	}532533	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;534	let (input, name) = map(535		take_while1(|v| v != ',' && v != '?' && v != '@'),536		str::to_owned,537	)(input)538	.map_err(err_to_string)?;539540	let kw_item = separated_pair(541		map(take_while1(|v| v != '&' && v != '='), str::to_owned),542		char('='),543		map(take_while1(|v| v != '&'), str::to_owned),544	);545	let kw = map(separated_list1(char('&'), kw_item), |vec| {546		vec.into_iter().collect::<BTreeMap<_, _>>()547	});548	let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);549550	let (input, attrs) = opt_kw(input).map_err(err_to_string)?;551552	if !input.is_empty() {553		return Err(format!("unexpected trailing input: {input:?}"));554	}555	Ok(if is_tag {556		HostItem::Tag { name, attrs }557	} else {558		HostItem::Host { name, attrs }559	})560}561562#[derive(Parser, Clone)]563pub struct FleetOpts {564	/// All hosts except those would be skipped565	#[clap(long, number_of_values = 1, value_parser = host_item_parser)]566	only: Vec<HostItem>,567568	/// Hosts to skip569	#[clap(long, number_of_values = 1)]570	skip: Vec<String>,571572	/// Host, which should be threaten as current machine573	#[clap(long)]574	pub localhost: Option<String>,575576	/// Override detected system for host, to perform builds via577	/// binfmt-declared qemu instead of trying to crosscompile578	#[clap(long, default_value = "detect")]579	pub local_system: String,580}581582impl FleetOpts {583	pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {584		if self.localhost.is_none() {585			self.localhost586				.replace(hostname::get().unwrap().to_str().unwrap().to_owned());587		}588		let directory = current_dir()?;589590		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;591		let root_field = pool.get().await?;592593		let builtins_field = Value::binding(root_field.clone(), "builtins").await?;594		if self.local_system == "detect" {595			self.local_system = nix_go_json!(builtins_field.currentSystem);596		}597		let local_system = self.local_system.clone();598599		let mut fleet_data_path = directory.clone();600		fleet_data_path.push("fleet.nix");601		let bytes = std::fs::read_to_string(fleet_data_path)?;602		let data: Mutex<FleetData> = nixlike::parse_str(&bytes)?;603604		let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;605		let fleet_field = nix_go!(fleet_root.default({ data }));606607		let config_field = nix_go!(fleet_field.config);608609		assert_warn("fleet config evaluation", &config_field).await?;610611		let import = nix_go!(builtins_field.import);612		let overlays = nix_go!(config_field.nixpkgs.overlays);613		let nixpkgs = nix_go!(fleet_field.nixpkgs.buildUsing | import);614615		let default_pkgs = nix_go!(nixpkgs(Obj {616			overlays,617			system: { self.local_system.clone() },618		}));619620		Ok(Config(Arc::new(FleetConfigInternals {621			opts: self,622			directory,623			data,624			local_system,625			nix_args,626			config_field,627			default_pkgs,628		})))629	}630}
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;