git.delta.rocks / jrsonnet / refs/commits / a8825de2d893

difftreelog

feat manual connection destination

ruzwnouyYaroslav Bolyukin2025-07-18parent: #c6d5484.patch.diff
in: trunk

2 files changed

modifiedcmds/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 {
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
before · crates/fleet-base/src/host.rs
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	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}
after · crates/fleet-base/src/host.rs
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}