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

difftreelog

refactor nix-eval is now fully sync, parallelism should be explicit at the callsite

zorryxklYaroslav Bolyukin2025-09-18parent: #cf89cc0.patch.diff
in: trunk

6 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2070,8 +2070,6 @@
  "serde_json",
  "test-log",
  "thiserror 2.0.16",
- "tokio",
- "tokio-util",
  "tracing",
  "tracing-indicatif",
  "vte 0.15.0",
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -8,7 +8,7 @@
 	opts::FleetOpts,
 };
 use nix_eval::nix_go;
-use tokio::task::LocalSet;
+use tokio::task::{LocalSet, spawn_blocking};
 use tracing::{Instrument, error, field, info, info_span, warn};
 
 #[derive(Parser)]
@@ -34,7 +34,9 @@
 	// let action = Action::from(self.subcommand.clone());
 	let nixos = host.nixos_config().await?;
 	let drv = nix_go!(nixos.system.build[{ build_attr }]);
-	let out_output = drv.build("out").await?;
+	let out_output = spawn_blocking(move || drv.build("out"))
+		.await
+		.expect("system derivation build should not panic")?;
 
 	// We already have system profiles for backups.
 	if !host.local {
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	io::{self, Read, Write, stdin, stdout},4	path::PathBuf,5	slice,6};78use age::Recipient;9use anyhow::{Context, Result, anyhow, bail, ensure};10use chrono::{DateTime, Utc};11use clap::Parser;12use fleet_base::{13	fleetdata::{FleetSecret, FleetSecretPart, FleetSharedSecret, encrypt_secret_data},14	host::Config,15	opts::FleetOpts,16};17use fleet_shared::SecretData;18use nix_eval::{NixType, Value, nix_go, nix_go_json};19use owo_colors::OwoColorize;20use serde::Deserialize;21use tabled::{Table, Tabled};22use tokio::fs::read;23use tracing::{Instrument, error, info, info_span, warn};2425#[derive(Parser)]26pub enum Secret {27	AddManager,28	/// Force load host keys for all defined hosts29	ForceKeys,30	/// Add secret, data should be provided in stdin31	AddShared {32		/// Secret name33		name: String,34		/// Secret owners35		#[clap(long, short)]36		machines: Vec<String>,37		/// Override secret if already present38		#[clap(long)]39		force: bool,40		/// Secret public part41		#[clap(long)]42		public: Option<String>,43		/// Load public part from specified file44		#[clap(long)]45		public_file: Option<PathBuf>,4647		/// Create a notification on secret expiration48		#[clap(long)]49		expires_at: Option<DateTime<Utc>>,5051		/// Secret with this name already exists, override its value while keeping the same owners.52		#[clap(long)]53		re_add: bool,5455		/// How to name public secret part56		#[clap(long, short = 'p', default_value = "public")]57		public_part: String,58		/// How to name private secret part59		#[clap(short = 's', long, default_value = "secret")]60		part: String,61	},62	/// Add secret, data should be provided in stdin63	Add {64		/// Secret name65		name: String,66		/// Secret owner67		#[clap(short = 'm', long)]68		machine: String,69		/// Replace secret if already present70		#[clap(long)]71		replace: bool,72		/// Add new parts to existing secret73		#[clap(long)]74		merge: bool,75		/// Secret public part76		#[clap(long)]77		public: Option<String>,78		/// Load public part from specified file79		#[clap(long)]80		public_file: Option<PathBuf>,8182		/// How to name public secret part83		#[clap(short = 'p', long, default_value = "public")]84		public_part: String,85		/// How to name private secret part86		#[clap(short = 's', long, default_value = "secret")]87		part: String,88	},89	/// Read secret from remote host, requires sudo on said host90	Read {91		name: String,92		#[clap(short = 'm', long)]93		machine: String,9495		/// Which private secret part to read96		#[clap(short = 'p', long, default_value = "secret")]97		part: String,98	},99	/// Read secret from remote host, requires sudo on said host100	ReadShared {101		name: String,102		/// Which private secret part to read103		#[clap(short = 'p', long, default_value = "secret")]104		part: String,105		/// Which host should we use to decrypt, in case if reencryption is required, without106		/// regeneration107		#[clap(long)]108		prefer_identities: Vec<String>,109	},110	UpdateShared {111		name: String,112113		#[clap(short = 'm', long)]114		machine: Option<Vec<String>>,115116		#[clap(long)]117		add_machine: Vec<String>,118		#[clap(long)]119		remove_machine: Vec<String>,120121		/// Which host should we use to decrypt122		#[clap(long)]123		prefer_identities: Vec<String>,124	},125	Regenerate {126		/// Which host should we use to decrypt, in case if reencryption is required, without127		/// regeneration128		#[clap(long)]129		prefer_identities: Vec<String>,130		/// Only regenerate shared secrets131		#[clap(long)]132		skip_hosts: bool,133	},134	List {},135	Edit {136		name: String,137		#[clap(short = 'm', long)]138		machine: String,139140		#[clap(long)]141		add: bool,142143		/// Which private secret part to read144		#[clap(short = 'p', long, default_value = "secret")]145		part: String,146	},147}148149fn secret_needs_regeneration(150	secret: &FleetSecret,151	expected_generation_data: &serde_json::Value,152) -> bool {153	let data_is_expected = secret.generation_data == *expected_generation_data;154	// TODO: Leeway?155	let expired = secret156		.expires_at157		.map(|expiration| expiration < Utc::now())158		.unwrap_or(false);159	expired || !data_is_expected160}161162#[allow(clippy::too_many_arguments)]163#[tracing::instrument(skip(config, secret, field, prefer_identities))]164async fn maybe_regenerate_shared_secret(165	secret_name: &str,166	config: &Config,167	mut secret: FleetSharedSecret,168	field: Value,169	expected_owners: &[String],170	expected_generation_data: serde_json::Value,171	prefer_identities: &[String],172) -> Result<FleetSharedSecret> {173	let original_set = secret.owners.clone();174175	let set = original_set.iter().collect::<BTreeSet<_>>();176	let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();177178	let regeneration_required =179		secret_needs_regeneration(&secret.secret, &expected_generation_data);180181	if set == expected_set && !regeneration_required {182		info!("no need to update owner list, it is already correct");183		return Ok(secret);184	}185186	let should_regenerate = if regeneration_required {187		info!("secret has its generation data changed, regeneration is required");188		true189	} else if set.difference(&expected_set).next().is_some() {190		// TODO: Remove this warning for revokable secrets.191		warn!(192			"host was removed from secret owners, but until this host rebuild, the secret will still be stored on it."193		);194		nix_go_json!(field.regenerateOnOwnerRemoved)195	} else if expected_set.difference(&set).next().is_some() {196		nix_go_json!(field.regenerateOnOwnerAdded)197	} else {198		false199	};200201	if should_regenerate {202		info!("secret needs to be regenerated");203		let generated = generate_shared(204			config,205			secret_name,206			field,207			expected_owners.to_vec(),208			expected_generation_data,209		)210		.await?;211		Ok(generated)212	} else {213		let identity_holder = if !prefer_identities.is_empty() {214			prefer_identities215				.iter()216				.find(|i| original_set.iter().any(|s| s == *i))217		} else {218			secret.owners.first()219		};220		let Some(identity_holder) = identity_holder else {221			bail!("no available holder found");222		};223224		for (part_name, part) in secret.secret.parts.iter_mut() {225			let _span = info_span!("part reencryption", part_name);226			if !part.raw.encrypted {227				continue;228			}229			let host = config.host(identity_holder).await?;230			let encrypted = host231				.reencrypt(part.raw.clone(), expected_owners.to_vec())232				.await?;233			part.raw = encrypted;234		}235236		secret.owners = expected_owners.to_vec();237		Ok(secret)238	}239}240241#[derive(Deserialize)]242#[serde(rename_all = "camelCase")]243enum GeneratorKind {244	Impure,245	Pure,246}247248async fn generate_pure(249	_config: &Config,250	_display_name: &str,251	_secret: Value,252	_default_generator: Value,253	_owners: &[String],254) -> Result<FleetSecret> {255	bail!("pure generators are broken for now")256}257async fn generate_impure(258	config: &Config,259	_display_name: &str,260	secret: Value,261	default_generator: Value,262	expected_owners: &[String],263	expected_generation_data: serde_json::Value,264) -> Result<FleetSecret> {265	let generator = nix_go!(secret.generator);266	let on: Option<String> = nix_go_json!(default_generator.impureOn);267268	let nixpkgs = &config.nixpkgs;269270	let host = if let Some(on) = &on {271		config.host(on).await?272	} else {273		config.local_host()274	};275	let on_pkgs = host.pkgs().await?;276	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);277278	let mut recipients = Vec::new();279	for owner in expected_owners {280		let key = config.key(owner).await?;281		recipients.push(key);282	}283	let generators = nix_go!(mk_secret_generators(Obj { recipients }));284	// FIXME: Apparently, // operator is slow in nix285	let pkgs_and_generators = on_pkgs.attrs_update(generators)?;286287	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));288289	let generator = nix_go!(call_package(generator)(Obj {}));290291	let generator = generator.build("out").await?;292	let generator = host.remote_derivation(&generator).await?;293294	let out_parent = host.mktemp_dir().await?;295	let out = format!("{out_parent}/out");296297	let mut r#gen = host.cmd(generator).await?;298	r#gen.env("out", &out);299	if on.is_none() {300		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.301		let project_path: String = config302			.directory303			.clone()304			.into_os_string()305			.into_string()306			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;307		r#gen.env("FLEET_PROJECT", project_path);308	}309	r#gen.run().await.context("impure generator")?;310311	{312		let marker = host.read_file_text(format!("{out}/marker")).await?;313		ensure!(marker == "SUCCESS", "generation not succeeded");314	}315316	let mut parts = BTreeMap::new();317	for part in host.read_dir(&out).await? {318		if part == "created_at" || part == "expires_at" || part == "marker" {319			continue;320		}321		let contents: SecretData = host322			.read_file_text(format!("{out}/{part}"))323			.await?324			.parse()325			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;326		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });327	}328329	let created_at = host.read_file_value(format!("{out}/created_at")).await?;330	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();331332	Ok(FleetSecret {333		created_at,334		expires_at,335		parts,336		generation_data: expected_generation_data,337	})338}339async fn generate(340	config: &Config,341	display_name: &str,342	secret: Value,343	expected_owners: &[String],344	expected_generation_data: serde_json::Value,345) -> Result<FleetSecret> {346	let generator = nix_go!(secret.generator);347	// Can't properly check on nix module system level348	{349		let gen_ty = generator.type_of();350		if matches!(gen_ty, NixType::Null) {351			bail!("secret has no generator defined, can't automatically generate it.");352		}353		if matches!(gen_ty, NixType::Attrs) {354			if !generator.has_field("__functor")? {355				bail!("generator should be functor, got {gen_ty:?}");356			}357		} else if matches!(gen_ty, NixType::Function) {358			bail!("generator should be functor, got {gen_ty:?}");359		}360	}361	let nixpkgs = &config.nixpkgs;362	let default_pkgs = &config.default_pkgs;363	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);364	// Generators provide additional information in passthru, to access365	// passthru we should call generator, but information about where this generator is supposed to build366	// is located in passthru... Thus evaluating generator on host.367	//368	// Maybe it is also possible to do some magic with __functor?369	//370	// I don't want to make modules always responsible for additional secret data anyway,371	// so it should be in derivation, and not in the secret data itself.372	let generators = nix_go!(default_mk_secret_generators(Obj {373		recipients: <Vec<String>>::new(),374	}));375	let pkgs_and_generators = default_pkgs.clone().attrs_update(generators)?;376377	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));378	let default_generator = nix_go!(call_package(generator)(Obj {}));379380	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);381382	match kind {383		GeneratorKind::Impure => {384			generate_impure(385				config,386				display_name,387				secret,388				default_generator,389				expected_owners,390				expected_generation_data,391			)392			.await393		}394		GeneratorKind::Pure => {395			generate_pure(396				config,397				display_name,398				secret,399				default_generator,400				expected_owners,401			)402			.await403		}404	}405}406async fn generate_shared(407	config: &Config,408	display_name: &str,409	secret: Value,410	expected_owners: Vec<String>,411	expected_generation_data: serde_json::Value,412) -> Result<FleetSharedSecret> {413	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);414	Ok(FleetSharedSecret {415		secret: generate(416			config,417			display_name,418			secret,419			&expected_owners,420			expected_generation_data,421		)422		.await?,423		owners: expected_owners,424	})425}426427async fn parse_public(428	public: Option<String>,429	public_file: Option<PathBuf>,430) -> Result<Option<SecretData>> {431	Ok(match (public, public_file) {432		(Some(v), None) => Some(SecretData {433			data: v.into(),434			encrypted: false,435		}),436		(None, Some(v)) => Some(SecretData {437			data: read(v).await?,438			encrypted: false,439		}),440		(Some(_), Some(_)) => {441			bail!("only public or public_file should be set")442		}443		(None, None) => None,444	})445}446447async fn parse_secret() -> Result<Option<Vec<u8>>> {448	let mut input = vec![];449	stdin().read_to_end(&mut input)?;450	if input.is_empty() {451		Ok(None)452	} else {453		Ok(Some(input))454	}455}456457fn parse_machines(458	initial: Vec<String>,459	machines: Option<Vec<String>>,460	mut add_machines: Vec<String>,461	mut remove_machines: Vec<String>,462) -> Result<Vec<String>> {463	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {464		bail!("no operation");465	}466467	let initial_machines = initial.clone();468	let mut target_machines = initial;469	info!("Currently encrypted for {initial_machines:?}");470471	// ensure!(machines.is_some() || !add_machines.is_empty() || )472	if let Some(machines) = machines {473		ensure!(474			add_machines.is_empty() && remove_machines.is_empty(),475			"can't combine --machines and --add-machines/--remove-machines"476		);477		let target = initial_machines.iter().collect::<HashSet<_>>();478		let source = machines.iter().collect::<HashSet<_>>();479		for removed in target.difference(&source) {480			remove_machines.push((*removed).clone());481		}482		for added in source.difference(&target) {483			add_machines.push((*added).clone());484		}485	}486487	for machine in &remove_machines {488		let mut removed = false;489		while let Some(pos) = target_machines.iter().position(|m| m == machine) {490			target_machines.swap_remove(pos);491			removed = true;492		}493		if !removed {494			warn!("secret is not enabled for {machine}");495		}496	}497	for machine in &add_machines {498		if target_machines.iter().any(|m| m == machine) {499			warn!("secret is already added to {machine}");500		} else {501			target_machines.push(machine.to_owned());502		}503	}504	if !remove_machines.is_empty() {505		// TODO: maybe force secret regeneration?506		// Not that useful without revokation.507		warn!(508			"secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"509		);510	}511	Ok(target_machines)512}513impl Secret {514	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {515		match self {516			Secret::AddManager => {517				todo!("part of fleet-pusher")518			}519			Secret::ForceKeys => {520				for host in config.list_hosts().await? {521					if opts.should_skip(&host).await? {522						continue;523					}524					config.key(&host.name).await?;525				}526			}527			Secret::AddShared {528				mut machines,529				name,530				force,531				public,532				public_part: public_name,533				public_file,534				expires_at,535				re_add,536				part: part_name,537			} => {538				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).539540				let exists = config.has_shared(&name);541				if exists && !force && !re_add {542					bail!("secret already defined");543				}544				if re_add {545					// Fixme: use clap to limit this usage546					ensure!(!force, "--force and --readd are not compatible");547					ensure!(exists, "secret doesn't exists");548					ensure!(549						machines.is_empty(),550						"you can't use machines argument for --readd"551					);552					let shared = config.shared_secret(&name)?;553					machines = shared.owners;554				}555556				let recipients = config.recipients(machines.clone()).await?;557558				let mut parts = BTreeMap::new();559560				let mut input = vec![];561				io::stdin().read_to_end(&mut input)?;562563				if !input.is_empty() {564					let encrypted =565						encrypt_secret_data(recipients.iter().map(|r| r as &dyn Recipient), input)566							.ok_or_else(|| anyhow!("no recipients provided"))?;567					parts.insert(part_name, FleetSecretPart { raw: encrypted });568				}569570				if let Some(public) = parse_public(public, public_file).await? {571					parts.insert(public_name, FleetSecretPart { raw: public });572				}573574				config.replace_shared(575					name,576					FleetSharedSecret {577						owners: machines,578						secret: FleetSecret {579							created_at: Utc::now(),580							expires_at,581							parts,582							generation_data: serde_json::Value::Null,583						},584					},585				);586			}587			Secret::Add {588				machine,589				name,590				replace,591				merge,592				public,593				public_part: public_name,594				public_file,595				part: part_name,596			} => {597				if config.has_secret(&machine, &name) && !replace && !merge {598					bail!(599						"secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"600					);601				}602603				let mut out = if merge && !replace {604					config605						.host_secret(&machine, &name)606						.context("failed to read existing secret for --merge")?607				} else {608					FleetSecret {609						created_at: Utc::now(),610						expires_at: None,611						parts: BTreeMap::new(),612						generation_data: serde_json::Value::Null,613					}614				};615616				if let Some(secret) = parse_secret().await? {617					let recipient = config.recipient(&machine).await?;618					let encrypted = encrypt_secret_data([&recipient as &dyn Recipient], secret)619						.expect("recipient provided");620					if out621						.parts622						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })623						.is_some() && !replace624					{625						bail!("part {part_name:?} is already defined");626					}627				}628629				if let Some(public) = parse_public(public, public_file).await? {630					if out631						.parts632						.insert(public_name.clone(), FleetSecretPart { raw: public })633						.is_some() && !replace634					{635						bail!("part {public_name:?} is already defined");636					}637				};638639				config.insert_secret(&machine, name, out);640			}641			#[allow(clippy::await_holding_refcell_ref)]642			Secret::Read {643				name,644				machine,645				part: part_name,646			} => {647				let secret = config.host_secret(&machine, &name)?;648				let Some(secret) = secret.parts.get(&part_name) else {649					bail!("no part {part_name} in secret {name}");650				};651				let data = if secret.raw.encrypted {652					let host = config.host(&machine).await?;653					host.decrypt(secret.raw.clone()).await?654				} else {655					secret.raw.data.clone()656				};657658				stdout().write_all(&data)?;659			}660			Secret::ReadShared {661				name,662				part: part_name,663				prefer_identities,664			} => {665				let secret = config.shared_secret(&name)?;666				let Some(part) = secret.secret.parts.get(&part_name) else {667					bail!("no part {part_name} in secret {name}");668				};669				let data = if part.raw.encrypted {670					let identity_holder = if !prefer_identities.is_empty() {671						prefer_identities672							.iter()673							.find(|i| secret.owners.iter().any(|s| s == *i))674					} else {675						secret.owners.first()676					};677					let Some(identity_holder) = identity_holder else {678						bail!("no available holder found");679					};680					let host = config.host(identity_holder).await?;681					host.decrypt(part.raw.clone()).await?682				} else {683					part.raw.data.clone()684				};685				stdout().write_all(&data)?;686			}687			Secret::UpdateShared {688				name,689				machine,690				add_machine,691				remove_machine,692				prefer_identities,693			} => {694				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).695696				let secret = config.shared_secret(&name)?;697				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {698					bail!("no secret");699				}700701				let initial_machines = secret.owners.clone();702				let target_machines = parse_machines(703					initial_machines.clone(),704					machine,705					add_machine,706					remove_machine,707				)?;708709				if target_machines.is_empty() {710					info!("no machines left for secret, removing it");711					config.remove_shared(&name);712					return Ok(());713				}714715				let config_field = &config.config_field;716				let name_clone = name.clone();717				let field = nix_go!(config_field.sharedSecrets[name_clone]);718				let expected_generation_data = nix_go_json!(field.expectedGenerationData);719720				let updated = maybe_regenerate_shared_secret(721					&name,722					config,723					secret,724					field,725					&target_machines,726					expected_generation_data,727					&prefer_identities,728					// None,729				)730				.await?;731				config.replace_shared(name, updated);732			}733			Secret::Regenerate {734				prefer_identities,735				skip_hosts,736			} => {737				info!("checking for secrets to regenerate");738				let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();739				{740					// Generate missing shared741					let _span = info_span!("shared").entered();742					let expected_shared_set = config743						.list_configured_shared()744						.await?745						.into_iter()746						.collect::<HashSet<_>>();747					for missing in expected_shared_set.difference(&stored_shared_set) {748						let config_field = &config.config_field;749						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);750						let expected_generation_data: serde_json::Value =751							nix_go_json!(secret.expectedGenerationData);752						let expected_owners: Option<Vec<String>> =753							nix_go_json!(secret.expectedOwners);754						let Some(expected_owners) = expected_owners else {755							// Can't generate this missing secret, as it has no defined owners.756							continue;757						};758						info!("generating secret: {missing}");759						let shared = generate_shared(760							config,761							missing,762							secret,763							expected_owners,764							expected_generation_data,765						)766						.in_current_span()767						.await?;768						config.replace_shared(missing.to_string(), shared)769					}770				}771				if !skip_hosts {772					for host in config.list_hosts().await? {773						if opts.should_skip(&host).await? {774							continue;775						}776777						let _span = info_span!("host", host = host.name).entered();778						let expected_set = host779							.list_configured_secrets()780							.in_current_span()781							.await?782							.into_iter()783							.collect::<HashSet<_>>();784						let stored_set = config785							.list_secrets(&host.name)786							.into_iter()787							.collect::<HashSet<_>>();788						for missing in expected_set.difference(&stored_set) {789							info!("generating secret: {missing}");790							let secret = host.secret_field(missing).in_current_span().await?;791							let expected_generation_data =792								nix_go_json!(secret.expectedGenerationData);793							let generated = match generate(794								config,795								missing,796								secret,797								slice::from_ref(&host.name),798								expected_generation_data,799							)800							.in_current_span()801							.await802							{803								Ok(v) => v,804								Err(e) => {805									error!("{e:?}");806									continue;807								}808							};809							config.insert_secret(&host.name, missing.to_string(), generated)810						}811						for name in stored_set {812							info!("updating secret: {name}");813							let data = config.host_secret(&host.name, &name)?;814							let secret = host.secret_field(&name).in_current_span().await?;815							let expected_generation_data =816								nix_go_json!(secret.expectedGenerationData);817							if secret_needs_regeneration(&data, &expected_generation_data) {818								let generated = match generate(819									config,820									&name,821									secret,822									slice::from_ref(&host.name),823									expected_generation_data,824								)825								.in_current_span()826								.await827								{828									Ok(v) => v,829									Err(e) => {830										error!("{e:?}");831										continue;832									}833								};834								config.insert_secret(&host.name, name.to_string(), generated)835							}836						}837					}838				}839				let mut to_remove = Vec::new();840				for name in &stored_shared_set {841					info!("updating secret: {name}");842					let data = config.shared_secret(name)?;843					let config_field = &config.config_field;844					let expected_owners: Option<Vec<String>> =845						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);846					let Some(expected_owners) = expected_owners else {847						warn!("secret was removed from fleet config: {name}, removing from data");848						to_remove.push(name.to_string());849						continue;850					};851852					let secret = nix_go!(config_field.sharedSecrets[{ name }]);853					let expected_generation_data = nix_go_json!(secret.expectedGenerationData);854					config.replace_shared(855						name.to_owned(),856						maybe_regenerate_shared_secret(857							name,858							config,859							data,860							secret,861							&expected_owners,862							expected_generation_data,863							&prefer_identities,864							// None,865						)866						.await?,867					);868				}869				for k in to_remove {870					config.remove_shared(&k);871				}872			}873			Secret::List {} => {874				let _span = info_span!("loading secrets").entered();875				let configured = config.list_configured_shared().await?;876				#[derive(Tabled)]877				struct SecretDisplay {878					#[tabled(rename = "Name")]879					name: String,880					#[tabled(rename = "Owners")]881					owners: String,882				}883				let mut table = vec![];884				for name in configured.iter().cloned() {885					let config = config.clone();886					let expected_owners = config.shared_secret_expected_owners(&name).await?;887					let data = config.shared_secret(&name)?;888					let owners = data889						.owners890						.iter()891						.map(|o| {892							if expected_owners.contains(o) {893								o.green().to_string()894							} else {895								o.red().to_string()896							}897						})898						.collect::<Vec<_>>();899					table.push(SecretDisplay {900						owners: owners.join(", "),901						name,902					})903				}904				info!("loaded\n{}", Table::new(table).to_string())905			}906			Secret::Edit {907				name,908				machine,909				part,910				add,911			} => {912				let secret = config.host_secret(&machine, &name)?;913				if let Some(data) = secret.parts.get(&part) {914					let host = config.host(&machine).await?;915					let secret = host.decrypt(data.raw.clone()).await?;916					String::from_utf8(secret).context("secret is not utf8")?917				} else if add {918					String::new()919				} else {920					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");921				};922			}923		}924		Ok(())925	}926}927928/*929async fn edit_temp_file(930	builder: tempfile::Builder<'_, '_>,931	r: Vec<u8>,932	header: &str,933	comment: &str,934) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {935	if !stdin().is_tty() {936		// TODO: Also try to open /dev/tty directly?937		bail!("stdin is not tty, can't open editor");938	}939940	use std::fmt::Write;941	let mut file = builder.tempfile()?;942943	let mut full_header = String::new();944	let mut had = false;945	for line in header.trim_end().lines() {946		had = true;947		writeln!(&mut full_header, "{comment}{line}")?;948	}949	if had {950		writeln!(&mut full_header, "{}", comment.trim_end())?;951	}952	writeln!(953		&mut full_header,954		"{comment}Do not touch this header! It will be removed automatically"955	)?;956957	file.write_all(full_header.as_bytes())?;958	file.write_all(&r)?;959960	let abs_path = file.into_temp_path();961	let editor = std::env::var_os("VISUAL")962		.or_else(|| std::env::var_os("EDITOR"))963		.unwrap_or_else(|| "vi".into());964	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())965		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;966	let editor_args = editor_args967		.into_iter()968		.map(|v| {969			// Only ASCII subsequences are replaced970			unsafe { OsString::from_encoded_bytes_unchecked(v) }971		})972		.collect_vec();973	let Some((editor, args)) = editor_args.split_first() else {974		bail!("EDITOR env var has no command");975	};976	let mut command = Command::new(editor);977	command.args(args);978979	let path_arg = abs_path.canonicalize()?;980981	// TODO: Save full state, using tcget/_getmode/_setmode982	let was_raw = terminal::is_raw_mode_enabled()?;983	terminal::enable_raw_mode()?;984985	let status = command.arg(path_arg).status().await;986987	if !was_raw {988		terminal::disable_raw_mode()?;989	}990991	let success = match status {992		Ok(s) => s.success(),993		Err(e) if e.kind() == io::ErrorKind::NotFound => {994			bail!("editor not found")995		}996		Err(e) => bail!("editor spawn error: {e}"),997	};998999	let mut file = std::fs::read(&abs_path).context("read editor output")?;1000	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {1001		todo!();1002	};1003	todo!();10041005	// Ok((success, abs_path))1006}1007*/
modifiedcmds/fleet/src/cmds/tf.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/tf.rs
+++ b/cmds/fleet/src/cmds/tf.rs
@@ -10,6 +10,7 @@
 use tokio::{
 	fs::{self, create_dir_all},
 	process::Command,
+	task::spawn_blocking,
 };
 use tracing::debug;
 
@@ -38,7 +39,10 @@
 			debug!("generating terraform configs");
 			let system = &config.local_system;
 			let config = &config.config_field;
-			let data: PathBuf = nix_go!(config.tf({ system })).build("out").await?;
+			let data = nix_go!(config.tf({ system }));
+			let data: PathBuf = spawn_blocking(move || data.build("out"))
+				.await
+				.expect("tf.json derivation should not fail")?;
 			let data = fs::read(&data).await?;
 
 			create_dir_all(&dir).await?;
modifiedcrates/nix-eval/Cargo.tomldiffbeforeafterboth
--- a/crates/nix-eval/Cargo.toml
+++ b/crates/nix-eval/Cargo.toml
@@ -11,8 +11,6 @@
 serde = { workspace = true, features = ["derive"] }
 serde_json.workspace = true
 thiserror.workspace = true
-tokio = { workspace = true }
-tokio-util.workspace = true
 tracing.workspace = true
 
 cxx = "1.0.168"
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -750,7 +750,7 @@
 		})?;
 		Ok(out)
 	}
-	pub async fn build(&self, output: &str) -> Result<PathBuf> {
+	pub fn build(&self, output: &str) -> Result<PathBuf> {
 		if !self.is_derivation() {
 			bail!("expected derivation to build")
 		}