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

difftreelog

feat ability to select specialisation to activate

Yaroslav Bolyukin2024-07-24parent: #d9fb30d.patch.diff
in: trunk

6 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -784,7 +784,7 @@
  "itertools",
  "nix-eval",
  "nixlike",
- "once_cell",
+ "nom",
  "openssh",
  "owo-colors",
  "peg",
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -19,7 +19,6 @@
 serde_json.workspace = true
 tempfile.workspace = true
 time = { version = "0.3", features = ["serde"] }
-once_cell = "1.19"
 hostname = "0.4.0"
 age-core = "0.10"
 peg = "0.8"
@@ -45,6 +44,7 @@
 human-repr = { version = "1.1", optional = true }
 indicatif = { version = "0.17", optional = true }
 nix-eval.workspace = true
+nom = "7.1.3"
 
 [features]
 # Not quite stable
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -126,6 +126,7 @@
 	action: DeployAction,
 	host: &ConfigHost,
 	built: PathBuf,
+	specialisation: Option<String>,
 	disable_rollback: bool,
 ) -> Result<()> {
 	let mut failed = false;
@@ -190,9 +191,14 @@
 	if action.should_activate() && !failed {
 		let _span = info_span!("activating").entered();
 		info!("executing activation script");
-		let mut switch_script = built.clone();
-		switch_script.push("bin");
-		switch_script.push("switch-to-configuration");
+		let specialised = if let Some(specialisation) = specialisation {
+			let mut specialised = built.join("specialisation");
+			specialised.push(specialisation);
+			specialised
+		} else {
+			built.clone()
+		};
+		let switch_script = specialised.join("bin/switch-to-configuration");
 		let mut cmd = host.cmd(switch_script).in_current_span().await?;
 		cmd.arg(action.name().expect("upload.should_activate == false"));
 		if let Err(e) = cmd.sudo().run().in_current_span().await {
@@ -255,12 +261,11 @@
 			.system
 			.build[{ build_attr }]
 	);
-	let outputs = drv.build().await.map_err(|e| {
+	let outputs = drv.build().await.inspect_err(|_| {
 			if build_attr == "sdImage" {
 				info!("sd-image build failed");
 				info!("Make sure you have imported modulesPath/installer/sd-card/sd-image-<arch>[-installer].nix (For installer, you may want to check config)");
 			}
-			e
 		})?;
 	let out_output = outputs
 		.get("out")
@@ -275,7 +280,7 @@
 		let set = LocalSet::new();
 		let build_attr = self.build_attr.clone();
 		for host in hosts.into_iter() {
-			if config.should_skip(&host.name) {
+			if config.should_skip(&host).await? {
 				continue;
 			}
 			let config = config.clone();
@@ -324,7 +329,7 @@
 		let hosts = config.list_hosts().await?;
 		let set = LocalSet::new();
 		for host in hosts.into_iter() {
-			if config.should_skip(&host.name) {
+			if config.should_skip(&host).await? {
 				continue;
 			}
 			let config = config.clone();
@@ -379,8 +384,19 @@
 							}
 						}
 					}
-					if let Err(e) =
-						deploy_task(self.action, &host, built, self.disable_rollback).await
+					if let Err(e) = deploy_task(
+						self.action,
+						&host,
+						built,
+						if let Ok(v) = config.action_attr(&host, "specialisation").await {
+							v
+						} else {
+							error!("unreachable? failed to get specialization");
+							return;
+						},
+						self.disable_rollback,
+					)
+					.await
 					{
 						error!("activation failed: {e}");
 					}
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -436,7 +436,7 @@
 		match self {
 			Secret::ForceKeys => {
 				for host in config.list_hosts().await? {
-					if config.should_skip(&host.name) {
+					if config.should_skip(&host).await? {
 						continue;
 					}
 					config.key(&host.name).await?;
@@ -639,7 +639,7 @@
 					}
 				}
 				for host in config.list_hosts().await? {
-					if config.should_skip(&host.name) {
+					if config.should_skip(&host).await? {
 						continue;
 					}
 
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
before · cmds/fleet/src/host.rs
1use std::{2	env::current_dir,3	ffi::{OsStr, OsString},4	fmt::Display,5	io::Write,6	ops::Deref,7	path::PathBuf,8	str::FromStr,9	sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, ensure, Context, Result};13use clap::{ArgGroup, Parser};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, NixSessionPool, 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 opts: FleetOpts,29	pub data: Mutex<FleetData>,30	pub nix_args: Vec<OsString>,31	/// fleet_config.config32	pub config_field: Value,33	/// fleet_config.unchecked.config34	pub config_unchecked_field: Value,3536	/// import nixpkgs {system = local};37	pub default_pkgs: Value,38}3940#[derive(Clone)]41pub struct Config(Arc<FleetConfigInternals>);4243impl Deref for Config {44	type Target = FleetConfigInternals;4546	fn deref(&self) -> &Self::Target {47		&self.048	}49}5051pub struct ConfigHost {52	config: Config,53	pub name: String,54	pub local: bool,55	pub session: OnceLock<Arc<openssh::Session>>,5657	pub nixos_config: Option<Value>,58}59impl ConfigHost {60	async fn open_session(&self) -> Result<Arc<openssh::Session>> {61		assert!(!self.local, "do not open ssh connection to local session");62		// FIXME: TOCTOU63		if let Some(session) = &self.session.get() {64			return Ok((*session).clone());65		};66		let session = SessionBuilder::default();6768		let session = session69			.connect(&self.name)70			.await71			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;72		let session = Arc::new(session);73		self.session.set(session.clone()).expect("TOCTOU happened");74		Ok(session)75	}76	pub async fn mktemp_dir(&self) -> Result<String> {77		let mut cmd = self.cmd("mktemp").await?;78		cmd.arg("-d");79		let path = cmd.run_string().await?;80		Ok(path.trim_end().to_owned())81	}82	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {83		let mut cmd = self.cmd("cat").await?;84		cmd.arg(path);85		cmd.run_bytes().await86	}87	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {88		let mut cmd = self.cmd("cat").await?;89		cmd.arg(path);90		cmd.run_string().await91	}92	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {93		let mut cmd = self.cmd("ls").await?;94		cmd.arg(path);95		let out = cmd.run_string().await?;96		let mut lines = out.split('\n');97		if let Some(last) = lines.next_back() {98			ensure!(last.is_empty(), "output of ls should end with newline");99		}100		Ok(lines.map(ToOwned::to_owned).collect())101	}102	#[allow(dead_code)]103	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {104		let text = self.read_file_text(path).await?;105		Ok(serde_json::from_str(&text)?)106	}107	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>108	where109		<D as FromStr>::Err: Display,110	{111		let text = self.read_file_text(path).await?;112		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))113	}114	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {115		if self.local {116			Ok(MyCommand::new(cmd))117		} else {118			let session = self.open_session().await?;119			Ok(MyCommand::new_on(cmd, session))120		}121	}122123	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {124		ensure!(data.encrypted, "secret is not encrypted");125		let mut cmd = self.cmd("fleet-install-secrets").await?;126		cmd.arg("decrypt").eqarg("--secret", data.to_string());127		let encoded = cmd128			.sudo()129			.run_string()130			.await131			.context("failed to call remote host for decrypt")?;132		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;133		ensure!(!data.encrypted, "secret came out encrypted");134		Ok(data.data)135	}136	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {137		ensure!(data.encrypted, "secret is not encrypted");138		let mut cmd = self.cmd("fleet-install-secrets").await?;139		cmd.arg("reencrypt").eqarg("--secret", data.to_string());140		for target in targets {141			let key = self.config.key(&target).await?;142			cmd.eqarg("--targets", key);143		}144		let encoded = cmd145			.sudo()146			.run_string()147			.await148			.context("failed to call remote host for decrypt")?;149		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;150		ensure!(data.encrypted, "secret came out not encrypted");151		Ok(data)152	}153	/// Returns path for futureproofing, as path might change i.e on conversion to CA154	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {155		if self.local {156			// Path is located locally, thus already trusted.157			return Ok(path.to_owned());158		}159		let mut nix = MyCommand::new("nix");160		nix.arg("copy")161			.arg("--substitute-on-destination")162			.comparg("--to", format!("ssh-ng://{}", self.name))163			.arg(path);164		nix.run_nix().await.context("nix copy")?;165		Ok(path.to_owned())166	}167	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {168		let mut cmd = self.cmd("systemctl").await?;169		cmd.arg("stop").arg(name);170		cmd.sudo().run().await171	}172	pub async fn systemctl_start(&self, name: &str) -> Result<()> {173		let mut cmd = self.cmd("systemctl").await?;174		cmd.arg("start").arg(name);175		cmd.sudo().run().await176	}177178	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {179		let mut cmd = self.cmd("rm").await?;180		cmd.arg("-f").arg(path);181		if sudo {182			cmd = cmd.sudo()183		}184		cmd.run().await185	}186187	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {188		let Some(nixos) = &self.nixos_config else {189			return Ok(vec![]);190		};191		let secrets = nix_go!(nixos.secrets);192		let mut out = Vec::new();193		for name in secrets.list_fields().await? {194			let secret = nix_go!(secrets[{ name }]);195			let is_shared: bool = nix_go_json!(secret.shared);196			if is_shared {197				continue;198			}199			out.push(name);200		}201		Ok(out)202	}203	pub async fn secret_field(&self, name: &str) -> Result<Value> {204		let Some(nixos) = &self.nixos_config else {205			bail!("host is virtual and has no secrets");206		};207		Ok(nix_go!(nixos.secrets[{ name }]))208	}209210	/// Packages for this host, resolved with nixpkgs overlays211	pub async fn pkgs(&self) -> Result<Value> {212		let Some(nixos) = &self.nixos_config else {213			return Ok(self.config.default_pkgs.clone());214		};215		Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))216	}217}218219impl Config {220	pub fn should_skip(&self, host: &str) -> bool {221		if !self.opts.skip.is_empty() {222			self.opts.skip.iter().any(|h| h as &str == host)223		} else if !self.opts.only.is_empty() {224			!self.opts.only.iter().any(|h| h as &str == host)225		} else {226			false227		}228	}229	pub fn is_local(&self, host: &str) -> bool {230		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)231	}232233	pub fn local_host(&self) -> ConfigHost {234		ConfigHost {235			config: self.clone(),236			name: "<virtual localhost>".to_owned(),237			local: true,238			session: OnceLock::new(),239			nixos_config: None,240		}241	}242243	pub async fn host(&self, name: &str) -> Result<ConfigHost> {244		let config = &self.config_unchecked_field;245		let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);246		Ok(ConfigHost {247			config: self.clone(),248			name: name.to_owned(),249			local: self.is_local(name),250			session: OnceLock::new(),251			nixos_config: Some(nixos_config),252		})253	}254	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {255		let config = &self.config_unchecked_field;256		let names = nix_go!(config.hosts).list_fields().await?;257		let mut out = vec![];258		for name in names {259			out.push(self.host(&name).await?);260		}261		Ok(out)262	}263	pub async fn system_config(&self, host: &str) -> Result<Value> {264		let fleet_field = &self.config_unchecked_field;265		Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))266	}267268	pub(super) fn data(&self) -> MutexGuard<FleetData> {269		self.data.lock().unwrap()270	}271	pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {272		self.data.lock().unwrap()273	}274	/// Shared secrets configured in fleet.nix or in flake275	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {276		let config_field = &self.config_unchecked_field;277		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)278	}279	/// Shared secrets configured in fleet.nix280	pub fn list_shared(&self) -> Vec<String> {281		let data = self.data();282		data.shared_secrets.keys().cloned().collect()283	}284	pub fn has_shared(&self, name: &str) -> bool {285		let data = self.data();286		data.shared_secrets.contains_key(name)287	}288	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {289		let mut data = self.data_mut();290		data.shared_secrets.insert(name.to_owned(), shared);291	}292	pub fn remove_shared(&self, secret: &str) {293		let mut data = self.data_mut();294		data.shared_secrets.remove(secret);295	}296297	pub fn list_secrets(&self, host: &str) -> Vec<String> {298		let data = self.data();299		let Some(secrets) = data.host_secrets.get(host) else {300			return Vec::new();301		};302		secrets.keys().cloned().collect()303	}304305	pub fn has_secret(&self, host: &str, secret: &str) -> bool {306		let data = self.data();307		let Some(host_secrets) = data.host_secrets.get(host) else {308			return false;309		};310		host_secrets.contains_key(secret)311	}312	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {313		let mut data = self.data_mut();314		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();315		host_secrets.insert(secret, value);316	}317318	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {319		let data = self.data();320		let Some(host_secrets) = data.host_secrets.get(host) else {321			bail!("no secrets for machine {host}");322		};323		let Some(secret) = host_secrets.get(secret) else {324			bail!("machine {host} has no secret {secret}");325		};326		Ok(secret.clone())327	}328	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {329		let data = self.data();330		let Some(secret) = data.shared_secrets.get(secret) else {331			bail!("no shared secret {secret}");332		};333		Ok(secret.clone())334	}335	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {336		let config_field = &self.config_unchecked_field;337		Ok(nix_go_json!(338			config_field.sharedSecrets[{ secret }].expectedOwners339		))340	}341342	pub fn save(&self) -> Result<()> {343		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.")?;344		let data = nixlike::serialize(&self.data() as &FleetData)?;345		tempfile.write_all(346			format!(347				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",348				data349			)350			.as_bytes(),351		)?;352		let mut fleet_data_path = self.directory.clone();353		fleet_data_path.push("fleet.nix");354		tempfile.persist(fleet_data_path)?;355		Ok(())356	}357}358359#[derive(Parser, Clone)]360#[clap(group = ArgGroup::new("target_hosts"))]361pub struct FleetOpts {362	/// All hosts except those would be skipped363	#[clap(long, number_of_values = 1, group = "target_hosts")]364	only: Vec<String>,365366	/// Hosts to skip367	#[clap(long, number_of_values = 1, group = "target_hosts")]368	skip: Vec<String>,369370	/// Host, which should be threaten as current machine371	#[clap(long)]372	pub localhost: Option<String>,373374	/// Override detected system for host, to perform builds via375	/// binfmt-declared qemu instead of trying to crosscompile376	#[clap(long, default_value = "detect")]377	pub local_system: String,378}379380impl FleetOpts {381	pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {382		if self.localhost.is_none() {383			self.localhost384				.replace(hostname::get().unwrap().to_str().unwrap().to_owned());385		}386		let directory = current_dir()?;387388		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;389		let root_field = pool.get().await?;390391		let builtins_field = Value::binding(root_field.clone(), "builtins").await?;392		if self.local_system == "detect" {393			self.local_system = nix_go_json!(builtins_field.currentSystem);394		}395		let local_system = self.local_system.clone();396397		let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;398		let fleet_field = nix_go!(fleet_root.default);399400		let config_field = nix_go!(fleet_field.config);401		let config_unchecked_field = nix_go!(fleet_field.unchecked.config);402403		let import = nix_go!(builtins_field.import);404		let overlays = nix_go!(config_unchecked_field.overlays);405		let nixpkgs = nix_go!(fleet_field.nixpkgs | import);406407		let default_pkgs = nix_go!(nixpkgs(Obj {408			overlays,409			system: { self.local_system.clone() },410		}));411412		let mut fleet_data_path = directory.clone();413		fleet_data_path.push("fleet.nix");414		let bytes = std::fs::read_to_string(fleet_data_path)?;415		let data = nixlike::parse_str(&bytes)?;416417		Ok(Config(Arc::new(FleetConfigInternals {418			opts: self,419			directory,420			data,421			local_system,422			nix_args,423			config_field,424			config_unchecked_field,425			default_pkgs,426		})))427	}428}
after · cmds/fleet/src/host.rs
1use std::{2	cell::OnceCell,3	collections::BTreeMap,4	env::current_dir,5	ffi::{OsStr, OsString},6	fmt::Display,7	io::Write,8	ops::Deref,9	path::PathBuf,10	str::FromStr,11	sync::{Arc, Mutex, MutexGuard, OnceLock},12};1314use anyhow::{anyhow, bail, ensure, Context, Result};15use clap::Parser;16use fleet_shared::SecretData;17use nix_eval::{nix_go, nix_go_json, NixSessionPool, Value};18use nom::{19	bytes::complete::take_while1,20	character::complete::char,21	combinator::{map, opt},22	multi::separated_list1,23	sequence::{preceded, separated_pair},24};25use openssh::SessionBuilder;26use serde::de::DeserializeOwned;27use tempfile::NamedTempFile;2829use crate::{30	command::MyCommand,31	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},32};3334pub struct FleetConfigInternals {35	pub local_system: String,36	pub directory: PathBuf,37	pub opts: FleetOpts,38	pub data: Mutex<FleetData>,39	pub nix_args: Vec<OsString>,40	/// fleet_config.config41	pub config_field: Value,42	/// fleet_config.unchecked.config43	pub config_unchecked_field: Value,4445	/// import nixpkgs {system = local};46	pub default_pkgs: Value,47}4849#[derive(Clone)]50pub struct Config(Arc<FleetConfigInternals>);5152impl Deref for Config {53	type Target = FleetConfigInternals;5455	fn deref(&self) -> &Self::Target {56		&self.057	}58}5960pub struct ConfigHost {61	config: Config,62	pub name: String,63	pub local: bool,64	pub session: OnceLock<Arc<openssh::Session>>,65	groups: OnceCell<Vec<String>>,6667	pub nixos_config: Option<Value>,68}69impl ConfigHost {70	pub async fn tags(&self) -> Result<Vec<String>> {71		if let Some(v) = self.groups.get() {72			return Ok(v.clone());73		}74		// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,75		// assuming getting tags always returns the same value.76		let Some(nixos_config) = &self.nixos_config else {77			return Ok(vec![]);78		};79		let tags: Vec<String> = nix_go_json!(nixos_config.tags);8081		let _ = self.groups.set(tags.clone());8283		Ok(tags)84	}85	async fn open_session(&self) -> Result<Arc<openssh::Session>> {86		assert!(!self.local, "do not open ssh connection to local session");87		// FIXME: TOCTOU88		if let Some(session) = &self.session.get() {89			return Ok((*session).clone());90		};91		let session = SessionBuilder::default();9293		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_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>133	where134		<D as FromStr>::Err: Display,135	{136		let text = self.read_file_text(path).await?;137		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))138	}139	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {140		if self.local {141			Ok(MyCommand::new(cmd))142		} else {143			let session = self.open_session().await?;144			Ok(MyCommand::new_on(cmd, session))145		}146	}147148	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {149		ensure!(data.encrypted, "secret is not encrypted");150		let mut cmd = self.cmd("fleet-install-secrets").await?;151		cmd.arg("decrypt").eqarg("--secret", data.to_string());152		let encoded = cmd153			.sudo()154			.run_string()155			.await156			.context("failed to call remote host for decrypt")?;157		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;158		ensure!(!data.encrypted, "secret came out encrypted");159		Ok(data.data)160	}161	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {162		ensure!(data.encrypted, "secret is not encrypted");163		let mut cmd = self.cmd("fleet-install-secrets").await?;164		cmd.arg("reencrypt").eqarg("--secret", data.to_string());165		for target in targets {166			let key = self.config.key(&target).await?;167			cmd.eqarg("--targets", key);168		}169		let encoded = cmd170			.sudo()171			.run_string()172			.await173			.context("failed to call remote host for decrypt")?;174		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;175		ensure!(data.encrypted, "secret came out not encrypted");176		Ok(data)177	}178	/// Returns path for futureproofing, as path might change i.e on conversion to CA179	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {180		if self.local {181			// Path is located locally, thus already trusted.182			return Ok(path.to_owned());183		}184		let mut nix = MyCommand::new("nix");185		nix.arg("copy")186			.arg("--substitute-on-destination")187			.comparg("--to", format!("ssh-ng://{}", self.name))188			.arg(path);189		nix.run_nix().await.context("nix copy")?;190		Ok(path.to_owned())191	}192	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {193		let mut cmd = self.cmd("systemctl").await?;194		cmd.arg("stop").arg(name);195		cmd.sudo().run().await196	}197	pub async fn systemctl_start(&self, name: &str) -> Result<()> {198		let mut cmd = self.cmd("systemctl").await?;199		cmd.arg("start").arg(name);200		cmd.sudo().run().await201	}202203	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {204		let mut cmd = self.cmd("rm").await?;205		cmd.arg("-f").arg(path);206		if sudo {207			cmd = cmd.sudo()208		}209		cmd.run().await210	}211212	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {213		let Some(nixos) = &self.nixos_config else {214			return Ok(vec![]);215		};216		let secrets = nix_go!(nixos.secrets);217		let mut out = Vec::new();218		for name in secrets.list_fields().await? {219			let secret = nix_go!(secrets[{ name }]);220			let is_shared: bool = nix_go_json!(secret.shared);221			if is_shared {222				continue;223			}224			out.push(name);225		}226		Ok(out)227	}228	pub async fn secret_field(&self, name: &str) -> Result<Value> {229		let Some(nixos) = &self.nixos_config else {230			bail!("host is virtual and has no secrets");231		};232		Ok(nix_go!(nixos.secrets[{ name }]))233	}234235	/// Packages for this host, resolved with nixpkgs overlays236	pub async fn pkgs(&self) -> Result<Value> {237		let Some(nixos) = &self.nixos_config else {238			return Ok(self.config.default_pkgs.clone());239		};240		Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))241	}242}243244impl Config {245	pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {246		if !self.opts.skip.is_empty() && self.opts.skip.iter().any(|h| h as &str == host.name) {247			return Ok(true);248		}249		if self.opts.only.is_empty() {250			return Ok(false);251		}252		let mut have_group_matches = false;253		for item in self.opts.only.iter() {254			match item {255				HostItem::Host { name, .. } if *name == host.name => {256					return Ok(false);257				}258				HostItem::Tag { .. } => {259					have_group_matches = true;260				}261				_ => {}262			}263		}264		if have_group_matches {265			let host_tags = host.tags().await?;266			for item in self.opts.only.iter() {267				match item {268					HostItem::Tag { name, .. } if host_tags.contains(name) => {269						return Ok(false);270					}271					_ => {}272				}273			}274		}275		Ok(true)276	}277	pub async fn action_attr(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {278		if self.opts.only.is_empty() {279			return Ok(None);280		}281		let mut have_group_matches = false;282		for item in self.opts.only.iter() {283			match item {284				HostItem::Host { name, attrs }285					if *name == host.name && attrs.contains_key(attr) =>286				{287					return Ok(attrs.get(attr).cloned());288				}289				HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {290					have_group_matches = true;291				}292				_ => {}293			}294		}295		if have_group_matches {296			let host_tags = host.tags().await?;297			for item in self.opts.only.iter() {298				match item {299					HostItem::Tag { name, attrs }300						if host_tags.contains(name) && attrs.contains_key(attr) =>301					{302						return Ok(attrs.get(attr).cloned());303					}304					_ => {}305				}306			}307		}308		Ok(None)309	}310	pub fn is_local(&self, host: &str) -> bool {311		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)312	}313314	pub fn local_host(&self) -> ConfigHost {315		ConfigHost {316			config: self.clone(),317			name: "<virtual localhost>".to_owned(),318			local: true,319			session: OnceLock::new(),320			nixos_config: None,321			groups: {322				let cell = OnceCell::new();323				let _ = cell.set(vec![]);324				cell325			},326		}327	}328329	pub async fn host(&self, name: &str) -> Result<ConfigHost> {330		let config = &self.config_unchecked_field;331		let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);332		Ok(ConfigHost {333			config: self.clone(),334			name: name.to_owned(),335			local: self.is_local(name),336			session: OnceLock::new(),337			nixos_config: Some(nixos_config),338			groups: OnceCell::new(),339		})340	}341	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {342		let config = &self.config_unchecked_field;343		let names = nix_go!(config.hosts).list_fields().await?;344		let mut out = vec![];345		for name in names {346			out.push(self.host(&name).await?);347		}348		Ok(out)349	}350	pub async fn system_config(&self, host: &str) -> Result<Value> {351		let fleet_field = &self.config_unchecked_field;352		Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))353	}354355	pub(super) fn data(&self) -> MutexGuard<FleetData> {356		self.data.lock().unwrap()357	}358	pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {359		self.data.lock().unwrap()360	}361	/// Shared secrets configured in fleet.nix or in flake362	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {363		let config_field = &self.config_unchecked_field;364		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)365	}366	/// Shared secrets configured in fleet.nix367	pub fn list_shared(&self) -> Vec<String> {368		let data = self.data();369		data.shared_secrets.keys().cloned().collect()370	}371	pub fn has_shared(&self, name: &str) -> bool {372		let data = self.data();373		data.shared_secrets.contains_key(name)374	}375	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {376		let mut data = self.data_mut();377		data.shared_secrets.insert(name.to_owned(), shared);378	}379	pub fn remove_shared(&self, secret: &str) {380		let mut data = self.data_mut();381		data.shared_secrets.remove(secret);382	}383384	pub fn list_secrets(&self, host: &str) -> Vec<String> {385		let data = self.data();386		let Some(secrets) = data.host_secrets.get(host) else {387			return Vec::new();388		};389		secrets.keys().cloned().collect()390	}391392	pub fn has_secret(&self, host: &str, secret: &str) -> bool {393		let data = self.data();394		let Some(host_secrets) = data.host_secrets.get(host) else {395			return false;396		};397		host_secrets.contains_key(secret)398	}399	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {400		let mut data = self.data_mut();401		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();402		host_secrets.insert(secret, value);403	}404405	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {406		let data = self.data();407		let Some(host_secrets) = data.host_secrets.get(host) else {408			bail!("no secrets for machine {host}");409		};410		let Some(secret) = host_secrets.get(secret) else {411			bail!("machine {host} has no secret {secret}");412		};413		Ok(secret.clone())414	}415	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {416		let data = self.data();417		let Some(secret) = data.shared_secrets.get(secret) else {418			bail!("no shared secret {secret}");419		};420		Ok(secret.clone())421	}422	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {423		let config_field = &self.config_unchecked_field;424		Ok(nix_go_json!(425			config_field.sharedSecrets[{ secret }].expectedOwners426		))427	}428429	pub fn save(&self) -> Result<()> {430		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.")?;431		let data = nixlike::serialize(&self.data() as &FleetData)?;432		tempfile.write_all(433			format!(434				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",435				data436			)437			.as_bytes(),438		)?;439		let mut fleet_data_path = self.directory.clone();440		fleet_data_path.push("fleet.nix");441		tempfile.persist(fleet_data_path)?;442		Ok(())443	}444}445446#[derive(Clone)]447enum HostItem {448	Host {449		name: String,450		attrs: BTreeMap<String, String>,451	},452	Tag {453		name: String,454		attrs: BTreeMap<String, String>,455	},456}457fn host_item_parser(input: &str) -> Result<HostItem, String> {458	fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {459		err.to_string()460	}461462	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;463	let (input, name) = map(464		take_while1(|v| v != ',' && v != '?' && v != '@'),465		str::to_owned,466	)(input)467	.map_err(err_to_string)?;468469	let kw_item = separated_pair(470		map(take_while1(|v| v != '&' && v != '='), str::to_owned),471		char('='),472		map(take_while1(|v| v != '&'), str::to_owned),473	);474	let kw = map(separated_list1(char('&'), kw_item), |vec| {475		vec.into_iter().collect::<BTreeMap<_, _>>()476	});477	let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);478479	let (input, attrs) = opt_kw(input).map_err(err_to_string)?;480481	if !input.is_empty() {482		return Err(format!("unexpected trailing input: {input:?}"));483	}484	Ok(if is_tag {485		HostItem::Tag { name, attrs }486	} else {487		HostItem::Host { name, attrs }488	})489}490491#[derive(Parser, Clone)]492pub struct FleetOpts {493	/// All hosts except those would be skipped494	#[clap(long, number_of_values = 1, value_parser = host_item_parser)]495	only: Vec<HostItem>,496497	/// Hosts to skip498	#[clap(long, number_of_values = 1)]499	skip: Vec<String>,500501	/// Host, which should be threaten as current machine502	#[clap(long)]503	pub localhost: Option<String>,504505	/// Override detected system for host, to perform builds via506	/// binfmt-declared qemu instead of trying to crosscompile507	#[clap(long, default_value = "detect")]508	pub local_system: String,509}510511impl FleetOpts {512	pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {513		if self.localhost.is_none() {514			self.localhost515				.replace(hostname::get().unwrap().to_str().unwrap().to_owned());516		}517		let directory = current_dir()?;518519		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;520		let root_field = pool.get().await?;521522		let builtins_field = Value::binding(root_field.clone(), "builtins").await?;523		if self.local_system == "detect" {524			self.local_system = nix_go_json!(builtins_field.currentSystem);525		}526		let local_system = self.local_system.clone();527528		let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;529		let fleet_field = nix_go!(fleet_root.default);530531		let config_field = nix_go!(fleet_field.config);532		let config_unchecked_field = nix_go!(fleet_field.unchecked.config);533534		let import = nix_go!(builtins_field.import);535		let overlays = nix_go!(config_unchecked_field.overlays);536		let nixpkgs = nix_go!(fleet_field.nixpkgs | import);537538		let default_pkgs = nix_go!(nixpkgs(Obj {539			overlays,540			system: { self.local_system.clone() },541		}));542543		let mut fleet_data_path = directory.clone();544		fleet_data_path.push("fleet.nix");545		let bytes = std::fs::read_to_string(fleet_data_path)?;546		let data = nixlike::parse_str(&bytes)?;547548		Ok(Config(Arc::new(FleetConfigInternals {549			opts: self,550			directory,551			data,552			local_system,553			nix_args,554			config_field,555			config_unchecked_field,556			default_pkgs,557		})))558	}559}
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -7,11 +7,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1720226507,
-        "narHash": "sha256-yHVvNsgrpyNTXZBEokL8uyB2J6gB1wEx0KOJzoeZi1A=",
+        "lastModified": 1721699339,
+        "narHash": "sha256-UqtSwU13vpzzM6w8tGghEbA7ObM3NCDzSpz19QQo9XE=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "0aed560c5c0a61c9385bddff471a13036203e11c",
+        "rev": "0081e9c447f3b70822c142908f08ceeb436982b8",
         "type": "github"
       },
       "original": {
@@ -40,11 +40,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1720525988,
-        "narHash": "sha256-6Vvrwl2rKrRt5gAYTFlM/pihCwHw8SY2o81TBm7KhIQ=",
+        "lastModified": 1721814637,
+        "narHash": "sha256-L3QkCvxeByJfW45wLkdZ9pL5h9PezOwwfx7G2sRfjiU=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "a630e7a8476e51b116f1ca7444dbad20701823d7",
+        "rev": "e0c444a0b8413a31df199052f5714d409dc4c1d0",
         "type": "github"
       },
       "original": {
@@ -68,11 +68,11 @@
     },
     "nixpkgs-stable-for-tests": {
       "locked": {
-        "lastModified": 1720386169,
-        "narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=",
+        "lastModified": 1721548954,
+        "narHash": "sha256-7cCC8+Tdq1+3OPyc3+gVo9dzUNkNIQfwSDJ2HSi2u3o=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "194846768975b7ad2c4988bdb82572c00222c0d7",
+        "rev": "63d37ccd2d178d54e7fb691d7ec76000740ea24a",
         "type": "github"
       },
       "original": {
@@ -98,11 +98,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1720491570,
-        "narHash": "sha256-PHS2BcQ9kxBpu9GKlDg3uAlrX/ahQOoAiVmwGl6BjD4=",
+        "lastModified": 1721810656,
+        "narHash": "sha256-33UCMmgPL+sz06+iupNkl99hcBABP56ENcxSoKqr0TY=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "b970af40fdc4bd80fd764796c5f97c15e2b564eb",
+        "rev": "a6afdaab4a47d6ecf647a74968e92a51c4a18e5a",
         "type": "github"
       },
       "original": {