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

difftreelog

fix do not require wildcard with callPackage

Yaroslav Bolyukin2024-12-08parent: #3b8059d.patch.diff
in: trunk

6 files changed

modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -265,13 +265,14 @@
 	let generator = nix_go!(secret.generator);
 	let on: Option<String> = nix_go_json!(default_generator.impureOn);
 
+	let nixpkgs = &config.nixpkgs;
+
 	let host = if let Some(on) = &on {
 		config.host(on).await?
 	} else {
 		config.local_host()
 	};
 	let on_pkgs = host.pkgs().await?;
-	let call_package = nix_go!(on_pkgs.callPackage);
 	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);
 
 	let mut recipients = Vec::new();
@@ -280,8 +281,11 @@
 		recipients.push(key);
 	}
 	let generators = nix_go!(mk_secret_generators(Obj { recipients }));
+	let pkgs_and_generators = nix_go!(on_pkgs + generators);
+
+	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));
 
-	let generator = nix_go!(call_package(generator)(generators));
+	let generator = nix_go!(call_package(generator)(Obj {}));
 
 	let generator = generator.build_maybe_batch(batch).await?;
 	let generator = generator
@@ -353,8 +357,8 @@
 			bail!("generator should be lambda, got {gen_ty}");
 		}
 	}
+	let nixpkgs = &config.nixpkgs;
 	let default_pkgs = &config.default_pkgs;
-	let default_call_package = nix_go!(default_pkgs.callPackage);
 	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);
 	// Generators provide additional information in passthru, to access
 	// passthru we should call generator, but information about where this generator is supposed to build
@@ -367,7 +371,10 @@
 	let generators = nix_go!(default_mk_secret_generators(Obj {
 		recipients: <Vec<String>>::new(),
 	}));
-	let default_generator = nix_go!(default_call_package(generator)(generators));
+	let pkgs_and_generators = nix_go!(default_pkgs + generators);
+
+	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));
+	let default_generator = nix_go!(call_package(generator)(Obj {}));
 
 	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);
 
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::{anyhow, bail, ensure, Context, Result};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tempfile::NamedTempFile;1920use crate::{21	command::MyCommand,22	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23};2425pub struct FleetConfigInternals {26	pub local_system: String,27	pub directory: PathBuf,28	pub data: Mutex<FleetData>,29	pub nix_args: Vec<OsString>,30	/// fleet_config.config31	pub config_field: Value,32	// TODO: Remove with connectivity refactor33	pub localhost: String,3435	/// import nixpkgs {system = local};36	pub default_pkgs: Value,3738	pub nix_session: NixSession,39}4041// TODO: Make field not pub42#[derive(Clone)]43pub struct Config(pub Arc<FleetConfigInternals>);4445impl Deref for Config {46	type Target = FleetConfigInternals;4748	fn deref(&self) -> &Self::Target {49		&self.050	}51}5253#[derive(Clone, Copy, Debug)]54pub enum EscalationStrategy {55	Sudo,56	Run0,57	Su,58}5960pub struct ConfigHost {61	config: Config,62	pub name: String,63	groups: OnceCell<Vec<String>>,6465	pub host_config: Option<Value>,66	pub nixos_config: OnceCell<Value>,67	pub pkgs_override: Option<Value>,6869	// TODO: Move command helpers away with connectivity refactor70	pub local: bool,71	pub session: OnceLock<Arc<openssh::Session>>,72}73// TODO: Move command helpers away with connectivity refactor74impl ConfigHost {75	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {76		// Prefer sudo, as run0 has some gotchas with polkit77		// and too many repeating prompts.78		if (self.find_in_path("sudo").await).is_ok() {79			return Ok(EscalationStrategy::Sudo);80		}81		if (self.find_in_path("run0").await).is_ok() {82			return Ok(EscalationStrategy::Run0);83		}84		Ok(EscalationStrategy::Su)85	}86	async fn open_session(&self) -> Result<Arc<openssh::Session>> {87		assert!(!self.local, "do not open ssh connection to local session");88		// FIXME: TOCTOU89		if let Some(session) = &self.session.get() {90			return Ok((*session).clone());91		};92		let session = SessionBuilder::default();93		let session = session94			.connect(&self.name)95			.await96			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;97		let session = Arc::new(session);98		self.session.set(session.clone()).expect("TOCTOU happened");99		Ok(session)100	}101	pub async fn mktemp_dir(&self) -> Result<String> {102		let mut cmd = self.cmd("mktemp").await?;103		cmd.arg("-d");104		let path = cmd.run_string().await?;105		Ok(path.trim_end().to_owned())106	}107	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {108		let mut cmd = self.cmd("cat").await?;109		cmd.arg(path);110		cmd.run_bytes().await111	}112	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {113		let mut cmd = self.cmd("cat").await?;114		cmd.arg(path);115		cmd.run_string().await116	}117	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {118		let mut cmd = self.cmd("ls").await?;119		cmd.arg(path);120		let out = cmd.run_string().await?;121		let mut lines = out.split('\n');122		if let Some(last) = lines.next_back() {123			ensure!(last.is_empty(), "output of ls should end with newline");124		}125		Ok(lines.map(ToOwned::to_owned).collect())126	}127	#[allow(dead_code)]128	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {129		let text = self.read_file_text(path).await?;130		Ok(serde_json::from_str(&text)?)131	}132	pub async fn read_env(&self, env: &str) -> Result<String> {133		let mut cmd = self.cmd("printenv").await?;134		cmd.arg(env);135		cmd.run_string().await136	}137	pub async fn find_in_path(&self, command: &str) -> Result<String> {138		// // `which` is not a part of coreutils, and it might not exist on machine.139		// let path = self.read_env("PATH").await?;140		// // Assuming delimiter is :, we don't work with windows host, this check will be much141		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)142		// for ele in path.split(':') {143		// 	let test_path = format!("{ele}/{cmd}");144		// 	test -x etc145		// }146		// let mut cmd = self.cmd("printenv").await?;147		// cmd.arg(env);148		// Ok(cmd.run_string().await?)149		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.150		let mut cmd = self151			.cmd_escalation(152				// Not used153				EscalationStrategy::Su,154				"which",155			)156			.await?;157		cmd.arg(command);158		cmd.run_string().await159	}160	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>161	where162		<D as FromStr>::Err: Display,163	{164		let text = self.read_file_text(path).await?;165		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))166	}167	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {168		self.cmd_escalation(self.escalation_strategy().await?, cmd)169			.await170	}171	pub async fn cmd_escalation(172		&self,173		escalation: EscalationStrategy,174		cmd: impl AsRef<OsStr>,175	) -> Result<MyCommand> {176		if self.local {177			Ok(MyCommand::new(escalation, cmd))178		} else {179			let session = self.open_session().await?;180			Ok(MyCommand::new_on(escalation, cmd, session))181		}182	}183184	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {185		ensure!(data.encrypted, "secret is not encrypted");186		let mut cmd = self.cmd("fleet-install-secrets").await?;187		cmd.arg("decrypt").eqarg("--secret", data.to_string());188		let encoded = cmd189			.sudo()190			.run_string()191			.await192			.context("failed to call remote host for decrypt")?;193		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;194		ensure!(!data.encrypted, "secret came out encrypted");195		Ok(data.data)196	}197	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {198		ensure!(data.encrypted, "secret is not encrypted");199		let mut cmd = self.cmd("fleet-install-secrets").await?;200		cmd.arg("reencrypt").eqarg("--secret", data.to_string());201		for target in targets {202			let key = self.config.key(&target).await?;203			cmd.eqarg("--targets", key);204		}205		let encoded = cmd206			.sudo()207			.run_string()208			.await209			.context("failed to call remote host for decrypt")?;210		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;211		ensure!(data.encrypted, "secret came out not encrypted");212		Ok(data)213	}214	/// Returns path for futureproofing, as path might change i.e on conversion to CA215	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {216		if self.local {217			// Path is located locally, thus already trusted.218			return Ok(path.to_owned());219		}220		let mut nix = MyCommand::new(221			// Not used222			EscalationStrategy::Su,223			"nix",224		);225		nix.arg("copy")226			.arg("--substitute-on-destination")227			.comparg("--to", format!("ssh-ng://{}", self.name))228			.arg(path);229		nix.run_nix().await.context("nix copy")?;230		Ok(path.to_owned())231	}232	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {233		let mut cmd = self.cmd("systemctl").await?;234		cmd.arg("stop").arg(name);235		cmd.sudo().run().await236	}237	pub async fn systemctl_start(&self, name: &str) -> Result<()> {238		let mut cmd = self.cmd("systemctl").await?;239		cmd.arg("start").arg(name);240		cmd.sudo().run().await241	}242243	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {244		let mut cmd = self.cmd("rm").await?;245		cmd.arg("-f").arg(path);246		if sudo {247			cmd = cmd.sudo()248		}249		cmd.run().await250	}251}252impl ConfigHost {253	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,254	// assuming getting tags always returns the same value.255	pub async fn tags(&self) -> Result<Vec<String>> {256		if let Some(v) = self.groups.get() {257			return Ok(v.clone());258		}259		let Some(host_config) = &self.host_config else {260			return Ok(vec![]);261		};262		let tags: Vec<String> = nix_go_json!(host_config.tags);263264		let _ = self.groups.set(tags.clone());265266		Ok(tags)267	}268	pub async fn nixos_config(&self) -> Result<Value> {269		if let Some(v) = self.nixos_config.get() {270			return Ok(v.clone());271		}272		let Some(host_config) = &self.host_config else {273			bail!("local host has no nixos_config");274		};275		let nixos_config = nix_go!(host_config.nixos.config);276		assert_warn("nixos config evaluation", &nixos_config).await?;277278		let _ = self.nixos_config.set(nixos_config.clone());279280		Ok(nixos_config)281	}282283	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {284		let nixos = self.nixos_config().await?;285		let secrets = nix_go!(nixos.secrets);286		let mut out = Vec::new();287		for name in secrets.list_fields().await? {288			let secret = nix_go!(secrets[{ name }]);289			let is_shared: bool = nix_go_json!(secret.shared);290			if is_shared {291				continue;292			}293			out.push(name);294		}295		Ok(out)296	}297	pub async fn secret_field(&self, name: &str) -> Result<Value> {298		let nixos = self.nixos_config().await?;299		Ok(nix_go!(nixos.secrets[{ name }]))300	}301302	/// Packages for this host, resolved with nixpkgs overlays303	pub async fn pkgs(&self) -> Result<Value> {304		if let Some(value) = &self.pkgs_override {305			return Ok(value.clone());306		}307		let Some(host_config) = &self.host_config else {308			bail!("local host has no host_config");309		};310		// TODO: Should nixos.options be cached?311		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))312	}313}314315impl Config {316	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {317		let config = &self.config_field;318		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);319		Ok(tagged)320	}321	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {322		let mut out = BTreeSet::new();323		for owner in owners {324			if let Some(tag) = owner.strip_prefix('@') {325				let hosts = self.tagged_hostnames(tag).await?;326				out.extend(hosts);327			} else {328				out.insert(owner);329			}330		}331		Ok(out)332	}333	pub fn local_host(&self) -> ConfigHost {334		ConfigHost {335			config: self.clone(),336			name: "<virtual localhost>".to_owned(),337			host_config: None,338			nixos_config: OnceCell::new(),339			groups: {340				let cell = OnceCell::new();341				let _ = cell.set(vec![]);342				cell343			},344			pkgs_override: Some(self.default_pkgs.clone()),345346			local: true,347			session: OnceLock::new(),348		}349	}350351	pub async fn host(&self, name: &str) -> Result<ConfigHost> {352		let config = &self.config_field;353		let host_config = nix_go!(config.hosts[{ name }]);354355		Ok(ConfigHost {356			config: self.clone(),357			name: name.to_owned(),358			host_config: Some(host_config),359			nixos_config: OnceCell::new(),360			groups: OnceCell::new(),361			pkgs_override: None,362363			// TODO: Remove with connectivit refactor364			local: self.localhost == name,365			session: OnceLock::new(),366		})367	}368	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {369		let config = &self.config_field;370		let names = nix_go!(config.hosts).list_fields().await?;371		let mut out = vec![];372		for name in names {373			out.push(self.host(&name).await?);374		}375		Ok(out)376	}377	// TODO: Replace usages with .host().nixos_config378	pub async fn system_config(&self, host: &str) -> Result<Value> {379		let fleet_field = &self.config_field;380		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))381	}382383	/// Shared secrets configured in fleet.nix or in flake384	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {385		let config_field = &self.config_field;386		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)387	}388	/// Shared secrets configured in fleet.nix389	pub fn list_shared(&self) -> Vec<String> {390		let data = self.data();391		data.shared_secrets.keys().cloned().collect()392	}393	pub fn has_shared(&self, name: &str) -> bool {394		let data = self.data();395		data.shared_secrets.contains_key(name)396	}397	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {398		let mut data = self.data_mut();399		data.shared_secrets.insert(name.to_owned(), shared);400	}401	pub fn remove_shared(&self, secret: &str) {402		let mut data = self.data_mut();403		data.shared_secrets.remove(secret);404	}405406	pub fn list_secrets(&self, host: &str) -> Vec<String> {407		let data = self.data();408		let Some(secrets) = data.host_secrets.get(host) else {409			return Vec::new();410		};411		secrets.keys().cloned().collect()412	}413414	pub fn has_secret(&self, host: &str, secret: &str) -> bool {415		let data = self.data();416		let Some(host_secrets) = data.host_secrets.get(host) else {417			return false;418		};419		host_secrets.contains_key(secret)420	}421	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {422		let mut data = self.data_mut();423		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();424		host_secrets.insert(secret, value);425	}426427	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {428		let data = self.data();429		let Some(host_secrets) = data.host_secrets.get(host) else {430			bail!("no secrets for machine {host}");431		};432		let Some(secret) = host_secrets.get(secret) else {433			bail!("machine {host} has no secret {secret}");434		};435		Ok(secret.clone())436	}437	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {438		let data = self.data();439		let Some(secret) = data.shared_secrets.get(secret) else {440			bail!("no shared secret {secret}");441		};442		Ok(secret.clone())443	}444	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {445		let config_field = &self.config_field;446		Ok(nix_go_json!(447			config_field.sharedSecrets[{ secret }].expectedOwners448		))449	}450451	// TODO: Should this be something modifiable from other processes?452	// E.g terraform provider might want to update FleetData (e.g secrets),453	// and current implementation assumes only one process holds current fleet.nix454	// Given that it is no longer needs to be a file for nix evaluation,455	// maybe it can be a .nix file for persistence, but accessible only456	// thru some shared state controller? Might it be stored in terraform457	// state provider?458	pub fn data(&self) -> MutexGuard<FleetData> {459		self.data.lock().unwrap()460	}461	pub fn data_mut(&self) -> MutexGuard<FleetData> {462		self.data.lock().unwrap()463	}464	pub fn save(&self) -> Result<()> {465		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.")?;466		let data = nixlike::serialize(&self.data() as &FleetData)?;467		tempfile.write_all(468			format!(469				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",470				data471			)472			.as_bytes(),473		)?;474		let mut fleet_data_path = self.directory.clone();475		fleet_data_path.push("fleet.nix");476		tempfile.persist(fleet_data_path)?;477		Ok(())478	}479}
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::{anyhow, bail, ensure, Context, Result};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tempfile::NamedTempFile;1920use crate::{21	command::MyCommand,22	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23};2425pub struct FleetConfigInternals {26	pub local_system: String,27	pub directory: PathBuf,28	pub data: Mutex<FleetData>,29	pub nix_args: Vec<OsString>,30	/// fleet_config.config31	pub config_field: Value,32	// TODO: Remove with connectivity refactor33	pub localhost: String,3435	/// import nixpkgs {system = local};36	pub default_pkgs: Value,37	pub nixpkgs: Value,3839	pub nix_session: NixSession,40}4142// TODO: Make field not pub43#[derive(Clone)]44pub struct Config(pub Arc<FleetConfigInternals>);4546impl Deref for Config {47	type Target = FleetConfigInternals;4849	fn deref(&self) -> &Self::Target {50		&self.051	}52}5354#[derive(Clone, Copy, Debug)]55pub enum EscalationStrategy {56	Sudo,57	Run0,58	Su,59}6061pub struct ConfigHost {62	config: Config,63	pub name: String,64	groups: OnceCell<Vec<String>>,6566	pub host_config: Option<Value>,67	pub nixos_config: OnceCell<Value>,68	pub pkgs_override: Option<Value>,6970	// TODO: Move command helpers away with connectivity refactor71	pub local: bool,72	pub session: OnceLock<Arc<openssh::Session>>,73}74// TODO: Move command helpers away with connectivity refactor75impl ConfigHost {76	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {77		// Prefer sudo, as run0 has some gotchas with polkit78		// and too many repeating prompts.79		if (self.find_in_path("sudo").await).is_ok() {80			return Ok(EscalationStrategy::Sudo);81		}82		if (self.find_in_path("run0").await).is_ok() {83			return Ok(EscalationStrategy::Run0);84		}85		Ok(EscalationStrategy::Su)86	}87	async fn open_session(&self) -> Result<Arc<openssh::Session>> {88		assert!(!self.local, "do not open ssh connection to local session");89		// FIXME: TOCTOU90		if let Some(session) = &self.session.get() {91			return Ok((*session).clone());92		};93		let session = SessionBuilder::default();94		let session = session95			.connect(&self.name)96			.await97			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;98		let session = Arc::new(session);99		self.session.set(session.clone()).expect("TOCTOU happened");100		Ok(session)101	}102	pub async fn mktemp_dir(&self) -> Result<String> {103		let mut cmd = self.cmd("mktemp").await?;104		cmd.arg("-d");105		let path = cmd.run_string().await?;106		Ok(path.trim_end().to_owned())107	}108	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {109		let mut cmd = self.cmd("cat").await?;110		cmd.arg(path);111		cmd.run_bytes().await112	}113	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {114		let mut cmd = self.cmd("cat").await?;115		cmd.arg(path);116		cmd.run_string().await117	}118	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {119		let mut cmd = self.cmd("ls").await?;120		cmd.arg(path);121		let out = cmd.run_string().await?;122		let mut lines = out.split('\n');123		if let Some(last) = lines.next_back() {124			ensure!(last.is_empty(), "output of ls should end with newline");125		}126		Ok(lines.map(ToOwned::to_owned).collect())127	}128	#[allow(dead_code)]129	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {130		let text = self.read_file_text(path).await?;131		Ok(serde_json::from_str(&text)?)132	}133	pub async fn read_env(&self, env: &str) -> Result<String> {134		let mut cmd = self.cmd("printenv").await?;135		cmd.arg(env);136		cmd.run_string().await137	}138	pub async fn find_in_path(&self, command: &str) -> Result<String> {139		// // `which` is not a part of coreutils, and it might not exist on machine.140		// let path = self.read_env("PATH").await?;141		// // Assuming delimiter is :, we don't work with windows host, this check will be much142		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)143		// for ele in path.split(':') {144		// 	let test_path = format!("{ele}/{cmd}");145		// 	test -x etc146		// }147		// let mut cmd = self.cmd("printenv").await?;148		// cmd.arg(env);149		// Ok(cmd.run_string().await?)150		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.151		let mut cmd = self152			.cmd_escalation(153				// Not used154				EscalationStrategy::Su,155				"which",156			)157			.await?;158		cmd.arg(command);159		cmd.run_string().await160	}161	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>162	where163		<D as FromStr>::Err: Display,164	{165		let text = self.read_file_text(path).await?;166		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))167	}168	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {169		self.cmd_escalation(self.escalation_strategy().await?, cmd)170			.await171	}172	pub async fn cmd_escalation(173		&self,174		escalation: EscalationStrategy,175		cmd: impl AsRef<OsStr>,176	) -> Result<MyCommand> {177		if self.local {178			Ok(MyCommand::new(escalation, cmd))179		} else {180			let session = self.open_session().await?;181			Ok(MyCommand::new_on(escalation, cmd, session))182		}183	}184185	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {186		ensure!(data.encrypted, "secret is not encrypted");187		let mut cmd = self.cmd("fleet-install-secrets").await?;188		cmd.arg("decrypt").eqarg("--secret", data.to_string());189		let encoded = cmd190			.sudo()191			.run_string()192			.await193			.context("failed to call remote host for decrypt")?;194		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;195		ensure!(!data.encrypted, "secret came out encrypted");196		Ok(data.data)197	}198	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {199		ensure!(data.encrypted, "secret is not encrypted");200		let mut cmd = self.cmd("fleet-install-secrets").await?;201		cmd.arg("reencrypt").eqarg("--secret", data.to_string());202		for target in targets {203			let key = self.config.key(&target).await?;204			cmd.eqarg("--targets", key);205		}206		let encoded = cmd207			.sudo()208			.run_string()209			.await210			.context("failed to call remote host for decrypt")?;211		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;212		ensure!(data.encrypted, "secret came out not encrypted");213		Ok(data)214	}215	/// Returns path for futureproofing, as path might change i.e on conversion to CA216	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {217		if self.local {218			// Path is located locally, thus already trusted.219			return Ok(path.to_owned());220		}221		let mut nix = MyCommand::new(222			// Not used223			EscalationStrategy::Su,224			"nix",225		);226		nix.arg("copy")227			.arg("--substitute-on-destination")228			.comparg("--to", format!("ssh-ng://{}", self.name))229			.arg(path);230		nix.run_nix().await.context("nix copy")?;231		Ok(path.to_owned())232	}233	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {234		let mut cmd = self.cmd("systemctl").await?;235		cmd.arg("stop").arg(name);236		cmd.sudo().run().await237	}238	pub async fn systemctl_start(&self, name: &str) -> Result<()> {239		let mut cmd = self.cmd("systemctl").await?;240		cmd.arg("start").arg(name);241		cmd.sudo().run().await242	}243244	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {245		let mut cmd = self.cmd("rm").await?;246		cmd.arg("-f").arg(path);247		if sudo {248			cmd = cmd.sudo()249		}250		cmd.run().await251	}252}253impl ConfigHost {254	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,255	// assuming getting tags always returns the same value.256	pub async fn tags(&self) -> Result<Vec<String>> {257		if let Some(v) = self.groups.get() {258			return Ok(v.clone());259		}260		let Some(host_config) = &self.host_config else {261			return Ok(vec![]);262		};263		let tags: Vec<String> = nix_go_json!(host_config.tags);264265		let _ = self.groups.set(tags.clone());266267		Ok(tags)268	}269	pub async fn nixos_config(&self) -> Result<Value> {270		if let Some(v) = self.nixos_config.get() {271			return Ok(v.clone());272		}273		let Some(host_config) = &self.host_config else {274			bail!("local host has no nixos_config");275		};276		let nixos_config = nix_go!(host_config.nixos.config);277		assert_warn("nixos config evaluation", &nixos_config).await?;278279		let _ = self.nixos_config.set(nixos_config.clone());280281		Ok(nixos_config)282	}283284	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {285		let nixos = self.nixos_config().await?;286		let secrets = nix_go!(nixos.secrets);287		let mut out = Vec::new();288		for name in secrets.list_fields().await? {289			let secret = nix_go!(secrets[{ name }]);290			let is_shared: bool = nix_go_json!(secret.shared);291			if is_shared {292				continue;293			}294			out.push(name);295		}296		Ok(out)297	}298	pub async fn secret_field(&self, name: &str) -> Result<Value> {299		let nixos = self.nixos_config().await?;300		Ok(nix_go!(nixos.secrets[{ name }]))301	}302303	/// Packages for this host, resolved with nixpkgs overlays304	pub async fn pkgs(&self) -> Result<Value> {305		if let Some(value) = &self.pkgs_override {306			return Ok(value.clone());307		}308		let Some(host_config) = &self.host_config else {309			bail!("local host has no host_config");310		};311		// TODO: Should nixos.options be cached?312		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))313	}314}315316impl Config {317	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {318		let config = &self.config_field;319		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);320		Ok(tagged)321	}322	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {323		let mut out = BTreeSet::new();324		for owner in owners {325			if let Some(tag) = owner.strip_prefix('@') {326				let hosts = self.tagged_hostnames(tag).await?;327				out.extend(hosts);328			} else {329				out.insert(owner);330			}331		}332		Ok(out)333	}334	pub fn local_host(&self) -> ConfigHost {335		ConfigHost {336			config: self.clone(),337			name: "<virtual localhost>".to_owned(),338			host_config: None,339			nixos_config: OnceCell::new(),340			groups: {341				let cell = OnceCell::new();342				let _ = cell.set(vec![]);343				cell344			},345			pkgs_override: Some(self.default_pkgs.clone()),346347			local: true,348			session: OnceLock::new(),349		}350	}351352	pub async fn host(&self, name: &str) -> Result<ConfigHost> {353		let config = &self.config_field;354		let host_config = nix_go!(config.hosts[{ name }]);355356		Ok(ConfigHost {357			config: self.clone(),358			name: name.to_owned(),359			host_config: Some(host_config),360			nixos_config: OnceCell::new(),361			groups: OnceCell::new(),362			pkgs_override: None,363364			// TODO: Remove with connectivit refactor365			local: self.localhost == name,366			session: OnceLock::new(),367		})368	}369	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {370		let config = &self.config_field;371		let names = nix_go!(config.hosts).list_fields().await?;372		let mut out = vec![];373		for name in names {374			out.push(self.host(&name).await?);375		}376		Ok(out)377	}378	// TODO: Replace usages with .host().nixos_config379	pub async fn system_config(&self, host: &str) -> Result<Value> {380		let fleet_field = &self.config_field;381		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))382	}383384	/// Shared secrets configured in fleet.nix or in flake385	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {386		let config_field = &self.config_field;387		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)388	}389	/// Shared secrets configured in fleet.nix390	pub fn list_shared(&self) -> Vec<String> {391		let data = self.data();392		data.shared_secrets.keys().cloned().collect()393	}394	pub fn has_shared(&self, name: &str) -> bool {395		let data = self.data();396		data.shared_secrets.contains_key(name)397	}398	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {399		let mut data = self.data_mut();400		data.shared_secrets.insert(name.to_owned(), shared);401	}402	pub fn remove_shared(&self, secret: &str) {403		let mut data = self.data_mut();404		data.shared_secrets.remove(secret);405	}406407	pub fn list_secrets(&self, host: &str) -> Vec<String> {408		let data = self.data();409		let Some(secrets) = data.host_secrets.get(host) else {410			return Vec::new();411		};412		secrets.keys().cloned().collect()413	}414415	pub fn has_secret(&self, host: &str, secret: &str) -> bool {416		let data = self.data();417		let Some(host_secrets) = data.host_secrets.get(host) else {418			return false;419		};420		host_secrets.contains_key(secret)421	}422	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {423		let mut data = self.data_mut();424		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();425		host_secrets.insert(secret, value);426	}427428	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {429		let data = self.data();430		let Some(host_secrets) = data.host_secrets.get(host) else {431			bail!("no secrets for machine {host}");432		};433		let Some(secret) = host_secrets.get(secret) else {434			bail!("machine {host} has no secret {secret}");435		};436		Ok(secret.clone())437	}438	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {439		let data = self.data();440		let Some(secret) = data.shared_secrets.get(secret) else {441			bail!("no shared secret {secret}");442		};443		Ok(secret.clone())444	}445	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {446		let config_field = &self.config_field;447		Ok(nix_go_json!(448			config_field.sharedSecrets[{ secret }].expectedOwners449		))450	}451452	// TODO: Should this be something modifiable from other processes?453	// E.g terraform provider might want to update FleetData (e.g secrets),454	// and current implementation assumes only one process holds current fleet.nix455	// Given that it is no longer needs to be a file for nix evaluation,456	// maybe it can be a .nix file for persistence, but accessible only457	// thru some shared state controller? Might it be stored in terraform458	// state provider?459	pub fn data(&self) -> MutexGuard<FleetData> {460		self.data.lock().unwrap()461	}462	pub fn data_mut(&self) -> MutexGuard<FleetData> {463		self.data.lock().unwrap()464	}465	pub fn save(&self) -> Result<()> {466		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.")?;467		let data = nixlike::serialize(&self.data() as &FleetData)?;468		tempfile.write_all(469			format!(470				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",471				data472			)473			.as_bytes(),474		)?;475		let mut fleet_data_path = self.directory.clone();476		fleet_data_path.push("fleet.nix");477		tempfile.persist(fleet_data_path)?;478		Ok(())479	}480}
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -225,6 +225,7 @@
 			nix_args,
 			config_field,
 			default_pkgs,
+			nixpkgs,
 			localhost: self.localhost.to_owned(),
 		})))
 	}
modifiedcrates/nix-eval/src/macros.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/macros.rs
+++ b/crates/nix-eval/src/macros.rs
@@ -231,6 +231,9 @@
 	(@o($o:ident) | $($var:tt)*) => {
 		$o.push(Index::Pipe($crate::nix_expr_inner!($($var)+)));
 	};
+	(@o($o:ident) + $($var:tt)*) => {
+		$o.push(Index::Merge($crate::nix_expr_inner!($($var)+)));
+	};
 	(@o($o:ident)) => {};
 	($field:ident $($tt:tt)+) => {{
 		use $crate::{nix_go, Index};
modifiedcrates/nix-eval/src/value.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/value.rs
+++ b/crates/nix-eval/src/value.rs
@@ -15,6 +15,7 @@
 	Expr(NixExprBuilder),
 	ExprApply(NixExprBuilder),
 	Pipe(NixExprBuilder),
+	Merge(NixExprBuilder),
 }
 impl Index {
 	pub fn var(v: impl AsRef<str>) -> Self {
@@ -56,6 +57,9 @@
 			Index::Pipe(e) => {
 				write!(f, "<map>({})", e.out)
 			}
+			Index::Merge(e) => {
+				write!(f, "//({})", e.out)
+			}
 		}
 	}
 }
@@ -157,6 +161,12 @@
 					let index = format!("sess_field_{}", index.0.value.expect("value"));
 					query = format!("({index} {query})");
 				}
+				Index::Merge(v) => {
+					let index = Value::new(self.0.session.clone(), &v.out).await?;
+					used_fields.push(index.clone());
+					let index = format!("sess_field_{}", index.0.value.expect("value"));
+					query = format!("({query} // {index})");
+				}
 			}
 		}
 
modifiedlib/default.nixdiffbeforeafterboth
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -46,7 +46,6 @@
     mkPassword = {size ? 32}: {
       coreutils,
       mkSecretGenerator,
-      ...
     }:
       mkSecretGenerator {
         script = ''
@@ -58,7 +57,7 @@
     mkEd25519 = {
       noEmbedPublic ? false,
       encoding ? null,
-    }: {mkSecretGenerator, ...}:
+    }: {mkSecretGenerator}:
       mkSecretGenerator {
         script = ''
           mkdir $out
@@ -68,7 +67,7 @@
         '';
       };
 
-    mkX25519 = {encoding ? null}: {mkSecretGenerator, ...}:
+    mkX25519 = {encoding ? null}: {mkSecretGenerator}:
       mkSecretGenerator {
         script = ''
           mkdir $out
@@ -80,7 +79,6 @@
     mkRsa = {size ? 4096}: {
       openssl,
       mkSecretGenerator,
-      ...
     }:
       mkSecretGenerator {
         script = ''
@@ -98,7 +96,7 @@
       count ? 32,
       encoding,
       noNuls ? false,
-    }: {mkSecretGenerator, ...}:
+    }: {mkSecretGenerator}:
       mkSecretGenerator {
         script = ''
           mkdir $out