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 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 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 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 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}