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

difftreelog

source

cmds/fleet/src/host.rs12.8 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 anyhow::{anyhow, bail, ensure, Context, Result};13use clap::{ArgGroup, Parser};14use fleet_shared::SecretData;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},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	/// fleet_config.config33	pub config_field: Field,34	/// fleet_config.unchecked.config35	pub config_unchecked_field: Field,3637	/// import nixpkgs {system = local};38	pub default_pkgs: Field,39}4041#[derive(Clone)]42pub struct Config(Arc<FleetConfigInternals>);4344impl Deref for Config {45	type Target = FleetConfigInternals;4647	fn deref(&self) -> &Self::Target {48		&self.049	}50}5152pub struct ConfigHost {53	config: Config,54	pub name: String,55	pub local: bool,56	pub session: OnceLock<Arc<openssh::Session>>,5758	pub nixos_config: Option<Field>,59}60impl ConfigHost {61	async fn open_session(&self) -> Result<Arc<openssh::Session>> {62		assert!(!self.local, "do not open ssh connection to local session");63		// FIXME: TOCTOU64		if let Some(session) = &self.session.get() {65			return Ok((*session).clone());66		};67		let session = SessionBuilder::default();6869		let session = session70			.connect(&self.name)71			.await72			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;73		let session = Arc::new(session);74		self.session.set(session.clone()).expect("TOCTOU happened");75		Ok(session)76	}77	pub async fn mktemp_dir(&self) -> Result<String> {78		let mut cmd = self.cmd("mktemp").await?;79		cmd.arg("-d");80		let path = cmd.run_string().await?;81		Ok(path.trim_end().to_owned())82	}83	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {84		let mut cmd = self.cmd("cat").await?;85		cmd.arg(path);86		cmd.run_bytes().await87	}88	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {89		let mut cmd = self.cmd("cat").await?;90		cmd.arg(path);91		cmd.run_string().await92	}93	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {94		let mut cmd = self.cmd("ls").await?;95		cmd.arg(path);96		let out = cmd.run_string().await?;97		let mut lines = out.split('\n');98		if let Some(last) = lines.next_back() {99			ensure!(last == "", "output of ls should end with newline");100		}101		Ok(lines.map(ToOwned::to_owned).collect())102	}103	#[allow(dead_code)]104	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {105		let text = self.read_file_text(path).await?;106		Ok(serde_json::from_str(&text)?)107	}108	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>109	where110		<D as FromStr>::Err: Display,111	{112		let text = self.read_file_text(path).await?;113		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))114	}115	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {116		if self.local {117			Ok(MyCommand::new(cmd))118		} else {119			let session = self.open_session().await?;120			Ok(MyCommand::new_on(cmd, session))121		}122	}123124	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {125		ensure!(data.encrypted, "secret is not encrypted");126		let mut cmd = self.cmd("fleet-install-secrets").await?;127		cmd.arg("decrypt").eqarg("--secret", data.to_string());128		let encoded = cmd129			.sudo()130			.run_string()131			.await132			.context("failed to call remote host for decrypt")?;133		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;134		ensure!(!data.encrypted, "didn't decrypted secret");135		Ok(data.data)136	}137	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {138		ensure!(data.encrypted, "secret is not encrypted");139		let mut cmd = self.cmd("fleet-install-secrets").await?;140		cmd.arg("reencrypt").eqarg("--secret", data.to_string());141		for target in targets {142			let key = self.config.key(&target).await?;143			cmd.eqarg("--targets", key);144		}145		let encoded = cmd146			.sudo()147			.run_string()148			.await149			.context("failed to call remote host for decrypt")?;150		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;151		ensure!(!data.encrypted, "didn't decrypted secret");152		Ok(data)153	}154	/// Returns path for futureproofing, as path might change i.e on conversion to CA155	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {156		if self.local {157			// Path is located locally, thus already trusted.158			return Ok(path.to_owned());159		}160		let mut nix = MyCommand::new("nix");161		nix.arg("copy")162			.arg("--substitute-on-destination")163			.comparg("--to", format!("ssh-ng://{}", self.name))164			.arg(path);165		nix.run_nix().await.context("nix copy")?;166		Ok(path.to_owned())167	}168	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {169		let mut cmd = self.cmd("systemctl").await?;170		cmd.arg("stop").arg(name);171		cmd.sudo().run().await172	}173	pub async fn systemctl_start(&self, name: &str) -> Result<()> {174		let mut cmd = self.cmd("systemctl").await?;175		cmd.arg("start").arg(name);176		cmd.sudo().run().await177	}178179	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {180		let mut cmd = self.cmd("rm").await?;181		cmd.arg("-f").arg(path);182		if sudo {183			cmd = cmd.sudo()184		}185		cmd.run().await186	}187188	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {189		let Some(nixos) = &self.nixos_config else {190			return Ok(vec![]);191		};192		let secrets = nix_go!(nixos.secrets);193		let mut out = Vec::new();194		for name in secrets.list_fields().await? {195			let secret = nix_go!(secrets[{ name }]);196			let is_shared: bool = nix_go_json!(secret.shared);197			if is_shared {198				continue;199			}200			out.push(name);201		}202		Ok(out)203	}204	pub async fn secret_field(&self, name: &str) -> Result<Field> {205		let Some(nixos) = &self.nixos_config else {206			bail!("host is virtual and has no secrets");207		};208		Ok(nix_go!(nixos.secrets[{ name }]))209	}210211	/// Packages for this host, resolved with nixpkgs overlays212	pub async fn pkgs(&self) -> Result<Field> {213		let Some(nixos) = &self.nixos_config else {214			return Ok(self.config.default_pkgs.clone());215		};216		Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))217	}218}219220impl Config {221	pub fn should_skip(&self, host: &str) -> bool {222		if !self.opts.skip.is_empty() {223			self.opts.skip.iter().any(|h| h as &str == host)224		} else if !self.opts.only.is_empty() {225			!self.opts.only.iter().any(|h| h as &str == host)226		} else {227			false228		}229	}230	pub fn is_local(&self, host: &str) -> bool {231		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)232	}233234	pub fn local_host(&self) -> ConfigHost {235		ConfigHost {236			config: self.clone(),237			name: "<virtual localhost>".to_owned(),238			local: true,239			session: OnceLock::new(),240			nixos_config: None,241		}242	}243244	pub async fn host(&self, name: &str) -> Result<ConfigHost> {245		let config = &self.config_unchecked_field;246		let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);247		Ok(ConfigHost {248			config: self.clone(),249			name: name.to_owned(),250			local: self.is_local(name),251			session: OnceLock::new(),252			nixos_config: Some(nixos_config),253		})254	}255	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {256		let config = &self.config_unchecked_field;257		let names = nix_go!(config.hosts).list_fields().await?;258		let mut out = vec![];259		for name in names {260			out.push(self.host(&name).await?);261		}262		Ok(out)263	}264	pub async fn system_config(&self, host: &str) -> Result<Field> {265		let fleet_field = &self.config_unchecked_field;266		Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))267	}268269	pub(super) fn data(&self) -> MutexGuard<FleetData> {270		self.data.lock().unwrap()271	}272	pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {273		self.data.lock().unwrap()274	}275	/// Shared secrets configured in fleet.nix or in flake276	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {277		let config_field = &self.config_unchecked_field;278		nix_go!(config_field.sharedSecrets).list_fields().await279	}280	/// Shared secrets configured in fleet.nix281	pub fn list_shared(&self) -> Vec<String> {282		let data = self.data();283		data.shared_secrets.keys().cloned().collect()284	}285	pub fn has_shared(&self, name: &str) -> bool {286		let data = self.data();287		data.shared_secrets.contains_key(name)288	}289	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {290		let mut data = self.data_mut();291		data.shared_secrets.insert(name.to_owned(), shared);292	}293	pub fn remove_shared(&self, secret: &str) {294		let mut data = self.data_mut();295		data.shared_secrets.remove(secret);296	}297298	pub fn list_secrets(&self, host: &str) -> Vec<String> {299		let data = self.data();300		let Some(secrets) = data.host_secrets.get(host) else {301			return Vec::new();302		};303		secrets.keys().cloned().collect()304	}305306	pub fn has_secret(&self, host: &str, secret: &str) -> bool {307		let data = self.data();308		let Some(host_secrets) = data.host_secrets.get(host) else {309			return false;310		};311		host_secrets.contains_key(secret)312	}313	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {314		let mut data = self.data_mut();315		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();316		host_secrets.insert(secret, value);317	}318319	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {320		let data = self.data();321		let Some(host_secrets) = data.host_secrets.get(host) else {322			bail!("no secrets for machine {host}");323		};324		let Some(secret) = host_secrets.get(secret) else {325			bail!("machine {host} has no secret {secret}");326		};327		Ok(secret.clone())328	}329	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {330		let data = self.data();331		let Some(secret) = data.shared_secrets.get(secret) else {332			bail!("no shared secret {secret}");333		};334		Ok(secret.clone())335	}336	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {337		let config_field = &self.config_unchecked_field;338		Ok(nix_go_json!(339			config_field.sharedSecrets[{ secret }].expectedOwners340		))341	}342343	pub fn save(&self) -> Result<()> {344		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.")?;345		let data = nixlike::serialize(&self.data() as &FleetData)?;346		tempfile.write_all(347			format!(348				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",349				data350			)351			.as_bytes(),352		)?;353		let mut fleet_data_path = self.directory.clone();354		fleet_data_path.push("fleet.nix");355		tempfile.persist(fleet_data_path)?;356		Ok(())357	}358}359360#[derive(Parser, Clone)]361#[clap(group = ArgGroup::new("target_hosts"))]362pub struct FleetOpts {363	/// All hosts except those would be skipped364	#[clap(long, number_of_values = 1, group = "target_hosts")]365	only: Vec<String>,366367	/// Hosts to skip368	#[clap(long, number_of_values = 1, group = "target_hosts")]369	skip: Vec<String>,370371	/// Host, which should be threaten as current machine372	#[clap(long)]373	pub localhost: Option<String>,374375	/// Override detected system for host, to perform builds via376	/// binfmt-declared qemu instead of trying to crosscompile377	#[clap(long, default_value = "detect")]378	pub local_system: String,379}380381impl FleetOpts {382	pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {383		if self.localhost.is_none() {384			self.localhost385				.replace(hostname::get().unwrap().to_str().unwrap().to_owned());386		}387		let directory = current_dir()?;388389		let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;390		let root_field = pool.get().await?;391392		let builtins_field = Field::field(root_field.clone(), "builtins").await?;393		if self.local_system == "detect" {394			self.local_system = nix_go_json!(builtins_field.currentSystem);395		}396		let local_system = self.local_system.clone();397398		let fleet_root = Field::field(root_field, "fleetConfigurations").await?;399		let fleet_field = nix_go!(fleet_root.default);400401		let config_field = nix_go!(fleet_field.config);402		let config_unchecked_field = nix_go!(fleet_field.unchecked.config);403404		let import = nix_go!(builtins_field.import);405		let overlays = nix_go!(config_unchecked_field.overlays);406		let nixpkgs = nix_go!(fleet_field.nixpkgs | import);407408		let default_pkgs = nix_go!(nixpkgs(Obj {409			overlays,410			system: { self.local_system.clone() },411		}));412413		let mut fleet_data_path = directory.clone();414		fleet_data_path.push("fleet.nix");415		let bytes = std::fs::read_to_string(fleet_data_path)?;416		let data = nixlike::parse_str(&bytes)?;417418		Ok(Config(Arc::new(FleetConfigInternals {419			opts: self,420			directory,421			data,422			local_system,423			nix_args,424			config_field,425			config_unchecked_field,426			default_pkgs,427		})))428	}429}