difftreelog
feat manual connection destination
in: trunk
2 files changed
cmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -125,6 +125,9 @@
if let Some(deploy_kind) = opts.action_attr::<DeployKind>(&host, "deploy_kind").await? {
host.set_deploy_kind(deploy_kind);
};
+ if let Some(destination) = opts.action_attr::<String>(&host, "dest").await? {
+ host.set_session_destination(destination);
+ };
set.spawn_local(
(async move {
crates/fleet-base/src/host.rsdiffbeforeafterboth1use std::{2 cell::OnceCell,3 collections::BTreeSet,4 ffi::{OsStr, OsString},5 fmt::Display,6 io::Write,7 ops::Deref,8 path::PathBuf,9 str::FromStr,10 sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{Context, Result, anyhow, bail, ensure};14use fleet_shared::SecretData;15use nix_eval::{NixSession, Value, nix_go, nix_go_json, util::assert_warn};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24 command::MyCommand,25 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},26};2728pub struct FleetConfigInternals {29 /// Fleet project directory, containing fleet.nix file.30 pub directory: PathBuf,31 /// builtins.currentSystem32 pub local_system: String,33 pub data: Mutex<FleetData>,34 pub nix_args: Vec<OsString>,35 /// fleet_config.config36 pub config_field: Value,37 // TODO: Remove with connectivity refactor38 pub localhost: String,3940 /// import nixpkgs {system = local};41 pub default_pkgs: Value,42 /// inputs.nixpkgs43 pub nixpkgs: Value,4445 pub nix_session: NixSession,46}4748// TODO: Make field not pub49#[derive(Clone)]50pub struct Config(pub Arc<FleetConfigInternals>);5152impl Deref for Config {53 type Target = FleetConfigInternals;5455 fn deref(&self) -> &Self::Target {56 &self.057 }58}5960#[derive(Clone, Copy, Debug)]61pub enum EscalationStrategy {62 Sudo,63 Run0,64 Su,65}6667#[derive(Clone, PartialEq, Copy, Debug)]68pub enum DeployKind {69 /// NixOS => NixOS managed by fleet70 UpgradeToFleet,71 /// NixOS managed by fleet => NixOS managed by fleet72 Fleet,73 /// Remote host has /mnt, /mnt/boot mounted,74 /// generated config is added to fleet configuration.75 NixosInstall,76 /// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),77 /// generated config is added to fleet configuration,78 /// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.79 NixosLustrate,80}8182impl FromStr for DeployKind {83 type Err = anyhow::Error;84 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {85 match s {86 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),87 "fleet" => Ok(Self::Fleet),88 "nixos-install" => Ok(Self::NixosInstall),89 "nixos-lustrate" => Ok(Self::NixosLustrate),90 v => bail!(91 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""92 ),93 }94 }95}96pub struct ConfigHost {97 config: Config,98 pub name: String,99 groups: OnceCell<Vec<String>>,100101 deploy_kind: OnceCell<DeployKind>,102103 pub host_config: Option<Value>,104 pub nixos_config: OnceCell<Value>,105 pub nixos_unchecked_config: OnceCell<Value>,106 pub pkgs_override: Option<Value>,107108 // TODO: Move command helpers away with connectivity refactor109 pub local: bool,110 pub session: OnceLock<Arc<openssh::Session>>,111}112113#[derive(Debug, Clone, Copy)]114pub enum GenerationStorage {115 Deployer,116 Machine,117 Pusher,118}119impl GenerationStorage {120 fn prefix(&self) -> &'static str {121 match self {122 GenerationStorage::Deployer => "deployer.",123 GenerationStorage::Machine => "",124 GenerationStorage::Pusher => "pusher.",125 }126 }127}128129#[derive(Tabled, Debug)]130pub struct Generation {131 #[tabled(rename = "ID", format("{}", self.rollback_id()))]132 pub id: u32,133 #[tabled(rename = "Current")]134 pub current: bool,135 #[tabled(rename = "Created at")]136 pub datetime: UtcDateTime,137 #[tabled(format = "{:?}")]138 pub store_path: PathBuf,139 #[tabled(skip)]140 pub location: GenerationStorage,141}142impl Generation {143 pub fn rollback_id(&self) -> String {144 format!("{}{}", self.location.prefix(), self.id)145 }146}147148fn parse_generation_line(g: &str) -> Option<Generation> {149 let mut parts = g.split_whitespace();150 let id = parts.next()?;151 let id: u32 = id.parse().ok()?;152 let date = parts.next()?;153 let time = parts.next()?;154 let current = if let Some(current) = parts.next() {155 if current == "(current)" {156 Some(true)157 } else {158 None159 }160 } else {161 Some(false)162 };163 let current = current?;164 if parts.next().is_some() {165 warn!("unexpected text after generation: {g}");166 }167168 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")169 .expect("valid format");170 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;171172 Some(Generation {173 id,174 current,175 datetime,176 store_path: PathBuf::new(),177 location: GenerationStorage::Machine,178 })179}180// TODO: Move command helpers away with connectivity refactor181impl ConfigHost {182 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {183 let mut cmd = self.cmd("nix-env").await?;184 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))185 .arg("--list-generations")186 .env("TZ", "UTC");187 // Sudo is required because --list-generations tries to acquire profile lock188 let data = cmd.sudo().run_string().await?;189 let mut generations = data190 .split('\n')191 .map(|e| e.trim())192 .filter(|&l| !l.is_empty())193 .filter_map(|g| {194 let generation = parse_generation_line(g);195 if generation.is_none() {196 warn!("bad generation: {g}");197 };198 generation199 })200 .collect::<Vec<_>>();201 for ele in generations.iter_mut() {202 let mut cmd = self.cmd("readlink").await?;203 cmd.arg("--")204 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));205 let path = cmd.run_string().await?;206 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));207 }208209 Ok(generations)210 }211212 pub fn set_deploy_kind(&self, kind: DeployKind) {213 self.deploy_kind214 .set(kind)215 .ok()216 .expect("deploy kind is already set");217 }218 pub async fn deploy_kind(&self) -> Result<DeployKind> {219 if let Some(kind) = self.deploy_kind.get() {220 return Ok(kind.clone());221 }222 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {223 Ok(v) => v,224 Err(e) => {225 bail!("failed to query remote system kind: {}", e);226 }227 };228 if !is_fleet_managed {229 bail!(indoc::indoc! {"230 host is not marked as managed by fleet231 if you're not trying to lustrate/install system from scratch,232 you should either233 1. manually create /etc/FLEET_HOST file on the target host,234 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet235 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos236 "});237 }238 // TOCTOU is possible239 let _ = self.deploy_kind.set(DeployKind::Fleet);240 Ok(self241 .deploy_kind242 .get()243 .expect("deploy kind is just set")244 .clone())245 }246 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {247 // Prefer sudo, as run0 has some gotchas with polkit248 // and too many repeating prompts.249 if (self.find_in_path("sudo").await).is_ok() {250 return Ok(EscalationStrategy::Sudo);251 }252 if (self.find_in_path("run0").await).is_ok() {253 return Ok(EscalationStrategy::Run0);254 }255 Ok(EscalationStrategy::Su)256 }257 async fn open_session(&self) -> Result<Arc<openssh::Session>> {258 assert!(!self.local, "do not open ssh connection to local session");259 // FIXME: TOCTOU260 if let Some(session) = &self.session.get() {261 return Ok((*session).clone());262 };263 let session = SessionBuilder::default();264 let session = session265 .connect(&self.name)266 .await267 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;268 let session = Arc::new(session);269 self.session.set(session.clone()).expect("TOCTOU happened");270 Ok(session)271 }272 pub async fn mktemp_dir(&self) -> Result<String> {273 let mut cmd = self.cmd("mktemp").await?;274 cmd.arg("-d");275 let path = cmd.run_string().await?;276 Ok(path.trim_end().to_owned())277 }278 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {279 let mut cmd = self.cmd("sh").await?;280 cmd.arg("-c")281 .arg("test -e \"$1\" && echo true || echo false")282 .arg("_")283 .arg(path);284 Ok(cmd.run_value().await?)285 }286 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {287 let mut cmd = self.cmd("cat").await?;288 cmd.arg(path);289 cmd.run_bytes().await290 }291 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {292 let mut cmd = self.cmd("cat").await?;293 cmd.arg(path);294 cmd.run_string().await295 }296 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {297 let mut cmd = self.cmd("ls").await?;298 cmd.arg(path);299 let out = cmd.run_string().await?;300 let mut lines = out.split('\n');301 if let Some(last) = lines.next_back() {302 ensure!(last.is_empty(), "output of ls should end with newline");303 }304 Ok(lines.map(ToOwned::to_owned).collect())305 }306 #[allow(dead_code)]307 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {308 let text = self.read_file_text(path).await?;309 Ok(serde_json::from_str(&text)?)310 }311 pub async fn read_env(&self, env: &str) -> Result<String> {312 let mut cmd = self.cmd("printenv").await?;313 cmd.arg(env);314 cmd.run_string().await315 }316 pub async fn find_in_path(&self, command: &str) -> Result<String> {317 // // `which` is not a part of coreutils, and it might not exist on machine.318 // let path = self.read_env("PATH").await?;319 // // Assuming delimiter is :, we don't work with windows host, this check will be much320 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)321 // for ele in path.split(':') {322 // let test_path = format!("{ele}/{cmd}");323 // test -x etc324 // }325 // let mut cmd = self.cmd("printenv").await?;326 // cmd.arg(env);327 // Ok(cmd.run_string().await?)328 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.329 let mut cmd = self330 .cmd_escalation(331 // Not used332 EscalationStrategy::Su,333 "which",334 )335 .await?;336 cmd.arg(command);337 cmd.run_string().await338 }339 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>340 where341 <D as FromStr>::Err: Display,342 {343 let text = self.read_file_text(path).await?;344 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))345 }346 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {347 self.cmd_escalation(self.escalation_strategy().await?, cmd)348 .await349 }350 pub async fn cmd_escalation(351 &self,352 escalation: EscalationStrategy,353 cmd: impl AsRef<OsStr>,354 ) -> Result<MyCommand> {355 if self.local {356 Ok(MyCommand::new(escalation, cmd))357 } else {358 let session = self.open_session().await?;359 Ok(MyCommand::new_on(escalation, cmd, session))360 }361 }362 pub async fn nix_cmd(&self) -> Result<MyCommand> {363 let mut nix = self.cmd("nix").await?;364 nix.args([365 "--extra-experimental-features",366 "nix-command",367 "--extra-experimental-features",368 "flakes",369 ]);370 Ok(nix)371 }372373 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {374 ensure!(data.encrypted, "secret is not encrypted");375 let mut cmd = self.cmd("fleet-install-secrets").await?;376 cmd.arg("decrypt").eqarg("--secret", data.to_string());377 let encoded = cmd378 .sudo()379 .run_string()380 .await381 .context("failed to call remote host for decrypt")?;382 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;383 ensure!(!data.encrypted, "secret came out encrypted");384 Ok(data.data)385 }386 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {387 ensure!(data.encrypted, "secret is not encrypted");388 let mut cmd = self.cmd("fleet-install-secrets").await?;389 cmd.arg("reencrypt").eqarg("--secret", data.to_string());390 for target in targets {391 let key = self.config.key(&target).await?;392 cmd.eqarg("--targets", key);393 }394 let encoded = cmd395 .sudo()396 .run_string()397 .await398 .context("failed to call remote host for decrypt")?;399 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;400 ensure!(data.encrypted, "secret came out not encrypted");401 Ok(data)402 }403 /// Returns path for futureproofing, as path might change i.e on conversion to CA404 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {405 if self.local {406 // Path is located locally, thus already trusted.407 return Ok(path.to_owned());408 }409 let mut nix = MyCommand::new(410 // Not used411 EscalationStrategy::Su,412 "nix",413 );414 nix.arg("copy").arg("--substitute-on-destination");415416 match self.deploy_kind().await? {417 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {418 nix.comparg("--to", format!("ssh-ng://{}", self.name));419 }420 DeployKind::NixosInstall => {421 nix422 // Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon423 .arg("--no-check-sigs")424 .comparg(425 "--to",426 format!("ssh-ng://root@{}?remote-store=/mnt", self.name),427 );428 }429 }430 nix.arg(path);431 nix.run_nix().await.context("nix copy")?;432 Ok(path.to_owned())433 }434 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {435 let mut cmd = self.cmd("systemctl").await?;436 cmd.arg("stop").arg(name);437 cmd.sudo().run().await438 }439 pub async fn systemctl_start(&self, name: &str) -> Result<()> {440 let mut cmd = self.cmd("systemctl").await?;441 cmd.arg("start").arg(name);442 cmd.sudo().run().await443 }444445 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {446 let mut cmd = self.cmd("rm").await?;447 cmd.arg("-f").arg(path);448 if sudo {449 cmd = cmd.sudo()450 }451 cmd.run().await452 }453}454impl ConfigHost {455 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,456 // assuming getting tags always returns the same value.457 pub async fn tags(&self) -> Result<Vec<String>> {458 if let Some(v) = self.groups.get() {459 return Ok(v.clone());460 }461 let Some(host_config) = &self.host_config else {462 return Ok(vec![]);463 };464 let tags: Vec<String> = nix_go_json!(host_config.tags);465466 let _ = self.groups.set(tags.clone());467468 Ok(tags)469 }470 pub async fn nixos_config(&self) -> Result<Value> {471 if let Some(v) = self.nixos_config.get() {472 return Ok(v.clone());473 }474 let Some(host_config) = &self.host_config else {475 bail!("local host has no nixos_config");476 };477 let nixos_config = nix_go!(host_config.nixos.config);478 assert_warn("nixos config evaluation", &nixos_config).await?;479480 let _ = self.nixos_config.set(nixos_config.clone());481482 Ok(nixos_config)483 }484 pub async fn nixos_unchecked_config(&self) -> Result<Value> {485 if let Some(v) = self.nixos_unchecked_config.get() {486 return Ok(v.clone());487 }488 let Some(host_config) = &self.host_config else {489 bail!("local host has no nixos_config");490 };491 let nixos_config = nix_go!(host_config.nixos_unchecked.config);492493 let _ = self.nixos_unchecked_config.set(nixos_config.clone());494495 Ok(nixos_config)496 }497498 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {499 let nixos = self.nixos_unchecked_config().await?;500 let secrets = nix_go!(nixos.secrets);501 let mut out = Vec::new();502 for name in secrets.list_fields().await? {503 let secret = nix_go!(secrets[{ name }]);504 let is_shared: bool = nix_go_json!(secret.shared);505 if is_shared {506 continue;507 }508 out.push(name);509 }510 Ok(out)511 }512 pub async fn secret_field(&self, name: &str) -> Result<Value> {513 let nixos = self.nixos_unchecked_config().await?;514 Ok(nix_go!(nixos.secrets[{ name }]))515 }516517 /// Packages for this host, resolved with nixpkgs overlays518 pub async fn pkgs(&self) -> Result<Value> {519 if let Some(value) = &self.pkgs_override {520 return Ok(value.clone());521 }522 let Some(host_config) = &self.host_config else {523 bail!("local host has no host_config");524 };525 // TODO: Should nixos.options be cached?526 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))527 }528}529530impl Config {531 pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {532 let config = &self.config_field;533 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);534 Ok(tagged)535 }536 pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {537 let mut out = BTreeSet::new();538 for owner in owners {539 if let Some(tag) = owner.strip_prefix('@') {540 let hosts = self.tagged_hostnames(tag).await?;541 out.extend(hosts);542 } else {543 out.insert(owner);544 }545 }546 Ok(out)547 }548 pub fn local_host(&self) -> ConfigHost {549 ConfigHost {550 config: self.clone(),551 name: "<virtual localhost>".to_owned(),552 host_config: None,553 nixos_config: OnceCell::new(),554 nixos_unchecked_config: OnceCell::new(),555 groups: {556 let cell = OnceCell::new();557 let _ = cell.set(vec![]);558 cell559 },560 pkgs_override: Some(self.default_pkgs.clone()),561562 local: true,563 session: OnceLock::new(),564 deploy_kind: OnceCell::new(),565 }566 }567568 pub async fn host(&self, name: &str) -> Result<ConfigHost> {569 let config = &self.config_field;570 let host_config = nix_go!(config.hosts[{ name }]);571572 Ok(ConfigHost {573 config: self.clone(),574 name: name.to_owned(),575 host_config: Some(host_config),576 nixos_config: OnceCell::new(),577 nixos_unchecked_config: OnceCell::new(),578 groups: OnceCell::new(),579 pkgs_override: None,580581 // TODO: Remove with connectivit refactor582 local: self.localhost == name,583 session: OnceLock::new(),584 deploy_kind: OnceCell::new(),585 })586 }587 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {588 let config = &self.config_field;589 let names = nix_go!(config.hosts).list_fields().await?;590 let mut out = vec![];591 for name in names {592 out.push(self.host(&name).await?);593 }594 Ok(out)595 }596 // TODO: Replace usages with .host().nixos_config597 pub async fn system_config(&self, host: &str) -> Result<Value> {598 let fleet_field = &self.config_field;599 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))600 }601602 /// Shared secrets configured in fleet.nix or in flake603 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {604 let config_field = &self.config_field;605 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)606 }607 /// Shared secrets configured in fleet.nix608 pub fn list_shared(&self) -> Vec<String> {609 let data = self.data();610 data.shared_secrets.keys().cloned().collect()611 }612 pub fn has_shared(&self, name: &str) -> bool {613 let data = self.data();614 data.shared_secrets.contains_key(name)615 }616 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {617 let mut data = self.data_mut();618 data.shared_secrets.insert(name.to_owned(), shared);619 }620 pub fn remove_shared(&self, secret: &str) {621 let mut data = self.data_mut();622 data.shared_secrets.remove(secret);623 }624625 pub fn list_secrets(&self, host: &str) -> Vec<String> {626 let data = self.data();627 let Some(secrets) = data.host_secrets.get(host) else {628 return Vec::new();629 };630 secrets.keys().cloned().collect()631 }632633 pub fn has_secret(&self, host: &str, secret: &str) -> bool {634 let data = self.data();635 let Some(host_secrets) = data.host_secrets.get(host) else {636 return false;637 };638 host_secrets.contains_key(secret)639 }640 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {641 let mut data = self.data_mut();642 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();643 host_secrets.insert(secret, value);644 }645646 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {647 let data = self.data();648 let Some(host_secrets) = data.host_secrets.get(host) else {649 bail!("no secrets for machine {host}");650 };651 let Some(secret) = host_secrets.get(secret) else {652 bail!("machine {host} has no secret {secret}");653 };654 Ok(secret.clone())655 }656 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {657 let data = self.data();658 let Some(secret) = data.shared_secrets.get(secret) else {659 bail!("no shared secret {secret}");660 };661 Ok(secret.clone())662 }663 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {664 let config_field = &self.config_field;665 Ok(nix_go_json!(666 config_field.sharedSecrets[{ secret }].expectedOwners667 ))668 }669670 // TODO: Should this be something modifiable from other processes?671 // E.g terraform provider might want to update FleetData (e.g secrets),672 // and current implementation assumes only one process holds current fleet.nix673 // Given that it is no longer needs to be a file for nix evaluation,674 // maybe it can be a .nix file for persistence, but accessible only675 // thru some shared state controller? Might it be stored in terraform676 // state provider?677 pub fn data(&self) -> MutexGuard<FleetData> {678 self.data.lock().unwrap()679 }680 pub fn data_mut(&self) -> MutexGuard<FleetData> {681 self.data.lock().unwrap()682 }683 pub fn save(&self) -> Result<()> {684 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.")?;685 let data = nixlike::serialize(&self.data() as &FleetData)?;686 tempfile.write_all(687 format!(688 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",689 data690 )691 .as_bytes(),692 )?;693 let mut fleet_data_path = self.directory.clone();694 fleet_data_path.push("fleet.nix");695 tempfile.persist(fleet_data_path)?;696 Ok(())697 }698}1use std::{2 cell::OnceCell,3 collections::BTreeSet,4 ffi::{OsStr, OsString},5 fmt::Display,6 io::Write,7 ops::Deref,8 path::PathBuf,9 str::FromStr,10 sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{Context, Result, anyhow, bail, ensure};14use fleet_shared::SecretData;15use nix_eval::{NixSession, Value, nix_go, nix_go_json, util::assert_warn};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24 command::MyCommand,25 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},26};2728pub struct FleetConfigInternals {29 /// Fleet project directory, containing fleet.nix file.30 pub directory: PathBuf,31 /// builtins.currentSystem32 pub local_system: String,33 pub data: Mutex<FleetData>,34 pub nix_args: Vec<OsString>,35 /// fleet_config.config36 pub config_field: Value,37 // TODO: Remove with connectivity refactor38 pub localhost: String,3940 /// import nixpkgs {system = local};41 pub default_pkgs: Value,42 /// inputs.nixpkgs43 pub nixpkgs: Value,4445 pub nix_session: NixSession,46}4748// TODO: Make field not pub49#[derive(Clone)]50pub struct Config(pub Arc<FleetConfigInternals>);5152impl Deref for Config {53 type Target = FleetConfigInternals;5455 fn deref(&self) -> &Self::Target {56 &self.057 }58}5960#[derive(Clone, Copy, Debug)]61pub enum EscalationStrategy {62 Sudo,63 Run0,64 Su,65}6667#[derive(Clone, PartialEq, Copy, Debug)]68pub enum DeployKind {69 /// NixOS => NixOS managed by fleet70 UpgradeToFleet,71 /// NixOS managed by fleet => NixOS managed by fleet72 Fleet,73 /// Remote host has /mnt, /mnt/boot mounted,74 /// generated config is added to fleet configuration.75 NixosInstall,76 /// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),77 /// generated config is added to fleet configuration,78 /// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.79 NixosLustrate,80}8182impl FromStr for DeployKind {83 type Err = anyhow::Error;84 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {85 match s {86 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),87 "fleet" => Ok(Self::Fleet),88 "nixos-install" => Ok(Self::NixosInstall),89 "nixos-lustrate" => Ok(Self::NixosLustrate),90 v => bail!(91 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""92 ),93 }94 }95}96pub struct ConfigHost {97 config: Config,98 pub name: String,99 groups: OnceCell<Vec<String>>,100101 // TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it102 deploy_kind: OnceCell<DeployKind>,103 session_destination: OnceCell<String>,104105 pub host_config: Option<Value>,106 pub nixos_config: OnceCell<Value>,107 pub nixos_unchecked_config: OnceCell<Value>,108 pub pkgs_override: Option<Value>,109110 // TODO: Move command helpers away with connectivity refactor111 pub local: bool,112 pub session: OnceLock<Arc<openssh::Session>>,113}114115#[derive(Debug, Clone, Copy)]116pub enum GenerationStorage {117 Deployer,118 Machine,119 Pusher,120}121impl GenerationStorage {122 fn prefix(&self) -> &'static str {123 match self {124 GenerationStorage::Deployer => "deployer.",125 GenerationStorage::Machine => "",126 GenerationStorage::Pusher => "pusher.",127 }128 }129}130131#[derive(Tabled, Debug)]132pub struct Generation {133 #[tabled(rename = "ID", format("{}", self.rollback_id()))]134 pub id: u32,135 #[tabled(rename = "Current")]136 pub current: bool,137 #[tabled(rename = "Created at")]138 pub datetime: UtcDateTime,139 #[tabled(format = "{:?}")]140 pub store_path: PathBuf,141 #[tabled(skip)]142 pub location: GenerationStorage,143}144impl Generation {145 pub fn rollback_id(&self) -> String {146 format!("{}{}", self.location.prefix(), self.id)147 }148}149150fn parse_generation_line(g: &str) -> Option<Generation> {151 let mut parts = g.split_whitespace();152 let id = parts.next()?;153 let id: u32 = id.parse().ok()?;154 let date = parts.next()?;155 let time = parts.next()?;156 let current = if let Some(current) = parts.next() {157 if current == "(current)" {158 Some(true)159 } else {160 None161 }162 } else {163 Some(false)164 };165 let current = current?;166 if parts.next().is_some() {167 warn!("unexpected text after generation: {g}");168 }169170 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")171 .expect("valid format");172 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;173174 Some(Generation {175 id,176 current,177 datetime,178 store_path: PathBuf::new(),179 location: GenerationStorage::Machine,180 })181}182// TODO: Move command helpers away with connectivity refactor183impl ConfigHost {184 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {185 let mut cmd = self.cmd("nix-env").await?;186 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))187 .arg("--list-generations")188 .env("TZ", "UTC");189 // Sudo is required because --list-generations tries to acquire profile lock190 let data = cmd.sudo().run_string().await?;191 let mut generations = data192 .split('\n')193 .map(|e| e.trim())194 .filter(|&l| !l.is_empty())195 .filter_map(|g| {196 let generation = parse_generation_line(g);197 if generation.is_none() {198 warn!("bad generation: {g}");199 };200 generation201 })202 .collect::<Vec<_>>();203 for ele in generations.iter_mut() {204 let mut cmd = self.cmd("readlink").await?;205 cmd.arg("--")206 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));207 let path = cmd.run_string().await?;208 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));209 }210211 Ok(generations)212 }213214 pub fn set_session_destination(&self, dest: String) {215 self.session_destination216 .set(dest)217 .ok()218 .expect("session destination is already set")219 }220 pub fn set_deploy_kind(&self, kind: DeployKind) {221 self.deploy_kind222 .set(kind)223 .ok()224 .expect("deploy kind is already set");225 }226 pub async fn deploy_kind(&self) -> Result<DeployKind> {227 if let Some(kind) = self.deploy_kind.get() {228 return Ok(*kind);229 }230 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {231 Ok(v) => v,232 Err(e) => {233 bail!("failed to query remote system kind: {}", e);234 }235 };236 if !is_fleet_managed {237 bail!(indoc::indoc! {"238 host is not marked as managed by fleet239 if you're not trying to lustrate/install system from scratch,240 you should either241 1. manually create /etc/FLEET_HOST file on the target host,242 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet243 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos244 "});245 }246 // TOCTOU is possible247 let _ = self.deploy_kind.set(DeployKind::Fleet);248 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))249 }250 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {251 // Prefer sudo, as run0 has some gotchas with polkit252 // and too many repeating prompts.253 if (self.find_in_path("sudo").await).is_ok() {254 return Ok(EscalationStrategy::Sudo);255 }256 if (self.find_in_path("run0").await).is_ok() {257 return Ok(EscalationStrategy::Run0);258 }259 Ok(EscalationStrategy::Su)260 }261 async fn open_session(&self) -> Result<Arc<openssh::Session>> {262 assert!(!self.local, "do not open ssh connection to local session");263 // FIXME: TOCTOU264 if let Some(session) = &self.session.get() {265 return Ok((*session).clone());266 };267 let session = SessionBuilder::default();268269 let dest = self.session_destination.get().unwrap_or(&self.name);270 let session = session271 .connect(&dest)272 .await273 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;274 let session = Arc::new(session);275 self.session.set(session.clone()).expect("TOCTOU happened");276 Ok(session)277 }278 pub async fn mktemp_dir(&self) -> Result<String> {279 let mut cmd = self.cmd("mktemp").await?;280 cmd.arg("-d");281 let path = cmd.run_string().await?;282 Ok(path.trim_end().to_owned())283 }284 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {285 let mut cmd = self.cmd("sh").await?;286 cmd.arg("-c")287 .arg("test -e \"$1\" && echo true || echo false")288 .arg("_")289 .arg(path);290 cmd.run_value().await291 }292 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {293 let mut cmd = self.cmd("cat").await?;294 cmd.arg(path);295 cmd.run_bytes().await296 }297 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {298 let mut cmd = self.cmd("cat").await?;299 cmd.arg(path);300 cmd.run_string().await301 }302 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {303 let mut cmd = self.cmd("ls").await?;304 cmd.arg(path);305 let out = cmd.run_string().await?;306 let mut lines = out.split('\n');307 if let Some(last) = lines.next_back() {308 ensure!(last.is_empty(), "output of ls should end with newline");309 }310 Ok(lines.map(ToOwned::to_owned).collect())311 }312 #[allow(dead_code)]313 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {314 let text = self.read_file_text(path).await?;315 Ok(serde_json::from_str(&text)?)316 }317 pub async fn read_env(&self, env: &str) -> Result<String> {318 let mut cmd = self.cmd("printenv").await?;319 cmd.arg(env);320 cmd.run_string().await321 }322 pub async fn find_in_path(&self, command: &str) -> Result<String> {323 // // `which` is not a part of coreutils, and it might not exist on machine.324 // let path = self.read_env("PATH").await?;325 // // Assuming delimiter is :, we don't work with windows host, this check will be much326 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)327 // for ele in path.split(':') {328 // let test_path = format!("{ele}/{cmd}");329 // test -x etc330 // }331 // let mut cmd = self.cmd("printenv").await?;332 // cmd.arg(env);333 // Ok(cmd.run_string().await?)334 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.335 let mut cmd = self336 .cmd_escalation(337 // Not used338 EscalationStrategy::Su,339 "which",340 )341 .await?;342 cmd.arg(command);343 cmd.run_string().await344 }345 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>346 where347 <D as FromStr>::Err: Display,348 {349 let text = self.read_file_text(path).await?;350 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))351 }352 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {353 self.cmd_escalation(self.escalation_strategy().await?, cmd)354 .await355 }356 pub async fn cmd_escalation(357 &self,358 escalation: EscalationStrategy,359 cmd: impl AsRef<OsStr>,360 ) -> Result<MyCommand> {361 if self.local {362 Ok(MyCommand::new(escalation, cmd))363 } else {364 let session = self.open_session().await?;365 Ok(MyCommand::new_on(escalation, cmd, session))366 }367 }368 pub async fn nix_cmd(&self) -> Result<MyCommand> {369 let mut nix = self.cmd("nix").await?;370 nix.args([371 "--extra-experimental-features",372 "nix-command",373 "--extra-experimental-features",374 "flakes",375 ]);376 Ok(nix)377 }378379 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {380 ensure!(data.encrypted, "secret is not encrypted");381 let mut cmd = self.cmd("fleet-install-secrets").await?;382 cmd.arg("decrypt").eqarg("--secret", data.to_string());383 let encoded = cmd384 .sudo()385 .run_string()386 .await387 .context("failed to call remote host for decrypt")?;388 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;389 ensure!(!data.encrypted, "secret came out encrypted");390 Ok(data.data)391 }392 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {393 ensure!(data.encrypted, "secret is not encrypted");394 let mut cmd = self.cmd("fleet-install-secrets").await?;395 cmd.arg("reencrypt").eqarg("--secret", data.to_string());396 for target in targets {397 let key = self.config.key(&target).await?;398 cmd.eqarg("--targets", key);399 }400 let encoded = cmd401 .sudo()402 .run_string()403 .await404 .context("failed to call remote host for decrypt")?;405 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;406 ensure!(data.encrypted, "secret came out not encrypted");407 Ok(data)408 }409 /// Returns path for futureproofing, as path might change i.e on conversion to CA410 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {411 if self.local {412 // Path is located locally, thus already trusted.413 return Ok(path.to_owned());414 }415 let mut nix = MyCommand::new(416 // Not used417 EscalationStrategy::Su,418 "nix",419 );420 nix.arg("copy").arg("--substitute-on-destination");421422 match self.deploy_kind().await? {423 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {424 nix.comparg("--to", format!("ssh-ng://{}", self.name));425 }426 DeployKind::NixosInstall => {427 nix428 // Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon429 .arg("--no-check-sigs")430 .comparg(431 "--to",432 format!("ssh-ng://root@{}?remote-store=/mnt", self.name),433 );434 }435 }436 nix.arg(path);437 nix.run_nix().await.context("nix copy")?;438 Ok(path.to_owned())439 }440 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {441 let mut cmd = self.cmd("systemctl").await?;442 cmd.arg("stop").arg(name);443 cmd.sudo().run().await444 }445 pub async fn systemctl_start(&self, name: &str) -> Result<()> {446 let mut cmd = self.cmd("systemctl").await?;447 cmd.arg("start").arg(name);448 cmd.sudo().run().await449 }450451 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {452 let mut cmd = self.cmd("rm").await?;453 cmd.arg("-f").arg(path);454 if sudo {455 cmd = cmd.sudo()456 }457 cmd.run().await458 }459}460impl ConfigHost {461 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,462 // assuming getting tags always returns the same value.463 pub async fn tags(&self) -> Result<Vec<String>> {464 if let Some(v) = self.groups.get() {465 return Ok(v.clone());466 }467 let Some(host_config) = &self.host_config else {468 return Ok(vec![]);469 };470 let tags: Vec<String> = nix_go_json!(host_config.tags);471472 let _ = self.groups.set(tags.clone());473474 Ok(tags)475 }476 pub async fn nixos_config(&self) -> Result<Value> {477 if let Some(v) = self.nixos_config.get() {478 return Ok(v.clone());479 }480 let Some(host_config) = &self.host_config else {481 bail!("local host has no nixos_config");482 };483 let nixos_config = nix_go!(host_config.nixos.config);484 assert_warn("nixos config evaluation", &nixos_config).await?;485486 let _ = self.nixos_config.set(nixos_config.clone());487488 Ok(nixos_config)489 }490 pub async fn nixos_unchecked_config(&self) -> Result<Value> {491 if let Some(v) = self.nixos_unchecked_config.get() {492 return Ok(v.clone());493 }494 let Some(host_config) = &self.host_config else {495 bail!("local host has no nixos_config");496 };497 let nixos_config = nix_go!(host_config.nixos_unchecked.config);498499 let _ = self.nixos_unchecked_config.set(nixos_config.clone());500501 Ok(nixos_config)502 }503504 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {505 let nixos = self.nixos_unchecked_config().await?;506 let secrets = nix_go!(nixos.secrets);507 let mut out = Vec::new();508 for name in secrets.list_fields().await? {509 let secret = nix_go!(secrets[{ name }]);510 let is_shared: bool = nix_go_json!(secret.shared);511 if is_shared {512 continue;513 }514 out.push(name);515 }516 Ok(out)517 }518 pub async fn secret_field(&self, name: &str) -> Result<Value> {519 let nixos = self.nixos_unchecked_config().await?;520 Ok(nix_go!(nixos.secrets[{ name }]))521 }522523 /// Packages for this host, resolved with nixpkgs overlays524 pub async fn pkgs(&self) -> Result<Value> {525 if let Some(value) = &self.pkgs_override {526 return Ok(value.clone());527 }528 let Some(host_config) = &self.host_config else {529 bail!("local host has no host_config");530 };531 // TODO: Should nixos.options be cached?532 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))533 }534}535536impl Config {537 pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {538 let config = &self.config_field;539 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);540 Ok(tagged)541 }542 pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {543 let mut out = BTreeSet::new();544 for owner in owners {545 if let Some(tag) = owner.strip_prefix('@') {546 let hosts = self.tagged_hostnames(tag).await?;547 out.extend(hosts);548 } else {549 out.insert(owner);550 }551 }552 Ok(out)553 }554 pub fn local_host(&self) -> ConfigHost {555 ConfigHost {556 config: self.clone(),557 name: "<virtual localhost>".to_owned(),558 host_config: None,559 nixos_config: OnceCell::new(),560 nixos_unchecked_config: OnceCell::new(),561 groups: {562 let cell = OnceCell::new();563 let _ = cell.set(vec![]);564 cell565 },566 pkgs_override: Some(self.default_pkgs.clone()),567568 local: true,569 session: OnceLock::new(),570 deploy_kind: OnceCell::new(),571 }572 }573574 pub async fn host(&self, name: &str) -> Result<ConfigHost> {575 let config = &self.config_field;576 let host_config = nix_go!(config.hosts[{ name }]);577578 Ok(ConfigHost {579 config: self.clone(),580 name: name.to_owned(),581 host_config: Some(host_config),582 nixos_config: OnceCell::new(),583 nixos_unchecked_config: OnceCell::new(),584 groups: OnceCell::new(),585 pkgs_override: None,586587 // TODO: Remove with connectivit refactor588 local: self.localhost == name,589 session: OnceLock::new(),590 deploy_kind: OnceCell::new(),591 })592 }593 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {594 let config = &self.config_field;595 let names = nix_go!(config.hosts).list_fields().await?;596 let mut out = vec![];597 for name in names {598 out.push(self.host(&name).await?);599 }600 Ok(out)601 }602 // TODO: Replace usages with .host().nixos_config603 pub async fn system_config(&self, host: &str) -> Result<Value> {604 let fleet_field = &self.config_field;605 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))606 }607608 /// Shared secrets configured in fleet.nix or in flake609 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {610 let config_field = &self.config_field;611 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)612 }613 /// Shared secrets configured in fleet.nix614 pub fn list_shared(&self) -> Vec<String> {615 let data = self.data();616 data.shared_secrets.keys().cloned().collect()617 }618 pub fn has_shared(&self, name: &str) -> bool {619 let data = self.data();620 data.shared_secrets.contains_key(name)621 }622 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {623 let mut data = self.data_mut();624 data.shared_secrets.insert(name.to_owned(), shared);625 }626 pub fn remove_shared(&self, secret: &str) {627 let mut data = self.data_mut();628 data.shared_secrets.remove(secret);629 }630631 pub fn list_secrets(&self, host: &str) -> Vec<String> {632 let data = self.data();633 let Some(secrets) = data.host_secrets.get(host) else {634 return Vec::new();635 };636 secrets.keys().cloned().collect()637 }638639 pub fn has_secret(&self, host: &str, secret: &str) -> bool {640 let data = self.data();641 let Some(host_secrets) = data.host_secrets.get(host) else {642 return false;643 };644 host_secrets.contains_key(secret)645 }646 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {647 let mut data = self.data_mut();648 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();649 host_secrets.insert(secret, value);650 }651652 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {653 let data = self.data();654 let Some(host_secrets) = data.host_secrets.get(host) else {655 bail!("no secrets for machine {host}");656 };657 let Some(secret) = host_secrets.get(secret) else {658 bail!("machine {host} has no secret {secret}");659 };660 Ok(secret.clone())661 }662 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {663 let data = self.data();664 let Some(secret) = data.shared_secrets.get(secret) else {665 bail!("no shared secret {secret}");666 };667 Ok(secret.clone())668 }669 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {670 let config_field = &self.config_field;671 Ok(nix_go_json!(672 config_field.sharedSecrets[{ secret }].expectedOwners673 ))674 }675676 // TODO: Should this be something modifiable from other processes?677 // E.g terraform provider might want to update FleetData (e.g secrets),678 // and current implementation assumes only one process holds current fleet.nix679 // Given that it is no longer needs to be a file for nix evaluation,680 // maybe it can be a .nix file for persistence, but accessible only681 // thru some shared state controller? Might it be stored in terraform682 // state provider?683 pub fn data(&self) -> MutexGuard<FleetData> {684 self.data.lock().unwrap()685 }686 pub fn data_mut(&self) -> MutexGuard<FleetData> {687 self.data.lock().unwrap()688 }689 pub fn save(&self) -> Result<()> {690 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.")?;691 let data = nixlike::serialize(&self.data() as &FleetData)?;692 tempfile.write_all(693 format!(694 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",695 data696 )697 .as_bytes(),698 )?;699 let mut fleet_data_path = self.directory.clone();700 fleet_data_path.push("fleet.nix");701 tempfile.persist(fleet_data_path)?;702 Ok(())703 }704}