git.delta.rocks / jrsonnet / refs/commits / 904d12180e52

difftreelog

source

cmds/fleet/src/host.rs9.9 KiBsourcehistory
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 age::Recipient;13use anyhow::{anyhow, bail, Context, Result};14use clap::{ArgGroup, Parser};15use openssh::SessionBuilder;16use serde::de::DeserializeOwned;17use tempfile::NamedTempFile;1819use crate::{20	better_nix_eval::{Field, NixSessionPool},21	command::MyCommand,22	fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData},23	nix_go, nix_go_json,24};2526pub struct FleetConfigInternals {27	pub local_system: String,28	pub directory: PathBuf,29	pub opts: FleetOpts,30	pub data: Mutex<FleetData>,31	pub nix_args: Vec<OsString>,32	/// fleetConfigurations.<name>.<localSystem>33	pub fleet_field: Field,34	/// fleet_config.configUnchecked35	pub config_field: Field,36	/// fleet_config.unchecked37	pub config_unchecked_field: Field,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	pub name: String,53	pub session: OnceLock<Arc<openssh::Session>>,54}55impl ConfigHost {56	pub async fn open_session(&self) -> Result<Arc<openssh::Session>> {57		// FIXME: TOCTOU58		if let Some(session) = &self.session.get() {59			return Ok((*session).clone());60		};61		let session = SessionBuilder::default();6263		let session = session64			.connect(&self.name)65			.await66			.map_err(|e| anyhow!("ssh error: {e}"))?;67		let session = Arc::new(session);68		self.session.set(session.clone()).expect("TOCTOU happened");69		Ok(session)70	}71	pub async fn mktemp_dir(&self) -> Result<String> {72		let mut cmd = self.cmd("mktemp").await?;73		cmd.arg("-d");74		let path = cmd.run_string().await?;75		Ok(path.trim_end().to_owned())76	}77	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {78		let mut cmd = self.cmd("cat").await?;79		cmd.arg(path);80		cmd.run_bytes().await81	}82	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {83		let mut cmd = self.cmd("cat").await?;84		cmd.arg(path);85		cmd.run_string().await86	}87	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {88		let text = self.read_file_text(path).await?;89		Ok(serde_json::from_str(&text)?)90	}91	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>92	where93		<D as FromStr>::Err: Display,94	{95		let text = self.read_file_text(path).await?;96		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))97	}98	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {99		let session = self.open_session().await?;100		Ok(MyCommand::new_on(cmd, session))101	}102103	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {104		let mut cmd = self.cmd("fleet-install-secrets").await?;105		cmd.arg("decrypt").eqarg("--secret", data.encode_z85());106		let encoded = cmd107			.sudo()108			.run_string()109			.await110			.context("failed to call remote host for decrypt")?;111		z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")112	}113	/// Returns path for futureproofing, as path might change i.e on conversion to CA114	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {115		let mut nix = MyCommand::new("nix");116		nix.arg("copy")117			.arg("--substitute-on-destination")118			.comparg("--to", format!("ssh-ng://{}", self.name))119			.arg(path);120		nix.run_nix().await?;121		Ok(path.to_owned())122	}123}124125impl Config {126	pub fn should_skip(&self, host: &str) -> bool {127		if !self.opts.skip.is_empty() {128			self.opts.skip.iter().any(|h| h as &str == host)129		} else if !self.opts.only.is_empty() {130			!self.opts.only.iter().any(|h| h as &str == host)131		} else {132			false133		}134	}135	pub fn is_local(&self, host: &str) -> bool {136		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)137	}138139	pub async fn run_on(&self, host: &str, mut command: MyCommand, sudo: bool) -> Result<()> {140		if sudo {141			command = command.sudo();142		}143		if !self.is_local(host) {144			command = command.ssh(host);145		}146		command.run().await147	}148	pub async fn run_string_on(149		&self,150		host: &str,151		mut command: MyCommand,152		sudo: bool,153	) -> Result<String> {154		if sudo {155			command = command.sudo();156		}157		if !self.is_local(host) {158			command = command.ssh(host);159		}160		command.run_string().await161	}162163	pub async fn host(&self, name: &str) -> Result<ConfigHost> {164		Ok(ConfigHost {165			name: name.to_owned(),166			session: OnceLock::new(),167		})168	}169	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {170		let fleet_field = &self.fleet_field;171		let names = nix_go!(fleet_field.configuredHosts).list_fields().await?;172		let mut out = vec![];173		for name in names {174			out.push(ConfigHost {175				name,176				session: OnceLock::new(),177			})178		}179		Ok(out)180	}181	pub async fn system_config(&self, host: &str) -> Result<Field> {182		let fleet_field = &self.fleet_field;183		Ok(nix_go!(fleet_field.configuredSystems[{ host }].config))184	}185186	pub(super) fn data(&self) -> MutexGuard<FleetData> {187		self.data.lock().unwrap()188	}189	pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {190		self.data.lock().unwrap()191	}192	/// Shared secrets configured in fleet.nix or in flake193	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {194		let config_field = &self.config_unchecked_field;195		nix_go!(config_field.configUnchecked.sharedSecrets)196			.list_fields()197			.await198	}199	/// Shared secrets configured in fleet.nix200	pub fn list_shared(&self) -> Vec<String> {201		let data = self.data();202		data.shared_secrets.keys().cloned().collect()203	}204	pub fn has_shared(&self, name: &str) -> bool {205		let data = self.data();206		data.shared_secrets.contains_key(name)207	}208	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {209		let mut data = self.data_mut();210		data.shared_secrets.insert(name.to_owned(), shared);211	}212	pub fn remove_shared(&self, secret: &str) {213		let mut data = self.data_mut();214		data.shared_secrets.remove(secret);215	}216217	pub fn has_secret(&self, host: &str, secret: &str) -> bool {218		let data = self.data();219		let Some(host_secrets) = data.host_secrets.get(host) else {220			return false;221		};222		host_secrets.contains_key(secret)223	}224	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {225		let mut data = self.data_mut();226		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();227		host_secrets.insert(secret, value);228	}229230	pub async fn reencrypt_on_host(231		&self,232		host: &str,233		data: SecretData,234		targets: Vec<String>,235	) -> Result<SecretData> {236		let mut recmd = MyCommand::new("fleet-install-secrets");237		recmd.arg("reencrypt").eqarg("--secret", data.encode_z85());238		for target in targets {239			recmd.eqarg("--targets", target);240		}241		recmd = recmd.sudo().ssh(host);242		let encoded = recmd243			.run_string()244			.await245			.context("failed to call remote host for decrypt")?246			.trim()247			.to_owned();248		SecretData::decode_z85(&encoded)249	}250251	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {252		let data = self.data();253		let Some(host_secrets) = data.host_secrets.get(host) else {254			bail!("no secrets for machine {host}");255		};256		let Some(secret) = host_secrets.get(secret) else {257			bail!("machine {host} has no secret {secret}");258		};259		Ok(secret.clone())260	}261	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {262		let data = self.data();263		let Some(secret) = data.shared_secrets.get(secret) else {264			bail!("no shared secret {secret}");265		};266		Ok(secret.clone())267	}268	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {269		let config_field = &self.config_unchecked_field;270		Ok(nix_go_json!(271			config_field.configUnchecked.sharedSecrets[{ secret }].expectedOwners272		))273	}274275	pub fn save(&self) -> Result<()> {276		let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;277		let data = nixlike::serialize(&self.data() as &FleetData)?;278		tempfile.write_all(279			format!(280				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",281				data282			)283			.as_bytes(),284		)?;285		let mut fleet_data_path = self.directory.clone();286		fleet_data_path.push("fleet.nix");287		tempfile.persist(fleet_data_path)?;288		Ok(())289	}290}291292#[derive(Parser, Clone)]293#[clap(group = ArgGroup::new("target_hosts"))]294pub struct FleetOpts {295	/// All hosts except those would be skipped296	#[clap(long, number_of_values = 1, group = "target_hosts")]297	only: Vec<String>,298299	/// Hosts to skip300	#[clap(long, number_of_values = 1, group = "target_hosts")]301	skip: Vec<String>,302303	/// Host, which should be threaten as current machine304	#[clap(long)]305	pub localhost: Option<String>,306307	/// Override detected system for host, to perform builds via308	/// binfmt-declared qemu instead of trying to crosscompile309	#[clap(long, default_value = "detect")]310	pub local_system: String,311}312313impl FleetOpts {314	pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {315		if self.localhost.is_none() {316			self.localhost317				.replace(hostname::get().unwrap().to_str().unwrap().to_owned());318		}319		let directory = current_dir()?;320321		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;322		let root_field = pool.get().await?;323324		if self.local_system == "detect" {325			let builtins_field = Field::field(root_field.clone(), "builtins").await?;326			self.local_system = nix_go_json!(builtins_field.currentSystem);327		}328		let local_system = self.local_system.clone();329330		let fleet_root = Field::field(root_field, "fleetConfigurations").await?;331332		let fleet_field = nix_go!(fleet_root.default);333		let config_field = nix_go!(fleet_field.configUnchecked);334		let config_unchecked_field = nix_go!(fleet_field.unchecked);335336		let mut fleet_data_path = directory.clone();337		fleet_data_path.push("fleet.nix");338		let bytes = std::fs::read_to_string(fleet_data_path)?;339		let data = nixlike::parse_str(&bytes)?;340341		Ok(Config(Arc::new(FleetConfigInternals {342			opts: self,343			directory,344			data,345			local_system,346			nix_args,347			fleet_field,348			config_field,349			config_unchecked_field,350		})))351	}352}