git.delta.rocks / jrsonnet / refs/commits / 3a7032e3bf89

difftreelog

fix legacy ssh store support

lwkltrupYaroslav Bolyukin2025-09-10parent: #79b689b.patch.diff
in: trunk

4 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
@@ -106,6 +106,9 @@
 			if let Some(destination) = opts.action_attr::<String>(&host, "dest").await? {
 				host.set_session_destination(destination);
 			};
+			if let Some(legacy) = opts.action_attr::<bool>(&host, "legacy_ssh_store").await? {
+				host.set_legacy_ssh_store(legacy);
+			};
 
 			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::{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,44}4546// TODO: Make field not pub47#[derive(Clone)]48pub struct Config(pub Arc<FleetConfigInternals>);4950impl Deref for Config {51	type Target = FleetConfigInternals;5253	fn deref(&self) -> &Self::Target {54		&self.055	}56}5758#[derive(Clone, Copy, Debug)]59pub enum EscalationStrategy {60	Sudo,61	Run0,62	Su,63}6465#[derive(Clone, PartialEq, Copy, Debug)]66pub enum DeployKind {67	/// NixOS => NixOS managed by fleet68	UpgradeToFleet,69	/// NixOS managed by fleet => NixOS managed by fleet70	Fleet,71	/// Remote host has /mnt, /mnt/boot mounted,72	/// generated config is added to fleet configuration.73	NixosInstall,74	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),75	/// generated config is added to fleet configuration,76	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.77	NixosLustrate,78}7980impl FromStr for DeployKind {81	type Err = anyhow::Error;82	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {83		match s {84			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),85			"fleet" => Ok(Self::Fleet),86			"nixos-install" => Ok(Self::NixosInstall),87			"nixos-lustrate" => Ok(Self::NixosLustrate),88			v => bail!(89				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""90			),91		}92	}93}94pub struct ConfigHost {95	config: Config,96	pub name: String,97	groups: OnceCell<Vec<String>>,9899	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it100	deploy_kind: OnceCell<DeployKind>,101	session_destination: OnceCell<String>,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_session_destination(&self, dest: String) {213		self.session_destination214			.set(dest)215			.expect("session destination is already set")216	}217	pub fn set_deploy_kind(&self, kind: DeployKind) {218		self.deploy_kind219			.set(kind)220			.expect("deploy kind is already set");221	}222	pub async fn deploy_kind(&self) -> Result<DeployKind> {223		if let Some(kind) = self.deploy_kind.get() {224			return Ok(*kind);225		}226		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {227			Ok(v) => v,228			Err(e) => {229				bail!("failed to query remote system kind: {}", e);230			}231		};232		if !is_fleet_managed {233			bail!(234				"{}",235				indoc::indoc! {"236				host is not marked as managed by fleet237				if you're not trying to lustrate/install system from scratch,238				you should either239					1. manually create /etc/FLEET_HOST file on the target host,240					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet241					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos242			"}243			);244		}245		// TOCTOU is possible246		let _ = self.deploy_kind.set(DeployKind::Fleet);247		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))248	}249	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {250		// Prefer sudo, as run0 has some gotchas with polkit251		// and too many repeating prompts.252		if (self.find_in_path("sudo").await).is_ok() {253			return Ok(EscalationStrategy::Sudo);254		}255		if (self.find_in_path("run0").await).is_ok() {256			return Ok(EscalationStrategy::Run0);257		}258		Ok(EscalationStrategy::Su)259	}260	async fn open_session(&self) -> Result<Arc<openssh::Session>> {261		assert!(!self.local, "do not open ssh connection to local session");262		// FIXME: TOCTOU263		if let Some(session) = &self.session.get() {264			return Ok((*session).clone());265		};266		let session = SessionBuilder::default();267268		let dest = self.session_destination.get().unwrap_or(&self.name);269		let session = session270			.connect(&dest)271			.await272			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;273		let session = Arc::new(session);274		self.session.set(session.clone()).expect("TOCTOU happened");275		Ok(session)276	}277	pub async fn mktemp_dir(&self) -> Result<String> {278		let mut cmd = self.cmd("mktemp").await?;279		cmd.arg("-d");280		let path = cmd.run_string().await?;281		Ok(path.trim_end().to_owned())282	}283	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {284		let mut cmd = self.cmd("sh").await?;285		cmd.arg("-c")286			.arg("test -e \"$1\" && echo true || echo false")287			.arg("_")288			.arg(path);289		cmd.run_value().await290	}291	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {292		let mut cmd = self.cmd("cat").await?;293		cmd.arg(path);294		cmd.run_bytes().await295	}296	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {297		let mut cmd = self.cmd("cat").await?;298		cmd.arg(path);299		cmd.run_string().await300	}301	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {302		let mut cmd = self.cmd("ls").await?;303		cmd.arg(path);304		let out = cmd.run_string().await?;305		let mut lines = out.split('\n');306		if let Some(last) = lines.next_back() {307			ensure!(last.is_empty(), "output of ls should end with newline");308		}309		Ok(lines.map(ToOwned::to_owned).collect())310	}311	#[allow(dead_code)]312	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {313		let text = self.read_file_text(path).await?;314		Ok(serde_json::from_str(&text)?)315	}316	pub async fn read_env(&self, env: &str) -> Result<String> {317		let mut cmd = self.cmd("printenv").await?;318		cmd.arg(env);319		cmd.run_string().await320	}321	pub async fn find_in_path(&self, command: &str) -> Result<String> {322		// // `which` is not a part of coreutils, and it might not exist on machine.323		// let path = self.read_env("PATH").await?;324		// // Assuming delimiter is :, we don't work with windows host, this check will be much325		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)326		// for ele in path.split(':') {327		// 	let test_path = format!("{ele}/{cmd}");328		// 	test -x etc329		// }330		// let mut cmd = self.cmd("printenv").await?;331		// cmd.arg(env);332		// Ok(cmd.run_string().await?)333		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.334		let mut cmd = self335			.cmd_escalation(336				// Not used337				EscalationStrategy::Su,338				"which",339			)340			.await?;341		cmd.arg(command);342		cmd.run_string().await343	}344	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>345	where346		<D as FromStr>::Err: Display,347	{348		let text = self.read_file_text(path).await?;349		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))350	}351	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {352		self.cmd_escalation(self.escalation_strategy().await?, cmd)353			.await354	}355	pub async fn cmd_escalation(356		&self,357		escalation: EscalationStrategy,358		cmd: impl AsRef<OsStr>,359	) -> Result<MyCommand> {360		if self.local {361			Ok(MyCommand::new(escalation, cmd))362		} else {363			let session = self.open_session().await?;364			Ok(MyCommand::new_on(escalation, cmd, session))365		}366	}367	pub async fn nix_cmd(&self) -> Result<MyCommand> {368		let mut nix = self.cmd("nix").await?;369		nix.args([370			"--extra-experimental-features",371			"nix-command",372			"--extra-experimental-features",373			"flakes",374		]);375		Ok(nix)376	}377378	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {379		ensure!(data.encrypted, "secret is not encrypted");380		let mut cmd = self.cmd("fleet-install-secrets").await?;381		cmd.arg("decrypt").eqarg("--secret", data.to_string());382		let encoded = cmd383			.sudo()384			.run_string()385			.await386			.context("failed to call remote host for decrypt")?;387		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;388		ensure!(!data.encrypted, "secret came out encrypted");389		Ok(data.data)390	}391	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {392		ensure!(data.encrypted, "secret is not encrypted");393		let mut cmd = self.cmd("fleet-install-secrets").await?;394		cmd.arg("reencrypt").eqarg("--secret", data.to_string());395		for target in targets {396			let key = self.config.key(&target).await?;397			cmd.eqarg("--targets", key);398		}399		let encoded = cmd400			.sudo()401			.run_string()402			.await403			.context("failed to call remote host for decrypt")?;404		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;405		ensure!(data.encrypted, "secret came out not encrypted");406		Ok(data)407	}408	/// Returns path for futureproofing, as path might change i.e on conversion to CA409	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {410		if self.local {411			// Path is located locally, thus already trusted.412			return Ok(path.to_owned());413		}414		let mut nix = MyCommand::new(415			// Not used416			EscalationStrategy::Su,417			"nix",418		);419		nix.arg("copy").arg("--substitute-on-destination");420421		match self.deploy_kind().await? {422			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {423				nix.comparg("--to", format!("ssh-ng://{}", self.name));424			}425			DeployKind::NixosInstall => {426				nix427					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon428					.arg("--no-check-sigs")429					.comparg(430						"--to",431						format!("ssh-ng://root@{}?remote-store=/mnt", self.name),432					);433			}434		}435		nix.arg(path);436		nix.run_nix().await.context("nix copy")?;437		Ok(path.to_owned())438	}439	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {440		let mut cmd = self.cmd("systemctl").await?;441		cmd.arg("stop").arg(name);442		cmd.sudo().run().await443	}444	pub async fn systemctl_start(&self, name: &str) -> Result<()> {445		let mut cmd = self.cmd("systemctl").await?;446		cmd.arg("start").arg(name);447		cmd.sudo().run().await448	}449450	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {451		let mut cmd = self.cmd("rm").await?;452		cmd.arg("-f").arg(path);453		if sudo {454			cmd = cmd.sudo()455		}456		cmd.run().await457	}458}459impl ConfigHost {460	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,461	// assuming getting tags always returns the same value.462	pub async fn tags(&self) -> Result<Vec<String>> {463		if let Some(v) = self.groups.get() {464			return Ok(v.clone());465		}466		let Some(host_config) = &self.host_config else {467			return Ok(vec![]);468		};469		let tags: Vec<String> = nix_go_json!(host_config.tags);470471		let _ = self.groups.set(tags.clone());472473		Ok(tags)474	}475	pub async fn nixos_config(&self) -> Result<Value> {476		if let Some(v) = self.nixos_config.get() {477			return Ok(v.clone());478		}479		let Some(host_config) = &self.host_config else {480			bail!("local host has no nixos_config");481		};482		let nixos_config = nix_go!(host_config.nixos.config);483		assert_warn("nixos config evaluation", &nixos_config).await?;484485		let _ = self.nixos_config.set(nixos_config.clone());486487		Ok(nixos_config)488	}489	pub async fn nixos_unchecked_config(&self) -> Result<Value> {490		if let Some(v) = self.nixos_unchecked_config.get() {491			return Ok(v.clone());492		}493		let Some(host_config) = &self.host_config else {494			bail!("local host has no nixos_config");495		};496		let nixos_config = nix_go!(host_config.nixos_unchecked.config);497498		let _ = self.nixos_unchecked_config.set(nixos_config.clone());499500		Ok(nixos_config)501	}502503	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {504		let nixos = self.nixos_unchecked_config().await?;505		let secrets = nix_go!(nixos.secrets);506		let mut out = Vec::new();507		for name in secrets.list_fields()? {508			let secret = secrets.get_field(&name)?;509			let is_shared: bool = nix_go_json!(secret.shared);510			if is_shared {511				continue;512			}513			out.push(name);514		}515		Ok(out)516	}517	pub async fn secret_field(&self, name: &str) -> Result<Value> {518		let nixos = self.nixos_unchecked_config().await?;519		Ok(nix_go!(nixos.secrets[{ name }]))520	}521522	/// Packages for this host, resolved with nixpkgs overlays523	pub async fn pkgs(&self) -> Result<Value> {524		if let Some(value) = &self.pkgs_override {525			return Ok(value.clone());526		}527		let Some(host_config) = &self.host_config else {528			bail!("local host has no host_config");529		};530		// TODO: Should nixos.options be cached?531		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))532	}533}534535impl Config {536	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {537		let config = &self.config_field;538		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);539		Ok(tagged)540	}541	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {542		let mut out = BTreeSet::new();543		for owner in owners {544			if let Some(tag) = owner.strip_prefix('@') {545				let hosts = self.tagged_hostnames(tag).await?;546				out.extend(hosts);547			} else {548				out.insert(owner);549			}550		}551		Ok(out)552	}553	pub fn local_host(&self) -> ConfigHost {554		ConfigHost {555			config: self.clone(),556			name: "<virtual localhost>".to_owned(),557			host_config: None,558			nixos_config: OnceCell::new(),559			nixos_unchecked_config: OnceCell::new(),560			groups: {561				let cell = OnceCell::new();562				let _ = cell.set(vec![]);563				cell564			},565			pkgs_override: Some(self.default_pkgs.clone()),566567			local: true,568			session: OnceLock::new(),569			deploy_kind: OnceCell::new(),570			session_destination: 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			session_destination: OnceCell::new(),592		})593	}594	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {595		let config = &self.config_field;596		let names = nix_go!(config.hosts).list_fields()?;597		let mut out = vec![];598		for name in names {599			out.push(self.host(&name).await?);600		}601		Ok(out)602	}603	// TODO: Replace usages with .host().nixos_config604	pub async fn system_config(&self, host: &str) -> Result<Value> {605		let fleet_field = &self.config_field;606		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))607	}608609	/// Shared secrets configured in fleet.nix or in flake610	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {611		let config_field = &self.config_field;612		nix_go!(config_field.sharedSecrets).list_fields()613	}614	/// Shared secrets configured in fleet.nix615	pub fn list_shared(&self) -> Vec<String> {616		let data = self.data();617		data.shared_secrets.keys().cloned().collect()618	}619	pub fn has_shared(&self, name: &str) -> bool {620		let data = self.data();621		data.shared_secrets.contains_key(name)622	}623	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {624		let mut data = self.data_mut();625		data.shared_secrets.insert(name.to_owned(), shared);626	}627	pub fn remove_shared(&self, secret: &str) {628		let mut data = self.data_mut();629		data.shared_secrets.remove(secret);630	}631632	pub fn list_secrets(&self, host: &str) -> Vec<String> {633		let data = self.data();634		let Some(secrets) = data.host_secrets.get(host) else {635			return Vec::new();636		};637		secrets.keys().cloned().collect()638	}639640	pub fn has_secret(&self, host: &str, secret: &str) -> bool {641		let data = self.data();642		let Some(host_secrets) = data.host_secrets.get(host) else {643			return false;644		};645		host_secrets.contains_key(secret)646	}647	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {648		let mut data = self.data_mut();649		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();650		host_secrets.insert(secret, value);651	}652653	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {654		let data = self.data();655		let Some(host_secrets) = data.host_secrets.get(host) else {656			bail!("no secrets for machine {host}");657		};658		let Some(secret) = host_secrets.get(secret) else {659			bail!("machine {host} has no secret {secret}");660		};661		Ok(secret.clone())662	}663	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {664		let data = self.data();665		let Some(secret) = data.shared_secrets.get(secret) else {666			bail!("no shared secret {secret}");667		};668		Ok(secret.clone())669	}670	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {671		let config_field = &self.config_field;672		Ok(nix_go_json!(673			config_field.sharedSecrets[{ secret }].expectedOwners674		))675	}676677	// TODO: Should this be something modifiable from other processes?678	// E.g terraform provider might want to update FleetData (e.g secrets),679	// and current implementation assumes only one process holds current fleet.nix680	// Given that it is no longer needs to be a file for nix evaluation,681	// maybe it can be a .nix file for persistence, but accessible only682	// thru some shared state controller? Might it be stored in terraform683	// state provider?684	pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {685		self.data.lock().unwrap()686	}687	pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {688		self.data.lock().unwrap()689	}690	pub fn save(&self) -> Result<()> {691		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.")?;692		let data = nixlike::serialize(&self.data() as &FleetData)?;693		tempfile.write_all(694			format!(695				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"696			)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}
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::{Value, nix_go, nix_go_json, util::assert_warn};16use openssh::{ControlPersist, 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,44}4546// TODO: Make field not pub47#[derive(Clone)]48pub struct Config(pub Arc<FleetConfigInternals>);4950impl Deref for Config {51	type Target = FleetConfigInternals;5253	fn deref(&self) -> &Self::Target {54		&self.055	}56}5758#[derive(Clone, Copy, Debug)]59pub enum EscalationStrategy {60	Sudo,61	Run0,62	Su,63}6465#[derive(Clone, PartialEq, Copy, Debug)]66pub enum DeployKind {67	/// NixOS => NixOS managed by fleet68	UpgradeToFleet,69	/// NixOS managed by fleet => NixOS managed by fleet70	Fleet,71	/// Remote host has /mnt, /mnt/boot mounted,72	/// generated config is added to fleet configuration.73	NixosInstall,74	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),75	/// generated config is added to fleet configuration,76	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.77	NixosLustrate,78}7980impl FromStr for DeployKind {81	type Err = anyhow::Error;82	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {83		match s {84			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),85			"fleet" => Ok(Self::Fleet),86			"nixos-install" => Ok(Self::NixosInstall),87			"nixos-lustrate" => Ok(Self::NixosLustrate),88			v => bail!(89				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""90			),91		}92	}93}94pub struct ConfigHost {95	config: Config,96	pub name: String,97	groups: OnceCell<Vec<String>>,9899	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it100	deploy_kind: OnceCell<DeployKind>,101	session_destination: OnceCell<String>,102	legacy_ssh_store: OnceCell<bool>,103104	pub host_config: Option<Value>,105	pub nixos_config: OnceCell<Value>,106	pub nixos_unchecked_config: OnceCell<Value>,107	pub pkgs_override: Option<Value>,108109	// TODO: Move command helpers away with connectivity refactor110	pub local: bool,111	pub session: OnceLock<Arc<openssh::Session>>,112}113114#[derive(Debug, Clone, Copy)]115pub enum GenerationStorage {116	Deployer,117	Machine,118	Pusher,119}120impl GenerationStorage {121	fn prefix(&self) -> &'static str {122		match self {123			GenerationStorage::Deployer => "deployer.",124			GenerationStorage::Machine => "",125			GenerationStorage::Pusher => "pusher.",126		}127	}128}129130#[derive(Tabled, Debug)]131pub struct Generation {132	#[tabled(rename = "ID", format("{}", self.rollback_id()))]133	pub id: u32,134	#[tabled(rename = "Current")]135	pub current: bool,136	#[tabled(rename = "Created at")]137	pub datetime: UtcDateTime,138	#[tabled(format = "{:?}")]139	pub store_path: PathBuf,140	#[tabled(skip)]141	pub location: GenerationStorage,142}143impl Generation {144	pub fn rollback_id(&self) -> String {145		format!("{}{}", self.location.prefix(), self.id)146	}147}148149fn parse_generation_line(g: &str) -> Option<Generation> {150	let mut parts = g.split_whitespace();151	let id = parts.next()?;152	let id: u32 = id.parse().ok()?;153	let date = parts.next()?;154	let time = parts.next()?;155	let current = if let Some(current) = parts.next() {156		if current == "(current)" {157			Some(true)158		} else {159			None160		}161	} else {162		Some(false)163	};164	let current = current?;165	if parts.next().is_some() {166		warn!("unexpected text after generation: {g}");167	}168169	let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")170		.expect("valid format");171	let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;172173	Some(Generation {174		id,175		current,176		datetime,177		store_path: PathBuf::new(),178		location: GenerationStorage::Machine,179	})180}181// TODO: Move command helpers away with connectivity refactor182impl ConfigHost {183	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {184		let mut cmd = self.cmd("nix-env").await?;185		cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))186			.arg("--list-generations")187			.env("TZ", "UTC");188		// Sudo is required because --list-generations tries to acquire profile lock189		let data = cmd.sudo().run_string().await?;190		let mut generations = data191			.split('\n')192			.map(|e| e.trim())193			.filter(|&l| !l.is_empty())194			.filter_map(|g| {195				let generation = parse_generation_line(g);196				if generation.is_none() {197					warn!("bad generation: {g}");198				};199				generation200			})201			.collect::<Vec<_>>();202		for ele in generations.iter_mut() {203			let mut cmd = self.cmd("readlink").await?;204			cmd.arg("--")205				.arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));206			let path = cmd.run_string().await?;207			ele.store_path = PathBuf::from(path.trim_end_matches("\n"));208		}209210		Ok(generations)211	}212213	pub fn set_session_destination(&self, dest: String) {214		self.session_destination215			.set(dest)216			.expect("session destination is already set")217	}218	pub fn set_deploy_kind(&self, kind: DeployKind) {219		self.deploy_kind220			.set(kind)221			.expect("deploy kind is already set");222	}223	pub fn set_legacy_ssh_store(&self, legacy: bool) {224		self.legacy_ssh_store225			.set(legacy)226			.expect("legacy ssh store is already set")227	}228	pub async fn deploy_kind(&self) -> Result<DeployKind> {229		if let Some(kind) = self.deploy_kind.get() {230			return Ok(*kind);231		}232		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {233			Ok(v) => v,234			Err(e) => {235				bail!("failed to query remote system kind: {}", e);236			}237		};238		if !is_fleet_managed {239			bail!(240				"{}",241				indoc::indoc! {"242				host is not marked as managed by fleet243				if you're not trying to lustrate/install system from scratch,244				you should either245					1. manually create /etc/FLEET_HOST file on the target host,246					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet247					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos248			"}249			);250		}251		// TOCTOU is possible252		let _ = self.deploy_kind.set(DeployKind::Fleet);253		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))254	}255	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {256		// Prefer sudo, as run0 has some gotchas with polkit257		// and too many repeating prompts.258		if (self.find_in_path("sudo").await).is_ok() {259			return Ok(EscalationStrategy::Sudo);260		}261		if (self.find_in_path("run0").await).is_ok() {262			return Ok(EscalationStrategy::Run0);263		}264		Ok(EscalationStrategy::Su)265	}266	async fn open_session(&self) -> Result<Arc<openssh::Session>> {267		assert!(!self.local, "do not open ssh connection to local session");268		// FIXME: TOCTOU269		if let Some(session) = &self.session.get() {270			return Ok((*session).clone());271		};272		let mut session = SessionBuilder::default();273		session.control_persist(ControlPersist::ClosedAfterInitialConnection);274275		let dest = self.session_destination.get().unwrap_or(&self.name);276		let session = session277			.connect(&dest)278			.await279			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;280		let session = Arc::new(session);281		self.session.set(session.clone()).expect("TOCTOU happened");282		Ok(session)283	}284	pub async fn mktemp_dir(&self) -> Result<String> {285		let mut cmd = self.cmd("mktemp").await?;286		cmd.arg("-d");287		let path = cmd.run_string().await?;288		Ok(path.trim_end().to_owned())289	}290	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {291		let mut cmd = self.cmd("sh").await?;292		cmd.arg("-c")293			.arg("test -e \"$1\" && echo true || echo false")294			.arg("_")295			.arg(path);296		cmd.run_value().await297	}298	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {299		let mut cmd = self.cmd("cat").await?;300		cmd.arg(path);301		cmd.run_bytes().await302	}303	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {304		let mut cmd = self.cmd("cat").await?;305		cmd.arg(path);306		cmd.run_string().await307	}308	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {309		let mut cmd = self.cmd("ls").await?;310		cmd.arg(path);311		let out = cmd.run_string().await?;312		let mut lines = out.split('\n');313		if let Some(last) = lines.next_back() {314			ensure!(last.is_empty(), "output of ls should end with newline");315		}316		Ok(lines.map(ToOwned::to_owned).collect())317	}318	#[allow(dead_code)]319	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {320		let text = self.read_file_text(path).await?;321		Ok(serde_json::from_str(&text)?)322	}323	pub async fn read_env(&self, env: &str) -> Result<String> {324		let mut cmd = self.cmd("printenv").await?;325		cmd.arg(env);326		cmd.run_string().await327	}328	pub async fn find_in_path(&self, command: &str) -> Result<String> {329		// // `which` is not a part of coreutils, and it might not exist on machine.330		// let path = self.read_env("PATH").await?;331		// // Assuming delimiter is :, we don't work with windows host, this check will be much332		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)333		// for ele in path.split(':') {334		// 	let test_path = format!("{ele}/{cmd}");335		// 	test -x etc336		// }337		// let mut cmd = self.cmd("printenv").await?;338		// cmd.arg(env);339		// Ok(cmd.run_string().await?)340		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.341		let mut cmd = self342			.cmd_escalation(343				// Not used344				EscalationStrategy::Su,345				"which",346			)347			.await?;348		cmd.arg(command);349		cmd.run_string().await350	}351	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>352	where353		<D as FromStr>::Err: Display,354	{355		let text = self.read_file_text(path).await?;356		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))357	}358	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {359		self.cmd_escalation(self.escalation_strategy().await?, cmd)360			.await361	}362	pub async fn cmd_escalation(363		&self,364		escalation: EscalationStrategy,365		cmd: impl AsRef<OsStr>,366	) -> Result<MyCommand> {367		if self.local {368			Ok(MyCommand::new(escalation, cmd))369		} else {370			let session = self.open_session().await?;371			Ok(MyCommand::new_on(escalation, cmd, session))372		}373	}374	pub async fn nix_cmd(&self) -> Result<MyCommand> {375		let mut nix = self.cmd("nix").await?;376		nix.args([377			"--extra-experimental-features",378			"nix-command",379			"--extra-experimental-features",380			"flakes",381		]);382		Ok(nix)383	}384385	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {386		ensure!(data.encrypted, "secret is not encrypted");387		let mut cmd = self.cmd("fleet-install-secrets").await?;388		cmd.arg("decrypt").eqarg("--secret", data.to_string());389		let encoded = cmd390			.sudo()391			.run_string()392			.await393			.context("failed to call remote host for decrypt")?;394		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;395		ensure!(!data.encrypted, "secret came out encrypted");396		Ok(data.data)397	}398	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {399		ensure!(data.encrypted, "secret is not encrypted");400		let mut cmd = self.cmd("fleet-install-secrets").await?;401		cmd.arg("reencrypt").eqarg("--secret", data.to_string());402		for target in targets {403			let key = self.config.key(&target).await?;404			cmd.eqarg("--targets", key);405		}406		let encoded = cmd407			.sudo()408			.run_string()409			.await410			.context("failed to call remote host for decrypt")?;411		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;412		ensure!(data.encrypted, "secret came out not encrypted");413		Ok(data)414	}415	/// Returns path for futureproofing, as path might change i.e on conversion to CA416	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {417		if self.local {418			// Path is located locally, thus already trusted.419			return Ok(path.to_owned());420		}421		let mut nix = MyCommand::new(422			// Not used423			EscalationStrategy::Su,424			"nix",425		);426		nix.arg("copy").arg("--substitute-on-destination");427428		let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {429			"ssh"430		} else {431			"ssh-ng"432		};433434		match self.deploy_kind().await? {435			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {436				nix.comparg("--to", format!("{proto}://{}", self.name));437			}438			DeployKind::NixosInstall => {439				nix440					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon441					.arg("--no-check-sigs")442					.comparg(443						"--to",444						format!("{proto}://root@{}?remote-store=/mnt", self.name),445					);446			}447		}448		nix.arg(path);449		nix.run_nix().await.context("nix copy")?;450		Ok(path.to_owned())451	}452	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {453		let mut cmd = self.cmd("systemctl").await?;454		cmd.arg("stop").arg(name);455		cmd.sudo().run().await456	}457	pub async fn systemctl_start(&self, name: &str) -> Result<()> {458		let mut cmd = self.cmd("systemctl").await?;459		cmd.arg("start").arg(name);460		cmd.sudo().run().await461	}462463	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {464		let mut cmd = self.cmd("rm").await?;465		cmd.arg("-f").arg(path);466		if sudo {467			cmd = cmd.sudo()468		}469		cmd.run().await470	}471}472impl ConfigHost {473	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,474	// assuming getting tags always returns the same value.475	pub async fn tags(&self) -> Result<Vec<String>> {476		if let Some(v) = self.groups.get() {477			return Ok(v.clone());478		}479		let Some(host_config) = &self.host_config else {480			return Ok(vec![]);481		};482		let tags: Vec<String> = nix_go_json!(host_config.tags);483484		let _ = self.groups.set(tags.clone());485486		Ok(tags)487	}488	pub async fn nixos_config(&self) -> Result<Value> {489		if let Some(v) = self.nixos_config.get() {490			return Ok(v.clone());491		}492		let Some(host_config) = &self.host_config else {493			bail!("local host has no nixos_config");494		};495		let nixos_config = nix_go!(host_config.nixos.config);496		assert_warn("nixos config evaluation", &nixos_config).await?;497498		let _ = self.nixos_config.set(nixos_config.clone());499500		Ok(nixos_config)501	}502	pub async fn nixos_unchecked_config(&self) -> Result<Value> {503		if let Some(v) = self.nixos_unchecked_config.get() {504			return Ok(v.clone());505		}506		let Some(host_config) = &self.host_config else {507			bail!("local host has no nixos_config");508		};509		let nixos_config = nix_go!(host_config.nixos_unchecked.config);510511		let _ = self.nixos_unchecked_config.set(nixos_config.clone());512513		Ok(nixos_config)514	}515516	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {517		let nixos = self.nixos_unchecked_config().await?;518		let secrets = nix_go!(nixos.secrets);519		let mut out = Vec::new();520		for name in secrets.list_fields()? {521			let secret = secrets.get_field(&name)?;522			let is_shared: bool = nix_go_json!(secret.shared);523			if is_shared {524				continue;525			}526			out.push(name);527		}528		Ok(out)529	}530	pub async fn secret_field(&self, name: &str) -> Result<Value> {531		let nixos = self.nixos_unchecked_config().await?;532		Ok(nix_go!(nixos.secrets[{ name }]))533	}534535	/// Packages for this host, resolved with nixpkgs overlays536	pub async fn pkgs(&self) -> Result<Value> {537		if let Some(value) = &self.pkgs_override {538			return Ok(value.clone());539		}540		let Some(host_config) = &self.host_config else {541			bail!("local host has no host_config");542		};543		// TODO: Should nixos.options be cached?544		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))545	}546}547548impl Config {549	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {550		let config = &self.config_field;551		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);552		Ok(tagged)553	}554	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {555		let mut out = BTreeSet::new();556		for owner in owners {557			if let Some(tag) = owner.strip_prefix('@') {558				let hosts = self.tagged_hostnames(tag).await?;559				out.extend(hosts);560			} else {561				out.insert(owner);562			}563		}564		Ok(out)565	}566	pub fn local_host(&self) -> ConfigHost {567		ConfigHost {568			config: self.clone(),569			name: "<virtual localhost>".to_owned(),570			host_config: None,571			nixos_config: OnceCell::new(),572			nixos_unchecked_config: OnceCell::new(),573			groups: {574				let cell = OnceCell::new();575				let _ = cell.set(vec![]);576				cell577			},578			pkgs_override: Some(self.default_pkgs.clone()),579580			local: true,581			session: OnceLock::new(),582			deploy_kind: OnceCell::new(),583			session_destination: OnceCell::new(),584			legacy_ssh_store: OnceCell::new(),585		}586	}587588	pub async fn host(&self, name: &str) -> Result<ConfigHost> {589		let config = &self.config_field;590		let host_config = nix_go!(config.hosts[{ name }]);591592		Ok(ConfigHost {593			config: self.clone(),594			name: name.to_owned(),595			host_config: Some(host_config),596			nixos_config: OnceCell::new(),597			nixos_unchecked_config: OnceCell::new(),598			groups: OnceCell::new(),599			pkgs_override: None,600601			// TODO: Remove with connectivit refactor602			local: self.localhost == name,603			session: OnceLock::new(),604			deploy_kind: OnceCell::new(),605			session_destination: OnceCell::new(),606			legacy_ssh_store: OnceCell::new(),607		})608	}609	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {610		let config = &self.config_field;611		let names = nix_go!(config.hosts).list_fields()?;612		let mut out = vec![];613		for name in names {614			out.push(self.host(&name).await?);615		}616		Ok(out)617	}618	// TODO: Replace usages with .host().nixos_config619	pub async fn system_config(&self, host: &str) -> Result<Value> {620		let fleet_field = &self.config_field;621		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))622	}623624	/// Shared secrets configured in fleet.nix or in flake625	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {626		let config_field = &self.config_field;627		nix_go!(config_field.sharedSecrets).list_fields()628	}629	/// Shared secrets configured in fleet.nix630	pub fn list_shared(&self) -> Vec<String> {631		let data = self.data();632		data.shared_secrets.keys().cloned().collect()633	}634	pub fn has_shared(&self, name: &str) -> bool {635		let data = self.data();636		data.shared_secrets.contains_key(name)637	}638	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {639		let mut data = self.data_mut();640		data.shared_secrets.insert(name.to_owned(), shared);641	}642	pub fn remove_shared(&self, secret: &str) {643		let mut data = self.data_mut();644		data.shared_secrets.remove(secret);645	}646647	pub fn list_secrets(&self, host: &str) -> Vec<String> {648		let data = self.data();649		let Some(secrets) = data.host_secrets.get(host) else {650			return Vec::new();651		};652		secrets.keys().cloned().collect()653	}654655	pub fn has_secret(&self, host: &str, secret: &str) -> bool {656		let data = self.data();657		let Some(host_secrets) = data.host_secrets.get(host) else {658			return false;659		};660		host_secrets.contains_key(secret)661	}662	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {663		let mut data = self.data_mut();664		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();665		host_secrets.insert(secret, value);666	}667668	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {669		let data = self.data();670		let Some(host_secrets) = data.host_secrets.get(host) else {671			bail!("no secrets for machine {host}");672		};673		let Some(secret) = host_secrets.get(secret) else {674			bail!("machine {host} has no secret {secret}");675		};676		Ok(secret.clone())677	}678	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {679		let data = self.data();680		let Some(secret) = data.shared_secrets.get(secret) else {681			bail!("no shared secret {secret}");682		};683		Ok(secret.clone())684	}685	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {686		let config_field = &self.config_field;687		Ok(nix_go_json!(688			config_field.sharedSecrets[{ secret }].expectedOwners689		))690	}691692	// TODO: Should this be something modifiable from other processes?693	// E.g terraform provider might want to update FleetData (e.g secrets),694	// and current implementation assumes only one process holds current fleet.nix695	// Given that it is no longer needs to be a file for nix evaluation,696	// maybe it can be a .nix file for persistence, but accessible only697	// thru some shared state controller? Might it be stored in terraform698	// state provider?699	pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {700		self.data.lock().unwrap()701	}702	pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {703		self.data.lock().unwrap()704	}705	pub fn save(&self) -> Result<()> {706		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.")?;707		let data = nixlike::serialize(&self.data() as &FleetData)?;708		tempfile.write_all(709			format!(710				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"711			)712			.as_bytes(),713		)?;714		let mut fleet_data_path = self.directory.clone();715		fleet_data_path.push("fleet.nix");716		tempfile.persist(fleet_data_path)?;717		Ok(())718	}719}
modifiedcrates/fleet-shared/src/encoding.rsdiffbeforeafterboth
--- a/crates/fleet-shared/src/encoding.rs
+++ b/crates/fleet-shared/src/encoding.rs
@@ -1,6 +1,5 @@
 use std::{
-	fmt::{self, Display},
-	str::FromStr,
+	collections::BTreeMap, fmt::{self, Display}, str::FromStr
 };
 
 use base64::engine::{Engine, general_purpose::STANDARD_NO_PAD};
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -168,12 +168,9 @@
                 cargo-fuzz
                 cargo-watch
                 cargo-outdated
-                gdb
 
                 pkg-config
                 openssl
-                bacon
-                nil
                 rustPlatform.bindgenHook
                 inputs'.nix.packages.nix-expr-c
                 inputs'.nix.packages.nix-flake-c