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		Self::resolve_target_outputs_with_logger_and_evaluator(
447			source,
448			config,
449			requested_hosts,
450			logger,
451			|cmd| cmd.output(),
452		)
453	}
454
455	pub fn resolve_target_outputs_with_logger_and_evaluator<F>(
456		source: &ConfigSource,
457		config: &mut CanonicalConfig,
458		requested_hosts: &[String],
459		logger: &crate::Logger,
460		evaluator: F,
461	) -> Result<(), NxdError>
462	where
463		F: FnOnce(&mut std::process::Command) -> std::io::Result<std::process::Output>,
464	{
465		if requested_hosts.is_empty() {
466			return Ok(());
467		}
468		let buildable_hosts: Vec<String> = requested_hosts
469			.iter()
470			.filter(|hostname| {
471				!config.spec.resources.iter().any(|resource| match resource {
472					crate::contract::Resource::DeploymentTarget(target) => {
473						let target_host = target.id.strip_prefix("deployment-target/").unwrap_or(&target.id);
474						target_host == *hostname && !target.metadata.build_system
475					}
476					_ => false,
477				})
478			})
479			.cloned()
480			.collect();
481
482		if buildable_hosts.is_empty() {
483			project_target_outputs(config, &[]);
484			return Ok(());
485		}
486		let ConfigSource::NixInstallable(installable) = source else {
487			project_target_outputs(config, &buildable_hosts);
488			return Ok(());
489		};
490		let (flake, attribute) = installable.rsplit_once('#').ok_or_else(|| {
491			NxdError::InvalidConfig(
492				"selected Nix output resolution requires an nxdConfigurations.<site> installable"
493					.to_string(),
494			)
495		})?;
496		let site = attribute.strip_prefix("nxdConfigurations.").ok_or_else(|| {
497			NxdError::InvalidConfig(
498				"selected Nix output resolution requires an nxdConfigurations.<site> installable"
499					.to_string(),
500			)
501		})?;
502		for hostname in &buildable_hosts {
503			let requires_secrets = config.spec.resources.iter().any(|resource| {
504				matches!(resource, crate::contract::Resource::DeploymentTarget(target)
505					if target.id == format!("deployment-target/{hostname}")
506						&& target.metadata.deployment.require_secrets)
507			});
508			if requires_secrets {
509				let source = crate::config::resolve_host_sops_source(hostname).ok_or_else(|| {
510					NxdError::InvalidConfig(format!(
511						"deployment-target/{hostname} requires installer secrets but no host SOPS input resolves"
512					))
513				})?;
514				if !source.path.is_file() {
515					return Err(NxdError::InvalidConfig(format!(
516						"deployment-target/{hostname} requires installer secrets but {} is not a file",
517						source.path.display()
518					)));
519				}
520			}
521		}
522		// Secret staging stays silent: it names host secret file paths, and this
523		// phase's progress is reported below without them.
524		let secret_store = if flake_declares_secret_input(flake) {
525			crate::workspace::source::stage_hosts_installer_input(
526				&buildable_hosts,
527				&crate::process::Logger::silent(),
528			)
529			.map_err(|error| NxdError::Io(format!("failed to stage selected host secrets: {error}")))?
530		} else {
531			None
532		};
533
534		logger.info("evaluating selected target outputs...");
535		let eval_start = std::time::Instant::now();
536		let output_installable = format!("{flake}#nxdTargetOutputs.{site}");
537		let resolved: BTreeMap<String, ResolvedTargetOutput> = {
538			let _span = crate::profiling::Span::new("selected-target-output-eval");
539			if buildable_hosts.is_empty() {
540				BTreeMap::new()
541			} else {
542				let hosts_json = serde_json::to_string(&buildable_hosts)?;
543				let hosts_literal = serde_json::to_string(&hosts_json)?;
544				let apply_expression = format!(
545					"let requested = builtins.fromJSON {hosts_literal}; in outputs: builtins.listToAttrs (map (name: {{ inherit name; value = outputs.${{name}} or (throw (\"missing target output \" + name)); }}) requested)"
546				);
547				let mut command = crate::process::sanitized_command("nix");
548				command.args([
549					"--extra-experimental-features",
550					"nix-command flakes",
551					"eval",
552					"--json",
553					"--impure",
554					"--no-eval-cache",
555					"--no-write-lock-file",
556					"--apply",
557					&apply_expression,
558				]);
559				if let Some(secret_store) = &secret_store {
560					command.args([
561						"--override-input",
562						crate::config::SECRET_INPUT_NAME,
563						&format!("path:{secret_store}"),
564					]);
565				}
566				command.arg(&output_installable);
567				let output = evaluator(&mut command).map_err(|error| {
568					NxdError::Io(format!("failed to resolve selected Nix outputs: {error}"))
569				})?;
570				if !output.status.success() {
571					return Err(NxdError::Io(format!(
572						"selected Nix output evaluation failed for {output_installable}: {}",
573						String::from_utf8_lossy(&output.stderr).trim()
574					)));
575				}
576				serde_json::from_slice(&output.stdout).map_err(|error| {
577					NxdError::InvalidConfig(format!("invalid selected Nix output map: {error}"))
578				})?
579			}
580		};
581		logger.info(&format!(
582			"evaluated selected target outputs in {}",
583			crate::progress::log::format_elapsed(eval_start.elapsed())
584		));
585		let needs_remote_evaluation = config.spec.resources.iter().any(|resource| {
586			matches!(resource, crate::contract::Resource::DeploymentTarget(target)
587				if buildable_hosts.iter().any(|hostname| target.id == format!("deployment-target/{hostname}"))
588					&& !target.metadata.deployment.local_eval)
589		});
590		let (evaluation_source, evaluation_source_inputs, source_declares_secret_input) =
591			if needs_remote_evaluation {
592				logger.info("archiving evaluation source for remote targets...");
593				let archive_start = std::time::Instant::now();
594				let archive = crate::process::sanitized_command("nix")
595					.args([
596						"--extra-experimental-features",
597						"nix-command flakes",
598						"flake",
599						"archive",
600						"--json",
601						"--no-write-lock-file",
602						flake,
603					])
604					.output()
605					.map_err(|error| NxdError::Io(format!("failed to archive evaluation source: {error}")))?;
606				if !archive.status.success() {
607					return Err(NxdError::Io(format!(
608						"failed to archive evaluation source: {}",
609						String::from_utf8_lossy(&archive.stderr).trim()
610					)));
611				}
612				let archived: serde_json::Value =
613					serde_json::from_slice(&archive.stdout).map_err(|error| {
614						NxdError::InvalidConfig(format!("invalid archived evaluation source: {error}"))
615					})?;
616				let source = archived
617					.get("path")
618					.and_then(serde_json::Value::as_str)
619					.filter(|path| path.starts_with("/nix/store/"))
620					.ok_or_else(|| {
621						NxdError::InvalidConfig(
622							"archived evaluation source did not return a Nix store path".to_string(),
623						)
624					})?
625					.to_string();
626				let inputs = archived_input_paths(&archived)?;
627				logger.info(&format!(
628					"archived evaluation source in {}",
629					crate::progress::log::format_elapsed(archive_start.elapsed())
630				));
631				(Some(source), inputs, archive_declares_secret_input(&archived))
632			} else {
633				(None, Vec::new(), false)
634			};
635		for resource in &mut config.spec.resources {
636			if let crate::contract::Resource::DeploymentTarget(target) = resource {
637				let hostname = target.id.strip_prefix("deployment-target/").unwrap_or(&target.id);
638				if let Some(binding) = resolved.get(hostname) {
639					target.metadata.system_output = binding.system_output.clone();
640					target.metadata.system_derivation = binding.system_derivation.clone();
641					target.metadata.disko_output = binding.disko_output.clone();
642					if !target.metadata.deployment.local_eval {
643						target.metadata.evaluation_source = evaluation_source.clone();
644						target.metadata.evaluation_source_inputs = evaluation_source_inputs.clone();
645						if source_declares_secret_input {
646							target.metadata.evaluation_secret_source = secret_store.clone();
647						}
648					}
649				}
650			}
651		}
652		Ok(())
653	}
654
655	fn render_config_projected(
656		source: ConfigSource,
657		requested_hosts: Option<&[String]>,
658	) -> Result<CanonicalConfig, NxdError> {
659		match source {
660			ConfigSource::CanonicalJson(path) => {
661				let content = fs::read_to_string(&path).map_err(|error| {
662					NxdError::Io(format!("failed to read config JSON {}: {error}", path.display()))
663				})?;
664				let mut config = parse_canonical_config(&content).map_err(|error| {
665					NxdError::InvalidConfig(format!("invalid canonical config JSON: {error}"))
666				})?;
667				if let Some(hosts) = requested_hosts {
668					project_target_outputs(&mut config, hosts);
669				}
670				Ok(config)
671			}
672			ConfigSource::NixInstallable(installable) => {
673				if installable.trim().is_empty()
674					|| installable.contains(char::is_control)
675					|| installable.starts_with('-')
676				{
677					return Err(NxdError::InvalidConfig(
678						"Nix installable must be a non-empty positional value".to_string(),
679					));
680				}
681				let mut command = crate::process::sanitized_command("nix");
682				command.args([
683					"--extra-experimental-features",
684					"nix-command flakes",
685					"eval",
686					"--json",
687					"--impure",
688					"--no-eval-cache",
689					"--no-write-lock-file",
690				]);
691				let apply_expression;
692				if let Some(hosts) = requested_hosts {
693					let hosts_json = serde_json::to_string(hosts)?;
694					let hosts_literal = serde_json::to_string(&hosts_json)?;
695					apply_expression = format!(
696						"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; }}; }}"
697					);
698					command.args(["--apply", &apply_expression]);
699				}
700				let output = command
701					.arg(&installable)
702					.output()
703					.map_err(|error| NxdError::Io(format!("failed to execute Nix evaluator: {error}")))?;
704				if !output.status.success() {
705					let stderr = String::from_utf8_lossy(&output.stderr);
706					let stdout = String::from_utf8_lossy(&output.stdout);
707					let detail = if !stderr.trim().is_empty() {
708						stderr.trim().to_string()
709					} else {
710						stdout.trim().to_string()
711					};
712					return Err(NxdError::Io(format!(
713						"Nix evaluator failed for {installable} with status {}: {detail}",
714						output.status
715					)));
716				}
717				serde_json::from_slice::<CanonicalConfig>(&output.stdout).map_err(|error| {
718					NxdError::InvalidConfig(format!("invalid canonical Nix output: {error}"))
719				})
720			}
721		}
722	}
723
724	pub fn plan(&self, request: PlanRequest) -> Result<PlanEnvelope, NxdError> {
725		self.plan_request(request, &crate::Logger::silent())
726	}
727
728	pub fn plan_with_progress(
729		&self,
730		request: PlanRequest,
731		logger: crate::Logger,
732	) -> Result<PlanEnvelope, NxdError> {
733		self.plan_request(request, &logger)
734	}
735
736	pub fn exec_plan(
737		&self,
738		request: PlanRequest,
739		argv: Vec<String>,
740	) -> Result<PlanEnvelope, NxdError> {
741		self.exec_plan_with_progress(request, argv, crate::Logger::silent())
742	}
743
744	pub fn exec_plan_with_progress(
745		&self,
746		request: PlanRequest,
747		argv: Vec<String>,
748		logger: crate::Logger,
749	) -> Result<PlanEnvelope, NxdError> {
750		if argv.is_empty()
751			|| argv.iter().any(|value| value.is_empty() || value.contains(char::is_control))
752		{
753			return Err(NxdError::InvalidConfig(
754				"exec requires a non-empty argv vector without empty or control-character arguments"
755					.to_string(),
756			));
757		}
758		self.plan_request_with_exec(request, Some(argv), &logger)
759	}
760
761	pub fn info(&self, request: InfoRequest) -> Result<InfoReport, NxdError> {
762		let config = match self.current_config() {
763			Some(config) => config,
764			None => Self::render_config_inventory(request.source)?,
765		};
766		validate_canonical_config(&config).map_err(|error| NxdError::InvalidConfig(error.0))?;
767		provider_lifecycle::deployment_target_info(
768			self.external_runtime(),
769			&config,
770			&request.target,
771			request.observe_ip,
772			request.wait,
773		)
774	}
775
776	pub fn apply(&self, request: ApplyRequest) -> Result<ApplyReport, NxdError> {
777		self.apply_request(request, &crate::Logger::silent())
778	}
779
780	pub fn apply_with_progress(
781		&self,
782		request: ApplyRequest,
783		logger: crate::Logger,
784	) -> Result<ApplyReport, NxdError> {
785		self.apply_request(request, &logger)
786	}
787
788	/// Create digest-bound approval evidence for a reviewed plan (no mutation).
789	pub fn create_approval(
790		&self,
791		request: CreateApprovalRequest,
792	) -> Result<crate::host_lifecycle::ApprovalEvidence, NxdError> {
793		let content = fs::read_to_string(&request.plan).map_err(|error| {
794			NxdError::Io(format!("failed to read plan {}: {error}", request.plan.display()))
795		})?;
796		let plan = parse_plan_envelope(&content)
797			.map_err(|error| NxdError::InvalidConfig(format!("invalid plan JSON: {error}")))?;
798		let plan_digest = plan
799			.digest()
800			.map_err(|error| NxdError::InvalidConfig(format!("failed to digest plan: {error}")))?;
801		let now = std::time::SystemTime::now()
802			.duration_since(std::time::UNIX_EPOCH)
803			.map(|duration| duration.as_secs())
804			.unwrap_or(0);
805		deployment_target::approval::create_approval_evidence(
806			&plan,
807			&plan_digest,
808			&request.principal,
809			now,
810			request.expires_at_unix,
811		)
812		.map_err(NxdError::PolicyRejected)
813	}
814
815	/// Import and validate approval evidence against a plan without applying.
816	pub fn validate_approval(&self, request: ValidateApprovalRequest) -> Result<(), NxdError> {
817		let content = fs::read_to_string(&request.plan).map_err(|error| {
818			NxdError::Io(format!("failed to read plan {}: {error}", request.plan.display()))
819		})?;
820		let plan = parse_plan_envelope(&content)
821			.map_err(|error| NxdError::InvalidConfig(format!("invalid plan JSON: {error}")))?;
822		let plan_digest = plan
823			.digest()
824			.map_err(|error| NxdError::InvalidConfig(format!("failed to digest plan: {error}")))?;
825		let evidence = deployment_target::approval::load_approval_evidence(&request.approval_evidence)
826			.map_err(NxdError::InvalidConfig)?;
827		let now = std::time::SystemTime::now()
828			.duration_since(std::time::UNIX_EPOCH)
829			.map(|duration| duration.as_secs())
830			.unwrap_or(0);
831		deployment_target::approval::validate_approval_for_plan(&plan, &plan_digest, &evidence, now)
832			.map_err(NxdError::PolicyRejected)
833	}
834
835	pub fn show_run(&self, run_id: Option<&str>) -> Result<serde_json::Value, NxdError> {
836		self.show_run_request(run_id)
837	}
838
839	pub fn monitor_run(&self, run_id: &str) -> Result<MonitorRunReport, NxdError> {
840		self.monitor_run_request(run_id)
841	}
842
843	pub fn cancel_run(&self, run_id: &str) -> Result<CancelRunReport, NxdError> {
844		self.cancel_run_request(run_id)
845	}
846
847	pub fn verify(&self, request: VerifyRequest) -> Result<VerificationReport, NxdError> {
848		verification::verify(self, request)
849	}
850
851	pub fn capture(&self, request: CaptureRequest) -> Result<CaptureReport, NxdError> {
852		self.capture_request(request, &crate::Logger::silent())
853	}
854
855	pub fn capture_with_progress(
856		&self,
857		request: CaptureRequest,
858		logger: crate::Logger,
859	) -> Result<CaptureReport, NxdError> {
860		self.capture_request(request, &logger)
861	}
862
863	pub fn artifact_build(
864		&self,
865		request: ArtifactBuildRequest,
866	) -> Result<ArtifactBuildReport, NxdError> {
867		self.artifact_build_request(request)
868	}
869
870	pub fn artifact_verify(&self, manifest: &Path) -> Result<(), NxdError> {
871		self.artifact_verify_request(manifest)
872	}
873
874	pub fn show_config(&self, selectors: &[String]) -> Result<CanonicalConfig, NxdError> {
875		let mut config = self
876			.current_config()
877			.ok_or_else(|| NxdError::InvalidConfig("show config requires canonical inventory".into()))?;
878		validate_canonical_config(&config).map_err(|error| NxdError::InvalidConfig(error.0))?;
879		if selectors.is_empty() {
880			return Ok(config);
881		}
882		let selection =
883			crate::domain::selection::resolve(&config, selectors).map_err(NxdError::InvalidConfig)?;
884		config
885			.spec
886			.resources
887			.retain(|resource| selection.resource_ids.contains(crate::contract::resource_id(resource)));
888		config.spec.resources.sort_by(|left, right| {
889			crate::contract::resource_id(left).cmp(crate::contract::resource_id(right))
890		});
891		Ok(config)
892	}
893
894	pub fn show_plan(&self, path: &Path) -> Result<PlanDisplay, NxdError> {
895		let content = fs::read_to_string(path)
896			.map_err(|error| NxdError::Io(format!("failed to read plan {}: {error}", path.display())))?;
897		let plan = parse_plan_envelope(&content)
898			.map_err(|error| NxdError::InvalidConfig(format!("invalid plan JSON: {error}")))?;
899		let plan_digest = plan
900			.digest()
901			.map_err(|error| NxdError::InvalidConfig(format!("failed to digest plan: {error}")))?;
902		Ok(PlanDisplay { plan, plan_digest })
903	}
904}
905
906#[cfg(test)]
907mod tests;
908
909mod apply;
910mod artifacts;
911mod capture;
912mod contracts;
913mod deployment_target;
914pub use contracts::*;
915mod host_planes;
916mod planning;
917mod provider_lifecycle;
918pub mod secret_mediation;
919mod verification;
920
921fn source_digest_input(source: &ConfigSource) -> Result<String, NxdError> {
922	match source {
923		ConfigSource::CanonicalJson(path) => fs::read_to_string(path).map_err(|error| {
924			NxdError::Io(format!("failed to read config JSON {}: {error}", path.display()))
925		}),
926		ConfigSource::NixInstallable(installable) => Ok(format!("nix:{installable}")),
927	}
928}