Skip to main content

nxd_core/application/
mod.rs

1use crate::contract::{
2	CanonicalConfig, ErrorCategory, ErrorEnvelope, canonical_json, parse_canonical_config,
3	validate_canonical_config,
4};
5use crate::plan::{PlanEnvelope, parse_plan_envelope, sha256_hex};
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt;
8use std::fs;
9use std::future::Future;
10use std::path::Path;
11
12const DEFAULT_PLAN_TTL_SECONDS: u64 = 3600;
13
14pub(crate) fn default_plan_expires_at_unix() -> u64 {
15	std::time::SystemTime::now()
16		.duration_since(std::time::UNIX_EPOCH)
17		.unwrap_or_default()
18		.as_secs()
19		.saturating_add(DEFAULT_PLAN_TTL_SECONDS)
20}
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum NxdError {
23	Io(String),
24	InvalidConfig(String),
25	StalePrecondition(String),
26	PolicyRejected(String),
27	Provider(crate::ports::provider::ProviderFailure),
28}
29
30impl fmt::Display for NxdError {
31	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32		match self {
33			Self::Io(message) => write!(formatter, "{message}"),
34			Self::InvalidConfig(message) => write!(formatter, "{message}"),
35			Self::StalePrecondition(message) => write!(formatter, "{message}"),
36			Self::PolicyRejected(message) => write!(formatter, "{message}"),
37			Self::Provider(failure) => write!(formatter, "{}", failure.safe_message),
38		}
39	}
40}
41
42impl NxdError {
43	pub const fn category(&self) -> ErrorCategory {
44		match self {
45			Self::InvalidConfig(_) => ErrorCategory::InvalidInput,
46			Self::Io(_) => ErrorCategory::Operational,
47			Self::StalePrecondition(_) => ErrorCategory::StalePrecondition,
48			Self::PolicyRejected(_) => ErrorCategory::PolicyRejected,
49			Self::Provider(failure) => match failure.category {
50				crate::ports::provider::ProviderErrorCategory::InvalidRequest => {
51					ErrorCategory::InvalidInput
52				}
53				crate::ports::provider::ProviderErrorCategory::Incompatible => ErrorCategory::Incompatible,
54				crate::ports::provider::ProviderErrorCategory::Cancelled
55				| crate::ports::provider::ProviderErrorCategory::Timeout
56				| crate::ports::provider::ProviderErrorCategory::Provider
57				| crate::ports::provider::ProviderErrorCategory::Transport
58				| crate::ports::provider::ProviderErrorCategory::Internal => ErrorCategory::Operational,
59			},
60		}
61	}
62
63	pub const fn exit_code(&self) -> u8 {
64		self.category().exit_code()
65	}
66
67	pub fn envelope(&self) -> ErrorEnvelope {
68		ErrorEnvelope::new(self.category(), self.to_string())
69	}
70
71	pub const fn provider_failure(&self) -> Option<&crate::ports::provider::ProviderFailure> {
72		match self {
73			Self::Provider(failure) => Some(failure),
74			_ => None,
75		}
76	}
77}
78
79impl From<serde_json::Error> for NxdError {
80	fn from(error: serde_json::Error) -> Self {
81		Self::InvalidConfig(error.to_string())
82	}
83}
84
85pub struct Application {
86	/// Canonical inventory loaded by the composition root. Nix target outputs may
87	/// be projected out until a lifecycle selection requires them.
88	pub(super) config: std::sync::Mutex<Option<CanonicalConfig>>,
89	/// External provider and secret executables composed from canonical configuration.
90	pub(super) external_runtime: crate::adapters::external_runtime::ExternalRuntimeGraph,
91}
92
93/// Async bridge used by provider-protocol and secret-process composition.
94/// It performs no provider or transport behavior itself.
95struct PveBackupJobRuntime;
96
97impl PveBackupJobRuntime {
98	fn async_runtime() -> &'static tokio::runtime::Runtime {
99		static RUNTIME: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
100		RUNTIME.get_or_init(|| {
101			tokio::runtime::Builder::new_multi_thread()
102				.enable_all()
103				.thread_name("nxd-async-runtime")
104				.build()
105				.expect("failed to create NXD async runtime")
106		})
107	}
108
109	fn run_async<F, T>(future: F) -> Result<T, NxdError>
110	where
111		F: Future<Output = Result<T, NxdError>> + Send + 'static,
112		T: Send + 'static,
113	{
114		let (result_sender, result_receiver) = std::sync::mpsc::sync_channel(1);
115		Self::async_runtime().spawn(async move {
116			let _ = result_sender.send(future.await);
117		});
118		result_receiver
119			.recv()
120			.map_err(|_| NxdError::Io("NXD async runtime worker dropped its result".to_string()))?
121	}
122}
123
124fn project_target_outputs(config: &mut CanonicalConfig, requested_hosts: &[String]) {
125	let requested = requested_hosts.iter().map(String::as_str).collect::<BTreeSet<_>>();
126	for resource in &mut config.spec.resources {
127		if let crate::contract::Resource::DeploymentTarget(target) = resource {
128			let hostname = target.id.strip_prefix("deployment-target/").unwrap_or(&target.id);
129			if !requested.contains(hostname) {
130				target.metadata.system_output = None;
131				target.metadata.system_derivation = None;
132				target.metadata.disko_output = None;
133			}
134		}
135	}
136}
137
138#[derive(serde::Deserialize)]
139#[serde(rename_all = "camelCase")]
140struct ResolvedTargetOutput {
141	system_output: Option<String>,
142	system_derivation: Option<String>,
143	disko_output: Option<String>,
144}
145
146fn archived_input_paths(archive: &serde_json::Value) -> Result<Vec<String>, NxdError> {
147	fn collect(value: &serde_json::Value, paths: &mut BTreeSet<String>) -> Result<(), NxdError> {
148		let Some(inputs) = value.get("inputs").and_then(serde_json::Value::as_object) else {
149			return Ok(());
150		};
151		for input in inputs.values() {
152			if let Some(path) = input.get("path").and_then(serde_json::Value::as_str) {
153				if !path.starts_with("/nix/store/") || path.contains(char::is_control) {
154					return Err(NxdError::InvalidConfig(
155						"archived evaluation input returned an invalid Nix store path".to_string(),
156					));
157				}
158				paths.insert(path.to_string());
159			}
160			collect(input, paths)?;
161		}
162		Ok(())
163	}
164
165	let mut paths = BTreeSet::new();
166	collect(archive, &mut paths)?;
167	Ok(paths.into_iter().collect())
168}
169
170/// Whether the archived flake declares the installer-secret input.
171///
172/// Overriding an input a flake does not declare makes Nix warn and ignore the
173/// flag, so consumers without host secrets would carry a staged path nothing
174/// reads. Only the root's own inputs count: a transitive dependency declaring
175/// the name does not make it overridable on this flake.
176fn archive_declares_secret_input(archive: &serde_json::Value) -> bool {
177	archive
178		.get("inputs")
179		.and_then(serde_json::Value::as_object)
180		.is_some_and(|inputs| inputs.contains_key(crate::config::SECRET_INPUT_NAME))
181}
182
183/// Whether `flake` declares the installer-secret input, read from its lock.
184///
185/// The local evaluation path stages secrets before anything archives the flake,
186/// so it cannot reuse [`archive_declares_secret_input`]. A failed probe reports
187/// `true` so a consumer that does declare the input keeps working: a spurious
188/// override only warns, whereas a missing one loses the host's secrets.
189fn flake_declares_secret_input(flake: &str) -> bool {
190	let Ok(output) = crate::process::sanitized_command("nix")
191		.args([
192			"--extra-experimental-features",
193			"nix-command flakes",
194			"flake",
195			"metadata",
196			"--json",
197			"--no-write-lock-file",
198			flake,
199		])
200		.output()
201	else {
202		return true;
203	};
204	if !output.status.success() {
205		return true;
206	}
207	let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
208		return true;
209	};
210	metadata
211		.pointer("/locks/nodes/root/inputs")
212		.and_then(serde_json::Value::as_object)
213		.is_some_and(|inputs| inputs.contains_key(crate::config::SECRET_INPUT_NAME))
214}
215
216impl Application {
217	pub(super) fn current_config(&self) -> Option<CanonicalConfig> {
218		self.config.lock().ok()?.clone()
219	}
220
221	pub(super) fn update_config(&self, config: &CanonicalConfig) {
222		if let Ok(mut guard) = self.config.lock()
223			&& guard.is_some()
224		{
225			*guard = Some(config.clone());
226		}
227	}
228
229	/// Construct the explicit local-state capability graph used only by
230	/// plan-independent show/monitor/approval/artifact-verification operations.
231	pub fn local_state() -> Self {
232		Self {
233			config: std::sync::Mutex::new(None),
234			external_runtime: crate::adapters::external_runtime::ExternalRuntimeGraph::default(),
235		}
236	}
237
238	/// Construct the application from an already-evaluated configuration.
239	///
240	/// Composition verifies every declared provider/secret executable, records
241	/// immutable digests, and refuses missing capabilities. Concrete in-core
242	/// PVE/PBS/SOPS runtimes are not constructed here.
243	pub fn from_canonical_config(config: &CanonicalConfig) -> Result<Self, NxdError> {
244		Self::from_canonical_config_with_linked(
245			config,
246			&crate::adapters::external_runtime::LinkedProviderRegistry::default(),
247		)
248	}
249
250	pub fn from_canonical_config_with_linked(
251		config: &CanonicalConfig,
252		linked: &crate::adapters::external_runtime::LinkedProviderRegistry,
253	) -> Result<Self, NxdError> {
254		let config = config.clone();
255		validate_canonical_config(&config).map_err(|error| NxdError::InvalidConfig(error.0))?;
256		let external_runtime =
257			crate::adapters::external_runtime::ExternalRuntimeGraph::from_canonical_with_linked(
258				&config, linked,
259			)
260			.map_err(|error| match error {
261				crate::adapters::external_runtime::CompositionError::InvalidConfig(message) => {
262					NxdError::InvalidConfig(message)
263				}
264				crate::adapters::external_runtime::CompositionError::MissingExecutable(message)
265				| crate::adapters::external_runtime::CompositionError::CapabilityUnavailable(message) => {
266					NxdError::InvalidConfig(message)
267				}
268			})?;
269		Ok(Self { config: std::sync::Mutex::new(Some(config)), external_runtime })
270	}
271
272	/// Construct the inventory-only application used by canonical read paths.
273	///
274	/// This validates desired state without composing provider or secret
275	/// executables. Operations requiring live observation must use the complete
276	/// application graph instead.
277	pub fn from_canonical_inventory(config: &CanonicalConfig) -> Result<Self, NxdError> {
278		let config = config.clone();
279		validate_canonical_config(&config).map_err(|error| NxdError::InvalidConfig(error.0))?;
280		Ok(Self {
281			config: std::sync::Mutex::new(Some(config)),
282			external_runtime: crate::adapters::external_runtime::ExternalRuntimeGraph::default(),
283		})
284	}
285
286	pub fn external_runtime(&self) -> &crate::adapters::external_runtime::ExternalRuntimeGraph {
287		&self.external_runtime
288	}
289
290	pub fn from_plan(plan: &PlanEnvelope) -> Result<Self, NxdError> {
291		Self::from_plan_with_linked(
292			plan,
293			&crate::adapters::external_runtime::LinkedProviderRegistry::default(),
294		)
295	}
296
297	pub fn from_plan_with_linked(
298		plan: &PlanEnvelope,
299		linked: &crate::adapters::external_runtime::LinkedProviderRegistry,
300	) -> Result<Self, NxdError> {
301		let external_runtime =
302			crate::adapters::external_runtime::ExternalRuntimeGraph::from_plan_with_linked(plan, linked)
303				.map_err(|error| NxdError::InvalidConfig(error.to_string()))?;
304		Ok(Self { config: std::sync::Mutex::new(None), external_runtime })
305	}
306
307	pub fn validate(&self, request: ValidateRequest) -> Result<ValidationReport, NxdError> {
308		// Prefer the composed application config (packaging already filled optional
309		// packaged provider commands). Re-render only when no composed config is
310		// available (e.g. tests that construct Application without composition).
311		let config = match self.current_config() {
312			Some(config) => config,
313			None => Self::render_config(request.source)?,
314		};
315		validate_canonical_config(&config).map_err(|error| NxdError::InvalidConfig(error.0))?;
316		if request.selectors.is_empty() {
317			return Ok(ValidationReport::new(config.spec.resources.len()));
318		}
319		let selection = crate::domain::selection::resolve(&config, &request.selectors)
320			.map_err(NxdError::InvalidConfig)?;
321		let protocol_selection = provider_lifecycle::classify_protocol_selection(
322			&config,
323			self.external_runtime(),
324			&selection.resource_ids,
325		)?
326		.ok_or_else(|| {
327			NxdError::InvalidConfig("selected resources require configured providers".to_string())
328		})?;
329		for provider_instance in protocol_selection.by_provider.keys() {
330			self
331				.external_runtime()
332				.require_provider(provider_instance)
333				.map_err(|error| NxdError::InvalidConfig(error.to_string()))?;
334		}
335		Ok(ValidationReport::new(config.spec.resources.len()))
336	}
337
338	pub fn render_config(source: ConfigSource) -> Result<CanonicalConfig, NxdError> {
339		Self::render_config_projected(source, None)
340	}
341
342	/// Load canonical inventory without forcing target build outputs. Lifecycle
343	/// planning performs selection against this value before requesting outputs.
344	pub fn render_config_inventory(source: ConfigSource) -> Result<CanonicalConfig, NxdError> {
345		match source {
346			ConfigSource::CanonicalJson(_) => Self::render_config_projected(source, None),
347			ConfigSource::NixInstallable(_) => Self::render_config_projected(source, Some(&[])),
348		}
349	}
350
351	/// Evaluate the lazy canonical inventory for one already-resolved exact
352	/// target. Non-install lifecycle commands omit reserved takeover artifacts
353	/// before Nix forces their derivations.
354	pub fn render_target_inventory(
355		source: ConfigSource,
356		hostname: &str,
357		include_lifecycle_artifacts: bool,
358	) -> Result<CanonicalConfig, NxdError> {
359		let ConfigSource::NixInstallable(installable) = source else {
360			return Self::render_config_inventory(source);
361		};
362		let (flake, attribute) = installable.rsplit_once('#').ok_or_else(|| {
363			NxdError::InvalidConfig(
364				"exact target inventory requires an nxdConfigurations.<site> installable".to_string(),
365			)
366		})?;
367		let site = attribute.strip_prefix("nxdConfigurations.").ok_or_else(|| {
368			NxdError::InvalidConfig(
369				"exact target inventory requires an nxdConfigurations.<site> installable".to_string(),
370			)
371		})?;
372		if hostname.is_empty()
373			|| hostname.contains('/')
374			|| hostname.contains(char::is_control)
375			|| !hostname
376				.chars()
377				.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
378		{
379			return Err(NxdError::InvalidConfig(format!(
380				"invalid exact target inventory hostname {hostname:?}"
381			)));
382		}
383		let hostname_attribute = serde_json::to_string(hostname)?;
384		let target_installable = format!("{flake}#nxdTargetInventories.{site}.{hostname_attribute}");
385		let mut command = crate::process::sanitized_command("nix");
386		command.args([
387			"--extra-experimental-features",
388			"nix-command flakes",
389			"eval",
390			"--json",
391			"--impure",
392			"--no-eval-cache",
393			"--no-write-lock-file",
394		]);
395		if !include_lifecycle_artifacts {
396			command.args(["--apply", "cfg: cfg // { spec = cfg.spec // { artifactSets = []; }; }"]);
397		}
398		let output = {
399			let _span = crate::profiling::Span::new("exact-target-inventory-eval");
400			command.arg(&target_installable).output()
401		}
402		.map_err(|error| NxdError::Io(format!("failed to evaluate exact target inventory: {error}")))?;
403		if !output.status.success() {
404			return Err(NxdError::InvalidConfig(format!(
405				"exact target inventory evaluation failed for {target_installable}: {}",
406				String::from_utf8_lossy(&output.stderr).trim()
407			)));
408		}
409		serde_json::from_slice(&output.stdout).map_err(|error| {
410			NxdError::InvalidConfig(format!(
411				"invalid canonical exact target inventory from {target_installable}: {error}"
412			))
413		})
414	}
415
416	/// Load the same canonical site while retaining build outputs only for the
417	/// already-resolved deployment targets.
418	pub fn render_config_for_hosts(
419		source: ConfigSource,
420		requested_hosts: &[String],
421	) -> Result<CanonicalConfig, NxdError> {
422		let mut config = Self::render_config_inventory(source.clone())?;
423		Self::resolve_target_outputs(&source, &mut config, requested_hosts)?;
424		Ok(config)
425	}
426
427	pub fn resolve_target_outputs(
428		source: &ConfigSource,
429		config: &mut CanonicalConfig,
430		requested_hosts: &[String],
431	) -> Result<(), NxdError> {
432		Self::resolve_target_outputs_with_logger(
433			source,
434			config,
435			requested_hosts,
436			&crate::Logger::silent(),
437		)
438	}
439
440	pub fn resolve_target_outputs_with_logger(
441		source: &ConfigSource,
442		config: &mut CanonicalConfig,
443		requested_hosts: &[String],
444		logger: &crate::Logger,
445	) -> Result<(), NxdError> {
446		if requested_hosts.is_empty() {
447			return Ok(());
448		}
449		let buildable_hosts: Vec<String> = requested_hosts
450			.iter()
451			.filter(|hostname| {
452				!config.spec.resources.iter().any(|resource| match resource {
453					crate::contract::Resource::DeploymentTarget(target) => {
454						let target_host = target.id.strip_prefix("deployment-target/").unwrap_or(&target.id);
455						target_host == *hostname && !target.metadata.build_system
456					}
457					_ => false,
458				})
459			})
460			.cloned()
461			.collect();
462
463		if buildable_hosts.is_empty() {
464			project_target_outputs(config, &[]);
465			return Ok(());
466		}
467		let ConfigSource::NixInstallable(installable) = source else {
468			project_target_outputs(config, &buildable_hosts);
469			return Ok(());
470		};
471		let (flake, attribute) = installable.rsplit_once('#').ok_or_else(|| {
472			NxdError::InvalidConfig(
473				"selected Nix output resolution requires an nxdConfigurations.<site> installable"
474					.to_string(),
475			)
476		})?;
477		let site = attribute.strip_prefix("nxdConfigurations.").ok_or_else(|| {
478			NxdError::InvalidConfig(
479				"selected Nix output resolution requires an nxdConfigurations.<site> installable"
480					.to_string(),
481			)
482		})?;
483		for hostname in &buildable_hosts {
484			let requires_secrets = config.spec.resources.iter().any(|resource| {
485				matches!(resource, crate::contract::Resource::DeploymentTarget(target)
486					if target.id == format!("deployment-target/{hostname}")
487						&& target.metadata.deployment.require_secrets)
488			});
489			if requires_secrets {
490				let source = crate::config::resolve_host_sops_source(hostname).ok_or_else(|| {
491					NxdError::InvalidConfig(format!(
492						"deployment-target/{hostname} requires installer secrets but no host SOPS input resolves"
493					))
494				})?;
495				if !source.path.is_file() {
496					return Err(NxdError::InvalidConfig(format!(
497						"deployment-target/{hostname} requires installer secrets but {} is not a file",
498						source.path.display()
499					)));
500				}
501			}
502		}
503		// Secret staging stays silent: it names host secret file paths, and this
504		// phase's progress is reported below without them.
505		let secret_store = if flake_declares_secret_input(flake) {
506			crate::workspace::source::stage_hosts_installer_input(
507				&buildable_hosts,
508				&crate::process::Logger::silent(),
509			)
510			.map_err(|error| NxdError::Io(format!("failed to stage selected host secrets: {error}")))?
511		} else {
512			None
513		};
514
515		logger.info("evaluating selected target outputs...");
516		let eval_start = std::time::Instant::now();
517		let output_installable = format!("{flake}#nxdTargetOutputs.{site}");
518		let resolved: BTreeMap<String, ResolvedTargetOutput> = {
519			let _span = crate::profiling::Span::new("selected-target-output-eval");
520			if buildable_hosts.len() <= 1 {
521				let hosts_json = serde_json::to_string(&buildable_hosts)?;
522				let hosts_literal = serde_json::to_string(&hosts_json)?;
523				let apply_expression = format!(
524					"let requested = builtins.fromJSON {hosts_literal}; in outputs: builtins.listToAttrs (map (name: {{ inherit name; value = outputs.${{name}} or (throw (\"missing target output \" + name)); }}) requested)"
525				);
526				let mut command = crate::process::sanitized_command("nix");
527				command.args([
528					"--extra-experimental-features",
529					"nix-command flakes",
530					"eval",
531					"--json",
532					"--impure",
533					"--no-eval-cache",
534					"--no-write-lock-file",
535					"--apply",
536					&apply_expression,
537				]);
538				if let Some(secret_store) = &secret_store {
539					command.args([
540						"--override-input",
541						crate::config::SECRET_INPUT_NAME,
542						&format!("path:{secret_store}"),
543					]);
544				}
545				let output = command.arg(&output_installable).output().map_err(|error| {
546					NxdError::Io(format!("failed to resolve selected Nix outputs: {error}"))
547				})?;
548				if !output.status.success() {
549					return Err(NxdError::Io(format!(
550						"selected Nix output evaluation failed for {output_installable}: {}",
551						String::from_utf8_lossy(&output.stderr).trim()
552					)));
553				}
554				serde_json::from_slice(&output.stdout).map_err(|error| {
555					NxdError::InvalidConfig(format!("invalid selected Nix output map: {error}"))
556				})?
557			} else {
558				let mut results = BTreeMap::new();
559				std::thread::scope(|scope| {
560					let mut handles = Vec::new();
561					for hostname in &buildable_hosts {
562						let output_installable = &output_installable;
563						let secret_store = &secret_store;
564						handles.push(scope.spawn(move || {
565							let host_json = serde_json::to_string(&vec![hostname.clone()])?;
566							let host_literal = serde_json::to_string(&host_json)?;
567							let apply_expression = format!(
568								"let requested = builtins.fromJSON {host_literal}; in outputs: builtins.listToAttrs (map (name: {{ inherit name; value = outputs.${{name}} or (throw (\"missing target output \" + name)); }}) requested)"
569							);
570							let mut command = crate::process::sanitized_command("nix");
571							command.args([
572								"--extra-experimental-features",
573								"nix-command flakes",
574								"eval",
575								"--json",
576								"--impure",
577								"--no-eval-cache",
578								"--no-write-lock-file",
579								"--apply",
580								&apply_expression,
581							]);
582							if let Some(secret_store) = secret_store {
583								command.args([
584									"--override-input",
585									crate::config::SECRET_INPUT_NAME,
586									&format!("path:{secret_store}"),
587								]);
588							}
589							let output = command.arg(output_installable).output().map_err(|error| {
590								NxdError::Io(format!("failed to resolve selected Nix outputs: {error}"))
591							})?;
592							if !output.status.success() {
593								return Err(NxdError::Io(format!(
594									"selected Nix output evaluation failed for {output_installable} ({hostname}): {}",
595									String::from_utf8_lossy(&output.stderr).trim()
596								)));
597							}
598							let map: BTreeMap<String, ResolvedTargetOutput> = serde_json::from_slice(&output.stdout).map_err(|error| {
599								NxdError::InvalidConfig(format!("invalid selected Nix output map: {error}"))
600							})?;
601							Ok::<_, NxdError>(map)
602						}));
603					}
604					for handle in handles {
605						let map = handle.join().map_err(|_| {
606							NxdError::Io("selected Nix output evaluation thread panicked".to_string())
607						})??;
608						results.extend(map);
609					}
610					Ok::<_, NxdError>(results)
611				})?
612			}
613		};
614		logger.info(&format!(
615			"evaluated selected target outputs in {}",
616			crate::progress::log::format_elapsed(eval_start.elapsed())
617		));
618		let needs_remote_evaluation = config.spec.resources.iter().any(|resource| {
619			matches!(resource, crate::contract::Resource::DeploymentTarget(target)
620				if buildable_hosts.iter().any(|hostname| target.id == format!("deployment-target/{hostname}"))
621					&& !target.metadata.deployment.local_eval)
622		});
623		let (evaluation_source, evaluation_source_inputs, source_declares_secret_input) =
624			if needs_remote_evaluation {
625				let archive = crate::process::sanitized_command("nix")
626					.args([
627						"--extra-experimental-features",
628						"nix-command flakes",
629						"flake",
630						"archive",
631						"--json",
632						"--no-write-lock-file",
633						flake,
634					])
635					.output()
636					.map_err(|error| NxdError::Io(format!("failed to archive evaluation source: {error}")))?;
637				if !archive.status.success() {
638					return Err(NxdError::Io(format!(
639						"failed to archive evaluation source: {}",
640						String::from_utf8_lossy(&archive.stderr).trim()
641					)));
642				}
643				let archived: serde_json::Value =
644					serde_json::from_slice(&archive.stdout).map_err(|error| {
645						NxdError::InvalidConfig(format!("invalid archived evaluation source: {error}"))
646					})?;
647				let source = archived
648					.get("path")
649					.and_then(serde_json::Value::as_str)
650					.filter(|path| path.starts_with("/nix/store/"))
651					.ok_or_else(|| {
652						NxdError::InvalidConfig(
653							"archived evaluation source did not return a Nix store path".to_string(),
654						)
655					})?
656					.to_string();
657				let inputs = archived_input_paths(&archived)?;
658				(Some(source), inputs, archive_declares_secret_input(&archived))
659			} else {
660				(None, Vec::new(), false)
661			};
662		for resource in &mut config.spec.resources {
663			if let crate::contract::Resource::DeploymentTarget(target) = resource {
664				let hostname = target.id.strip_prefix("deployment-target/").unwrap_or(&target.id);
665				if let Some(binding) = resolved.get(hostname) {
666					target.metadata.system_output = binding.system_output.clone();
667					target.metadata.system_derivation = binding.system_derivation.clone();
668					target.metadata.disko_output = binding.disko_output.clone();
669					if !target.metadata.deployment.local_eval {
670						target.metadata.evaluation_source = evaluation_source.clone();
671						target.metadata.evaluation_source_inputs = evaluation_source_inputs.clone();
672						if source_declares_secret_input {
673							target.metadata.evaluation_secret_source = secret_store.clone();
674						}
675					}
676				}
677			}
678		}
679		Ok(())
680	}
681
682	fn render_config_projected(
683		source: ConfigSource,
684		requested_hosts: Option<&[String]>,
685	) -> Result<CanonicalConfig, NxdError> {
686		match source {
687			ConfigSource::CanonicalJson(path) => {
688				let content = fs::read_to_string(&path).map_err(|error| {
689					NxdError::Io(format!("failed to read config JSON {}: {error}", path.display()))
690				})?;
691				let mut config = parse_canonical_config(&content).map_err(|error| {
692					NxdError::InvalidConfig(format!("invalid canonical config JSON: {error}"))
693				})?;
694				if let Some(hosts) = requested_hosts {
695					project_target_outputs(&mut config, hosts);
696				}
697				Ok(config)
698			}
699			ConfigSource::NixInstallable(installable) => {
700				if installable.trim().is_empty()
701					|| installable.contains(char::is_control)
702					|| installable.starts_with('-')
703				{
704					return Err(NxdError::InvalidConfig(
705						"Nix installable must be a non-empty positional value".to_string(),
706					));
707				}
708				let mut command = crate::process::sanitized_command("nix");
709				command.args([
710					"--extra-experimental-features",
711					"nix-command flakes",
712					"eval",
713					"--json",
714					"--impure",
715					"--no-eval-cache",
716					"--no-write-lock-file",
717				]);
718				let apply_expression;
719				if let Some(hosts) = requested_hosts {
720					let hosts_json = serde_json::to_string(hosts)?;
721					let hosts_literal = serde_json::to_string(&hosts_json)?;
722					apply_expression = format!(
723						"let requested = builtins.fromJSON {hosts_literal}; in cfg: cfg // {{ spec = cfg.spec // {{ resources = map (resource: if (resource.kind or \"\") != \"deploymentTarget\" || builtins.elem (builtins.replaceStrings [\"deployment-target/\"] [\"\"] resource.id) requested then resource else resource // {{ metadata = builtins.removeAttrs resource.metadata [\"systemOutput\" \"systemDerivation\" \"diskoOutput\"]; }}) cfg.spec.resources; }}; }}"
724					);
725					command.args(["--apply", &apply_expression]);
726				}
727				let output = command
728					.arg(&installable)
729					.output()
730					.map_err(|error| NxdError::Io(format!("failed to execute Nix evaluator: {error}")))?;
731				if !output.status.success() {
732					let stderr = String::from_utf8_lossy(&output.stderr);
733					let stdout = String::from_utf8_lossy(&output.stdout);
734					let detail = if !stderr.trim().is_empty() {
735						stderr.trim().to_string()
736					} else {
737						stdout.trim().to_string()
738					};
739					return Err(NxdError::Io(format!(
740						"Nix evaluator failed for {installable} with status {}: {detail}",
741						output.status
742					)));
743				}
744				serde_json::from_slice::<CanonicalConfig>(&output.stdout).map_err(|error| {
745					NxdError::InvalidConfig(format!("invalid canonical Nix output: {error}"))
746				})
747			}
748		}
749	}
750
751	pub fn plan(&self, request: PlanRequest) -> Result<PlanEnvelope, NxdError> {
752		self.plan_request(request, &crate::Logger::silent())
753	}
754
755	pub fn plan_with_progress(
756		&self,
757		request: PlanRequest,
758		logger: crate::Logger,
759	) -> Result<PlanEnvelope, NxdError> {
760		self.plan_request(request, &logger)
761	}
762
763	pub fn exec_plan(
764		&self,
765		request: PlanRequest,
766		argv: Vec<String>,
767	) -> Result<PlanEnvelope, NxdError> {
768		self.exec_plan_with_progress(request, argv, crate::Logger::silent())
769	}
770
771	pub fn exec_plan_with_progress(
772		&self,
773		request: PlanRequest,
774		argv: Vec<String>,
775		logger: crate::Logger,
776	) -> Result<PlanEnvelope, NxdError> {
777		if argv.is_empty()
778			|| argv.iter().any(|value| value.is_empty() || value.contains(char::is_control))
779		{
780			return Err(NxdError::InvalidConfig(
781				"exec requires a non-empty argv vector without empty or control-character arguments"
782					.to_string(),
783			));
784		}
785		self.plan_request_with_exec(request, Some(argv), &logger)
786	}
787
788	pub fn info(&self, request: InfoRequest) -> Result<InfoReport, NxdError> {
789		let config = match self.current_config() {
790			Some(config) => config,
791			None => Self::render_config_inventory(request.source)?,
792		};
793		validate_canonical_config(&config).map_err(|error| NxdError::InvalidConfig(error.0))?;
794		provider_lifecycle::deployment_target_info(
795			self.external_runtime(),
796			&config,
797			&request.target,
798			request.observe_ip,
799			request.wait,
800		)
801	}
802
803	pub fn apply(&self, request: ApplyRequest) -> Result<ApplyReport, NxdError> {
804		self.apply_request(request, &crate::Logger::silent())
805	}
806
807	pub fn apply_with_progress(
808		&self,
809		request: ApplyRequest,
810		logger: crate::Logger,
811	) -> Result<ApplyReport, NxdError> {
812		self.apply_request(request, &logger)
813	}
814
815	/// Create digest-bound approval evidence for a reviewed plan (no mutation).
816	pub fn create_approval(
817		&self,
818		request: CreateApprovalRequest,
819	) -> Result<crate::host_lifecycle::ApprovalEvidence, NxdError> {
820		let content = fs::read_to_string(&request.plan).map_err(|error| {
821			NxdError::Io(format!("failed to read plan {}: {error}", request.plan.display()))
822		})?;
823		let plan = parse_plan_envelope(&content)
824			.map_err(|error| NxdError::InvalidConfig(format!("invalid plan JSON: {error}")))?;
825		let plan_digest = plan
826			.digest()
827			.map_err(|error| NxdError::InvalidConfig(format!("failed to digest plan: {error}")))?;
828		let now = std::time::SystemTime::now()
829			.duration_since(std::time::UNIX_EPOCH)
830			.map(|duration| duration.as_secs())
831			.unwrap_or(0);
832		deployment_target::approval::create_approval_evidence(
833			&plan,
834			&plan_digest,
835			&request.principal,
836			now,
837			request.expires_at_unix,
838		)
839		.map_err(NxdError::PolicyRejected)
840	}
841
842	/// Import and validate approval evidence against a plan without applying.
843	pub fn validate_approval(&self, request: ValidateApprovalRequest) -> Result<(), NxdError> {
844		let content = fs::read_to_string(&request.plan).map_err(|error| {
845			NxdError::Io(format!("failed to read plan {}: {error}", request.plan.display()))
846		})?;
847		let plan = parse_plan_envelope(&content)
848			.map_err(|error| NxdError::InvalidConfig(format!("invalid plan JSON: {error}")))?;
849		let plan_digest = plan
850			.digest()
851			.map_err(|error| NxdError::InvalidConfig(format!("failed to digest plan: {error}")))?;
852		let evidence = deployment_target::approval::load_approval_evidence(&request.approval_evidence)
853			.map_err(NxdError::InvalidConfig)?;
854		let now = std::time::SystemTime::now()
855			.duration_since(std::time::UNIX_EPOCH)
856			.map(|duration| duration.as_secs())
857			.unwrap_or(0);
858		deployment_target::approval::validate_approval_for_plan(&plan, &plan_digest, &evidence, now)
859			.map_err(NxdError::PolicyRejected)
860	}
861
862	pub fn show_run(&self, run_id: Option<&str>) -> Result<serde_json::Value, NxdError> {
863		self.show_run_request(run_id)
864	}
865
866	pub fn monitor_run(&self, run_id: &str) -> Result<MonitorRunReport, NxdError> {
867		self.monitor_run_request(run_id)
868	}
869
870	pub fn cancel_run(&self, run_id: &str) -> Result<CancelRunReport, NxdError> {
871		self.cancel_run_request(run_id)
872	}
873
874	pub fn verify(&self, request: VerifyRequest) -> Result<VerificationReport, NxdError> {
875		verification::verify(self, request)
876	}
877
878	pub fn capture(&self, request: CaptureRequest) -> Result<CaptureReport, NxdError> {
879		self.capture_request(request, &crate::Logger::silent())
880	}
881
882	pub fn capture_with_progress(
883		&self,
884		request: CaptureRequest,
885		logger: crate::Logger,
886	) -> Result<CaptureReport, NxdError> {
887		self.capture_request(request, &logger)
888	}
889
890	pub fn artifact_build(
891		&self,
892		request: ArtifactBuildRequest,
893	) -> Result<ArtifactBuildReport, NxdError> {
894		self.artifact_build_request(request)
895	}
896
897	pub fn artifact_verify(&self, manifest: &Path) -> Result<(), NxdError> {
898		self.artifact_verify_request(manifest)
899	}
900
901	pub fn show_config(&self, selectors: &[String]) -> Result<CanonicalConfig, NxdError> {
902		let mut config = self
903			.current_config()
904			.ok_or_else(|| NxdError::InvalidConfig("show config requires canonical inventory".into()))?;
905		validate_canonical_config(&config).map_err(|error| NxdError::InvalidConfig(error.0))?;
906		if selectors.is_empty() {
907			return Ok(config);
908		}
909		let selection =
910			crate::domain::selection::resolve(&config, selectors).map_err(NxdError::InvalidConfig)?;
911		config
912			.spec
913			.resources
914			.retain(|resource| selection.resource_ids.contains(crate::contract::resource_id(resource)));
915		config.spec.resources.sort_by(|left, right| {
916			crate::contract::resource_id(left).cmp(crate::contract::resource_id(right))
917		});
918		Ok(config)
919	}
920
921	pub fn show_plan(&self, path: &Path) -> Result<PlanDisplay, NxdError> {
922		let content = fs::read_to_string(path)
923			.map_err(|error| NxdError::Io(format!("failed to read plan {}: {error}", path.display())))?;
924		let plan = parse_plan_envelope(&content)
925			.map_err(|error| NxdError::InvalidConfig(format!("invalid plan JSON: {error}")))?;
926		let plan_digest = plan
927			.digest()
928			.map_err(|error| NxdError::InvalidConfig(format!("failed to digest plan: {error}")))?;
929		Ok(PlanDisplay { plan, plan_digest })
930	}
931}
932
933#[cfg(test)]
934mod tests;
935
936mod apply;
937mod artifacts;
938mod capture;
939mod contracts;
940mod deployment_target;
941pub use contracts::*;
942mod host_planes;
943mod planning;
944mod provider_lifecycle;
945pub mod secret_mediation;
946mod verification;
947
948fn source_digest_input(source: &ConfigSource) -> Result<String, NxdError> {
949	match source {
950		ConfigSource::CanonicalJson(path) => fs::read_to_string(path).map_err(|error| {
951			NxdError::Io(format!("failed to read config JSON {}: {error}", path.display()))
952		}),
953		ConfigSource::NixInstallable(installable) => Ok(format!("nix:{installable}")),
954	}
955}