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
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}