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

difftreelog

feat mkAskPass generator

tzqksnqzYaroslav Bolyukin2026-01-22parent: #d706686.patch.diff
in: trunk

8 files changed

modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,3 +23,8 @@
 tokio = { version = "1.45.1", features = ["fs", "macros", "rt", "rt-multi-thread", "sync", "time"] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
+
+[profile.dev]
+panic = "abort"
+[profile.release]
+panic = "abort"
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -121,12 +121,12 @@
 	})
 }
 
-#[derive(Serialize, Deserialize, Clone)]
+#[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct FleetSecretPart {
 	pub raw: SecretData,
 }
 
-#[derive(Serialize, Deserialize, Clone)]
+#[derive(Serialize, Deserialize, Clone, Debug)]
 #[serde(rename_all = "camelCase")]
 #[must_use]
 pub struct FleetSecretData {
@@ -144,7 +144,7 @@
 	pub generation_data: Value,
 }
 
-#[derive(Serialize, Deserialize, Clone)]
+#[derive(Serialize, Deserialize, Clone, Debug)]
 #[serde(rename_all = "camelCase")]
 #[must_use]
 pub struct FleetSecretDistribution {
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
after · crates/fleet-base/src/opts.rs
1use std::{2	collections::BTreeMap,3	env::current_dir,4	ffi::OsString,5	str::FromStr,6	sync::{Arc, Mutex},7};89use anyhow::{Context, Result, bail};10use nix_eval::{11	FetchSettings, FlakeLockFlags, FlakeReference, FlakeReferenceParseFlags, FlakeSettings, Value,12	gc_now, nix_go, util::assert_warn,13};14use nom::{15	Parser,16	bytes::complete::take_while1,17	character::complete::char,18	combinator::{map, opt},19	multi::separated_list1,20	sequence::{preceded, separated_pair},21};2223use crate::{24	fleetdata::FleetData,25	host::{Config, ConfigHost, FleetConfigInternals},26	primops::{PRIMOPS_DATA, init_primops},27};2829#[derive(Clone)]30pub enum HostItem {31	Host {32		name: String,33		attrs: BTreeMap<String, String>,34	},35	Tag {36		name: String,37		attrs: BTreeMap<String, String>,38	},39}40fn host_item_parser(input: &str) -> Result<HostItem, String> {41	fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {42		err.to_string()43	}4445	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())46		.parse_complete(input)47		.map_err(err_to_string)?;48	let (input, name) = map(49		take_while1(|v| v != ',' && v != '?' && v != '@'),50		str::to_owned,51	)52	.parse_complete(input)53	.map_err(err_to_string)?;5455	let kw_item = separated_pair(56		map(take_while1(|v| v != '&' && v != '='), str::to_owned),57		char('='),58		map(take_while1(|v| v != '&'), str::to_owned),59	);60	let kw = map(separated_list1(char('&'), kw_item), |vec| {61		vec.into_iter().collect::<BTreeMap<_, _>>()62	});63	let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);6465	let (input, attrs) = opt_kw.parse_complete(input).map_err(err_to_string)?;6667	if !input.is_empty() {68		return Err(format!("unexpected trailing input: {input:?}"));69	}70	Ok(if is_tag {71		HostItem::Tag { name, attrs }72	} else {73		HostItem::Host { name, attrs }74	})75}7677// TODO: Rename to HostSelector78#[derive(clap::Parser, Clone)]79pub struct FleetOpts {80	/// All hosts except those would be skipped81	#[clap(long, number_of_values = 1, value_parser = host_item_parser)]82	pub only: Vec<HostItem>,8384	/// Hosts to skip85	#[clap(long, number_of_values = 1)]86	pub skip: Vec<String>,8788	/// Host, which should be threaten as current machine89	// TODO: Replace with connectivity refactor90	#[clap(long, default_value_t = hostname::get().expect("unknown hostname").to_str().expect("hostname is not utf-8").to_owned())]91	pub localhost: String,9293	/// Override detected system for host, to perform builds via94	/// binfmt-declared qemu instead of trying to crosscompile95	#[clap(long, default_value = env!("NIX_SYSTEM"))]96	pub local_system: String,9798	/// By default fleet continues on single derivation build failure99	/// this flag makes command fail immediately100	///101	/// Opposite of Nix's --keep-going102	#[clap(long)]103	pub fail_fast: bool,104}105106impl FleetOpts {107	pub async fn filter_skipped(108		&self,109		hosts: impl IntoIterator<Item = ConfigHost>,110	) -> Result<Vec<ConfigHost>> {111		let mut out = Vec::new();112		for host in hosts {113			if self.should_skip(&host).await? {114				continue;115			}116			out.push(host);117		}118		Ok(out)119	}120	pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {121		if self.skip.iter().any(|h| h as &str == host.name) {122			return Ok(true);123		}124		if self.only.is_empty() {125			return Ok(false);126		}127		let mut have_group_matches = false;128		for item in self.only.iter() {129			match item {130				HostItem::Host { name, .. } if *name == host.name => {131					return Ok(false);132				}133				HostItem::Tag { .. } => {134					have_group_matches = true;135				}136				_ => {}137			}138		}139		if have_group_matches {140			let host_tags = host.tags().await?;141			for item in self.only.iter() {142				match item {143					HostItem::Tag { name, .. } if host_tags.contains(name) => {144						return Ok(false);145					}146					_ => {}147				}148			}149		}150		Ok(true)151	}152	pub async fn action_attr<T: FromStr>(&self, host: &ConfigHost, attr: &str) -> Result<Option<T>>153	where154		T::Err: Sync,155		anyhow::Error: From<T::Err>,156	{157		let str = self.action_attr_str(host, attr).await?;158		Ok(str.map(|v| T::from_str(&v)).transpose()?)159	}160	pub async fn action_attr_str(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {161		if self.only.is_empty() {162			return Ok(None);163		}164		let mut have_group_matches = false;165		for item in self.only.iter() {166			match item {167				HostItem::Host { name, attrs }168					if *name == host.name && attrs.contains_key(attr) =>169				{170					return Ok(attrs.get(attr).cloned());171				}172				HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {173					have_group_matches = true;174				}175				_ => {}176			}177		}178		if have_group_matches {179			let host_tags = host.tags().await?;180			for item in self.only.iter() {181				match item {182					HostItem::Tag { name, attrs }183						if host_tags.contains(name) && attrs.contains_key(attr) =>184					{185						return Ok(attrs.get(attr).cloned());186					}187					_ => {}188				}189			}190		}191		Ok(None)192	}193	pub fn is_local(&self, host: &str) -> bool {194		self.localhost == host195	}196197	// TODO: Config should be detached from opts.198	pub async fn build(&self, nix_args: Vec<OsString>, assert: bool) -> Result<Config> {199		let cwd = current_dir()?;200		let mut directory = cwd.clone();201		let mut fleet_data_path = directory.join("fleet.nix");202		while !fleet_data_path.is_file() {203			// fleet.nix204			fleet_data_path.pop();205			if !directory.pop() || !fleet_data_path.pop() {206				bail!(207					"fleet.nix not found at {} or any of the parent directories",208					cwd.display()209				);210			}211			fleet_data_path.push("fleet.nix");212		}213		let bytes =214			std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;215		let data = Arc::new(Mutex::new(FleetData::from_str(&bytes)?));216217		init_primops();218219		let mut fetch_settings = FetchSettings::new();220		fetch_settings.set(c"warn-dirty", c"false");221222		let mut flake_settings = FlakeSettings::new()?;223		let mut parse = FlakeReferenceParseFlags::new(&flake_settings)?;224		// For some reason, lazy trees not being used when there is no base dir set225		parse.set_base_dir("/")?;226227		let (mut flake, _) = FlakeReference::new(228			directory229				.to_str()230				.ok_or_else(|| anyhow::anyhow!("fleet dir should have utf-8 path"))?,231			&flake_settings,232			&parse,233			&fetch_settings,234		)?;235236		let lock = FlakeLockFlags::new(&flake_settings)?;237238		let flake = flake.lock(&fetch_settings, &flake_settings, &lock)?;239240		let flake = flake.get_attrs(&mut flake_settings)?;241242		let builtins_field = Value::eval("builtins")?;243244		let fleet_root = flake.get_field("fleetConfigurations")?;245		let fleet_field = nix_go!(fleet_root.default(Obj {}));246247		let config_field = nix_go!(fleet_field.config);248249		if assert {250			assert_warn("fleet config evaluation", &config_field)251				.await252				.context("failed to verify assertions")?;253		}254255		let import = nix_go!(builtins_field.import);256		let overlays = nix_go!(config_field.nixpkgs.overlays);257		let nixpkgs = nix_go!(config_field.nixpkgs.buildUsing);258		let nixpkgs_imported = nix_go!(import(nixpkgs));259260		let default_pkgs = nix_go!(nixpkgs_imported(Obj {261			overlays,262			system: self.local_system.clone(),263		}));264265		if cfg!(debug_assertions) {266			gc_now();267		}268		let config = Config(Arc::new(FleetConfigInternals {269			directory,270			data,271			flake_outputs: flake,272			local_system: self.local_system.clone(),273			nix_args,274			config_field,275			default_pkgs,276			nixpkgs,277			localhost: self.localhost.to_owned(),278		}));279280		PRIMOPS_DATA281			.set(config.clone())282			.map_err(|_| ())283			.expect("only one fleet config may exist per process");284		Ok(config)285	}286}
modifiedcrates/fleet-base/src/primops.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/primops.rs
+++ b/crates/fleet-base/src/primops.rs
@@ -1,10 +1,15 @@
-use std::collections::HashMap;
-use std::sync::{Arc, Mutex};
+use std::cell::OnceCell;
+use std::collections::{BTreeMap, HashMap};
+use std::sync::{Arc, Mutex, OnceLock};
 
-use nix_eval::{NativeFn, Value};
-use tracing::info;
+use anyhow::{Context, bail};
+use itertools::Itertools;
+use nix_eval::{NativeFn, Value, nix_go, nix_go_json};
+use serde::Deserialize;
+use tracing::{info, warn};
 
 use crate::fleetdata::{FleetData, FleetSecrets};
+use crate::host::Config;
 
 #[derive(thiserror::Error, Debug)]
 enum Error {}
@@ -23,18 +28,76 @@
 
 struct FsSecretsBackend {}
 
-pub fn init_primops(secrets: Arc<Mutex<FleetData>>) {
+pub static PRIMOPS_DATA: OnceLock<Config> = OnceLock::new();
+
+#[derive(Deserialize, Debug)]
+struct GeneratorPart {
+	encrypted: bool,
+}
+
+pub fn init_primops() {
 	info!("initializing primops");
 	NativeFn::new(
 		c"__fleetEnsureHostSecret",
 		c"Ensure secret existence for a host, regenerating it in case of some mismatch",
 		[c"host", c"secret", c"generator"],
-		|[host, secret, generator]| {
-			todo!("ensure secret");
-			Ok(Value::new_attrs(HashMap::from_iter([(
-				"raw",
-				Value::new_str("rawData"),
-			)])))
+		|es, [host, secret, generator]| {
+			info!("get host");
+			let host = host.to_string()?;
+			info!("get secret");
+			let secret = secret.to_string()?;
+
+			info!("get config");
+			let config = PRIMOPS_DATA
+				.get()
+				.expect("primops data should be set on init");
+
+			info!("get pkgs");
+			let nixpkgs = &config.nixpkgs;
+			let default_pkgs = &config.default_pkgs;
+			let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);
+			let generators = nix_go!(default_mk_secret_generators(Obj {
+				recipients: <Vec<String>>::new(),
+			}));
+			let pkgs_and_generators = default_pkgs.clone().attrs_update(generators)?;
+
+			info!("call package");
+			let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));
+			let default_generator = call_package
+				.call(generator.clone())
+				.context("calling callPackage with generator")?
+				.call(Value::new_attrs(HashMap::new()))
+				.context("providing extra callPackage args")?;
+
+			info!("get parts");
+			let mut parts: BTreeMap<String, GeneratorPart> = nix_go_json!(default_generator.parts);
+			info!("got parts: {parts:?}");
+
+			let Some(existing) = config
+				.host_secret(&host, &secret) else {
+				bail!("missing secret {secret} for host {host}; secret needs regeneration")
+			};
+
+			info!("got existing: {existing:?}");
+
+			let mut out = HashMap::new();
+
+			for (part_name, part) in &existing.secret.parts {
+				let Some(definition) = parts.remove(part_name) else {
+					warn!("secret {secret} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix");
+					continue;
+				};
+				if definition.encrypted != part.raw.encrypted {
+					bail!("secret {secret} part {part_name} is supposed to be {}, but it is {}; secret needs regeneration", if definition.encrypted {"encrypted"} else {"unencrypted"}, if part.raw.encrypted {"encrypted"} else {"unencrypted"});
+				}
+				out.insert(part_name.as_str(), Value::new_attrs(HashMap::from_iter([("raw", Value::new_str(&part.raw.to_string()))])));
+			}
+			if !parts.is_empty(){
+				let defs = parts.keys().collect_vec();
+				bail!("secret parts are defined, but not stored: {defs:?}, secret needs regeneration")
+			}
+
+			Ok(Value::new_attrs(out))
 		},
 	)
 	.register();
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -12,7 +12,7 @@
 use serde::de::DeserializeOwned;
 
 pub use anyhow::Result;
-use tracing::instrument;
+use tracing::{info, instrument, warn};
 
 use self::logging::{ErrorInfoBuilder, nix_logging_cxx};
 use self::nix_cxx::set_fetcher_setting;
@@ -22,7 +22,8 @@
 	GC_thread_is_registered, GC_unregister_my_thread, ListBuilder as c_list_builder, PrimOp,
 	PrimOpFun, Store as c_store, StorePath as c_store_path, alloc_primop, alloc_value,
 	bindings_builder_free, bindings_builder_insert, c_context, c_context_create, c_context_free,
-	clear_err, copy_value, err_code, err_info_msg, err_msg, eval_state_build,
+	clear_err, copy_value, err_NIX_ERR_KEY, err_NIX_ERR_NIX_ERROR, err_NIX_ERR_OVERFLOW,
+	err_NIX_ERR_UNKNOWN, err_code, err_info_msg, err_msg, eval_state_build,
 	eval_state_builder_load, eval_state_builder_new, eval_state_builder_set_eval_setting,
 	expr_eval_from_string, fetchers_settings, fetchers_settings_free, fetchers_settings_new,
 	flake_lock, flake_lock_flags, flake_lock_flags_free, flake_lock_flags_new, flake_reference,
@@ -36,9 +37,8 @@
 	make_attrs, make_bindings_builder, make_list, make_list_builder, realised_string,
 	realised_string_free, realised_string_get_buffer_size, realised_string_get_buffer_start,
 	realised_string_get_store_path, realised_string_get_store_path_count, register_primop,
-	set_err_msg, setting_set, state_free, store_copy_closure, store_get_fs_closure, store_open,
-	store_parse_path, store_path_free, store_path_name, string_realise, value, value_call,
-	value_decref, value_incref,
+	set_err_msg, setting_set, state_free, store_open, store_parse_path, store_path_free,
+	store_path_name, string_realise, value, value_call, value_decref, value_force, value_incref,
 };
 
 // Contains macros helpers
@@ -62,6 +62,7 @@
 		type nix_fetchers_settings;
 		include!("nix-eval/src/lib.hh");
 
+		#[allow(clippy::missing_safety_doc)]
 		unsafe fn set_fetcher_setting(
 			settings: *mut nix_fetchers_settings,
 			setting: *const c_char,
@@ -111,19 +112,19 @@
 #[derive(Debug)]
 #[repr(i32)]
 pub enum NixErrorKind {
-	Unknown = 1,
-	Overflow = 2,
-	Key = 3,
-	Generic = 4,
+	Unknown = err_NIX_ERR_UNKNOWN,
+	Overflow = err_NIX_ERR_OVERFLOW,
+	Key = err_NIX_ERR_KEY,
+	Generic = err_NIX_ERR_NIX_ERROR,
 }
 impl NixErrorKind {
 	fn from_int(v: c_int) -> Option<Self> {
 		Some(match v {
 			0 => return None,
-			-1 => Self::Unknown,
-			-2 => Self::Overflow,
-			-3 => Self::Key,
-			-4 => Self::Generic,
+			nix_raw::err_NIX_ERR_UNKNOWN => Self::Unknown,
+			nix_raw::err_NIX_ERR_OVERFLOW => Self::Overflow,
+			nix_raw::err_NIX_ERR_KEY => Self::Key,
+			nix_raw::err_NIX_ERR_NIX_ERROR => Self::Generic,
 			_ => {
 				debug_assert!(false, "unexpected nix error kind: {v}");
 				Self::Unknown
@@ -298,8 +299,10 @@
 	}
 }
 
-static GLOBAL_STATE: LazyLock<GlobalState> =
-	LazyLock::new(|| GlobalState::new().expect("global state init shouldn't fail"));
+static GLOBAL_STATE: LazyLock<GlobalState> = LazyLock::new(|| {
+	info!("initializing nix global state");
+	GlobalState::new().expect("global state init shouldn't fail")
+});
 
 thread_local! {
 	static THREAD_STATE: RefCell<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));
@@ -424,7 +427,8 @@
 	}
 }
 
-struct EvalState(*mut c_eval_state);
+#[repr(transparent)]
+pub struct EvalState(*mut c_eval_state);
 unsafe impl Send for EvalState {}
 unsafe impl Sync for EvalState {}
 
@@ -697,7 +701,15 @@
 		let builtin = Self::eval("builtins.toString")?;
 		builtin.call(self.clone())
 	}
+	fn force(&mut self, s: *mut nix_raw::EvalState) -> Result<()> {
+		with_default_context(|c, _| unsafe { value_force(c, s, self.0) })?;
+		Ok(())
+	}
 	pub fn to_string(&self) -> Result<String> {
+		let ty = self.type_of();
+		if !matches!(ty, NixType::String) {
+			bail!("unexpected type: {ty:?}, expected string");
+		}
 		let mut str_out = String::new();
 		with_default_context(|c, _| unsafe {
 			get_string(c, self.0, Some(copy_nix_str), (&raw mut str_out).cast())
@@ -931,17 +943,39 @@
 	ret: *mut value,
 ) {
 	let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };
+	let mut e = None;
 	let args: [&Value; N] = array::from_fn(|i| {
-		let v: &Value = unsafe { &*args.add(i).cast_const().cast() };
-		v
+		let v: &mut Value = unsafe { &mut *args.add(i).cast() };
+
+		info!("forcing arg");
+		if matches!(v.type_of(), NixType::Thunk)
+			&& let Err(err) = v.force(state)
+		{
+			e = Some(err);
+		};
+		v as &Value
 	});
+	info!("args forced");
 	let ctx: &mut NixContext = unsafe { &mut *context.cast() };
 
-	match user_closure(args) {
+	if let Some(e) = e {
+		warn!("set err = {e}");
+		unsafe { init_int(context, ret, 0) };
+		return ctx.set_err(
+			NixErrorKind::Unknown,
+			&CString::new(e.to_string()).expect("forcing argument value failed"),
+		);
+	}
+
+	let state: &EvalState = unsafe { std::mem::transmute(&state) };
+
+	match user_closure(state, args) {
 		Ok(v) => {
 			unsafe { copy_value(context, ret, v.0) };
 		}
 		Err(e) => {
+			unsafe { init_int(context, ret, 0) };
+			warn!("set err = {e:#?}");
 			ctx.set_err(
 				NixErrorKind::Unknown,
 				&CString::new(e.to_string()).expect("error should not contain internal nuls"),
@@ -950,7 +984,7 @@
 	}
 }
 
-type UserClosure<const N: usize> = Box<dyn Fn([&Value; N]) -> Result<Value>>;
+type UserClosure<const N: usize> = Box<dyn Fn(&EvalState, [&Value; N]) -> Result<Value>>;
 
 pub struct NativeFn(*mut PrimOp);
 impl NativeFn {
@@ -958,7 +992,7 @@
 		name: &'static CStr,
 		doc: &'static CStr,
 		args: [&'static CStr; N],
-		f: impl Fn([&Value; N]) -> Result<Value> + 'static,
+		f: impl Fn(&EvalState, [&Value; N]) -> Result<Value> + 'static,
 	) -> Self {
 		// Double-boxing to make it thin pointer, as vtable gets outside of first Box
 		let closure: Box<UserClosure<N>> = Box::new(Box::new(f));
@@ -1003,7 +1037,7 @@
 		c"__uppercaseSuffix2",
 		c"make string uppercase and add suffix",
 		[c"str", c"suffix"],
-		|[str, suffix]: [&Value; 2]| {
+		|_, [str, suffix]: [&Value; 2]| {
 			let str = str.to_string()?;
 			let suffix = suffix.to_string()?;
 			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))
@@ -1038,7 +1072,7 @@
 		c"uppercase_suffix",
 		c"make string uppercase and add suffix",
 		[c"str", c"suffix"],
-		|[str, suffix]: [&Value; 2]| {
+		|es, [str, suffix]: [&Value; 2]| {
 			let str = str.to_string()?;
 			let suffix = suffix.to_string()?;
 			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))
modifiedcrates/nix-eval/src/macros.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/macros.rs
+++ b/crates/nix-eval/src/macros.rs
@@ -16,7 +16,12 @@
 		$(nix_expr_inner!(@obj($o) $($tt)*);)?
 	}};
 	(@obj($o:ident)) => {{}};
-	(Obj { $($tt:tt)* }) => {{
+	(Obj { }) => {{
+		use $crate::{nix_expr_inner};
+		let out = std::collections::hash_map::HashMap::new();
+		Value::new_attrs(out)
+	}};
+	(Obj { $($tt:tt)+ }) => {{
 		use $crate::{nix_expr_inner};
 		let mut out = std::collections::hash_map::HashMap::new();
 		nix_expr_inner!(@obj(out) $($tt)*);
modifiedlib/default.nixdiffbeforeafterboth
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -149,6 +149,23 @@
         }
       );
 
+    mkAskPass =
+      { prompt ? "Secret value", part ? "secret" }:
+      (
+        {
+          kdePackages,
+          mkImpureSecretGenerator,
+        }:
+        mkImpureSecretGenerator {
+          # TODO: Escape prompt?
+          script = ''
+            ${kdePackages.kdialog}/bin/kdialog --inputbox "${prompt}" | gh private -o $out/${part}
+          '';
+
+          parts.${part}.encrypted = true;
+        }
+      );
+
     /**
       Generate a random RSA keypair
 
@@ -251,6 +268,7 @@
     mkBytes
     mkHexBytes
     mkBase64Bytes
+    mkAskPass
     ;
 
   strings =
modifiedmodules/nixos/secrets.nixdiffbeforeafterboth
--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -19,7 +19,6 @@
     submodule
     str
     attrsOf
-    nullOr
     unspecified
     uniq
     functionTo
@@ -77,13 +76,12 @@
     {
       options = {
         parts = mkOption {
-          type = attrsOf (secretPartType secretName);
+          type = uniq (attrsOf (secretPartType secretName));
           description = "Definition of secret parts";
         };
         generator = mkOption {
-          type = uniq (nullOr (functionTo package));
+          type = uniq (functionTo package);
           description = "Derivation to evaluate for secret generation";
-          default = null;
         };
         mode = mkOption {
           type = str;
@@ -103,14 +101,25 @@
         };
       };
       config = {
-        parts = builtins.fleetEnsureHostSecret sysConfig.networking.hostName secretName config.generator;
+        # C api is broken in regard to thunks
+        # https://github.com/NixOS/nix/issues/12800
+        parts = let 
+          hostName = sysConfig.networking.hostName;
+          generator = config.generator;
+        in builtins.deepSeq [
+          hostName
+          secretName
+          generator
+        ] (builtins.fleetEnsureHostSecret
+          hostName
+          secretName
+          generator);
       };
     }
   );
-  secretsData = (mapAttrs (_: s: s.definition) config.secrets);
   secretsFile = pkgs.writeTextFile {
     name = "secrets.json";
-    text = toJSON secretsData;
+    text = toJSON config.system.secretsData;
   };
   useSysusers =
     (config.systemd ? sysusers && config.systemd.sysusers.enable)
@@ -121,17 +130,20 @@
     secrets = mkOption {
       type = attrsOf secretType;
       default = { };
-      apply = v: (mapAttrs (_: secret: secret.parts // { definition = secret; }) v);
+      apply = mapAttrs (_: secret: secret.parts // {definition = secret;});
       description = "Host-local secrets";
     };
     system.secretsData = mkOption {
       type = unspecified;
-      default = { };
+      default = mapAttrs (_: s:
+        (removeAttrs s.definition ["generator"]) // {
+          parts = mapAttrs (_: part: removeAttrs part ["data"]) s.definition.parts;
+        }
+      ) config.secrets;
       description = "secrets.json contents";
     };
   };
   config = {
-    system = { inherit secretsData; };
     environment.systemPackages = [ pkgs.fleet-install-secrets ];
 
     systemd.services.fleet-install-secrets = mkIf useSysusers {