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 pub(super) config: std::sync::Mutex<Option<CanonicalConfig>>,
89 pub(super) external_runtime: crate::adapters::external_runtime::ExternalRuntimeGraph,
91}
92
93struct 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
170fn 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
183fn 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 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 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 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 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 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 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 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 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 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 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}