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