Skip to main content

nxd_core/domain/
host_lifecycle.rs

1//! Host-lifecycle contracts (intent, selectors, approval).
2//!
3//! Resource IDs use the released spelling `deployment-target/<host>`
4//! (see `schemas/canonical-resource.schema.json`). Lifecycle intent is an
5//! **operator plan request**, not ambient desired state: it is required when
6//! planning deployment targets, is persisted on the plan, and contributes to
7//! the plan digest.
8
9use crate::plan::PlanActionRisk;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeSet;
12
13pub const DEPLOYMENT_TARGET_PREFIX: &str = "deployment-target/";
14
15/// Operator-selected host lifecycle intent.
16///
17/// Intent is not read from host flake desired state. Identical selectors with
18/// different intents must produce different plan digests.
19#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
20#[serde(rename_all = "kebab-case")]
21pub enum LifecycleIntent {
22	/// Side-effect-free observe; plan contains preconditions and noop/empty actions only.
23	Observe,
24	/// Build system derivation/path only; must not require SSH or provider mutation.
25	BuildOnly,
26	/// Activate current generation.
27	Switch,
28	/// Set default boot generation.
29	Boot,
30	/// Temporary activation.
31	Test,
32	/// Install / first-boot / recreate path.
33	Install,
34	/// Explicit guest/provider destroy.
35	Destroy,
36	/// In-place convert.
37	Convert,
38	/// Provider-owned recovery through the normal reviewed plan/apply DAG.
39	Recovery,
40	/// Execute an explicitly reviewed argv vector through the target provider.
41	Exec,
42}
43
44/// Explicit handling of an owning guest during an install lifecycle.
45///
46/// This is operation input, not desired state. It prevents a plain deploy from
47/// silently changing an existing machine's identity or disks.
48#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
49#[serde(rename_all = "kebab-case")]
50pub enum InstallMode {
51	/// Create a missing guest and refuse an existing guest.
52	#[default]
53	Create,
54	/// Preserve the existing guest identity and reinstall it in place.
55	Reinstall,
56	/// Destroy and recreate the guest before installation.
57	Replace,
58}
59
60/// Explicit stable SSH host-identity maintenance requested at plan time.
61///
62/// This is never inferred from observed drift. Both variants produce
63/// identity-critical actions and therefore require digest-bound approval.
64#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
65#[serde(rename_all = "kebab-case")]
66pub enum HostIdentityAction {
67	/// Generate a fresh Ed25519 pair, overwrite the configured private-key
68	/// sink, and publish the derived public key.
69	Rotate,
70	/// Read operator-supplied private material from the configured binding and
71	/// publish only its derived public key.
72	Import,
73}
74
75impl HostIdentityAction {
76	pub fn as_str(self) -> &'static str {
77		match self {
78			Self::Rotate => "rotate",
79			Self::Import => "import",
80		}
81	}
82}
83
84impl InstallMode {
85	pub fn as_str(self) -> &'static str {
86		match self {
87			Self::Create => "create",
88			Self::Reinstall => "reinstall",
89			Self::Replace => "replace",
90		}
91	}
92}
93
94/// Confidential enrollment delivery selected for install and convert plans.
95///
96/// The strategy is explicit operation input, persisted in the reviewed plan,
97/// and therefore covered by the plan digest.
98#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
99#[serde(rename_all = "kebab-case")]
100pub enum EnrollmentStrategy {
101	/// Route the newly minted value directly to a declared Nix runtime-secret
102	/// input. No durable consumer-repository write is permitted.
103	Ephemeral,
104	/// Construct and apply a separate enrollment/sink plan, then evaluate and
105	/// review the install or convert plan again.
106	#[default]
107	DurableStaged,
108}
109
110impl EnrollmentStrategy {
111	pub fn as_str(self) -> &'static str {
112		match self {
113			Self::Ephemeral => "ephemeral",
114			Self::DurableStaged => "durable-staged",
115		}
116	}
117}
118
119impl LifecycleIntent {
120	pub fn as_str(self) -> &'static str {
121		match self {
122			Self::Observe => "observe",
123			Self::BuildOnly => "build-only",
124			Self::Switch => "switch",
125			Self::Boot => "boot",
126			Self::Test => "test",
127			Self::Install => "install",
128			Self::Destroy => "destroy",
129			Self::Convert => "convert",
130			Self::Recovery => "recovery",
131			Self::Exec => "exec",
132		}
133	}
134
135	pub fn parse(value: &str) -> Result<Self, String> {
136		match value {
137			"observe" => Ok(Self::Observe),
138			"build-only" => Ok(Self::BuildOnly),
139			"switch" => Ok(Self::Switch),
140			"boot" => Ok(Self::Boot),
141			"test" => Ok(Self::Test),
142			"install" => Ok(Self::Install),
143			"destroy" => Ok(Self::Destroy),
144			"convert" => Ok(Self::Convert),
145			"recovery" => Ok(Self::Recovery),
146			"exec" => Ok(Self::Exec),
147			other => Err(format!(
148				"unsupported lifecycle intent {other:?}; expected observe|build-only|switch|boot|test|install|destroy|convert|recovery|exec"
149			)),
150		}
151	}
152
153	/// Intents that may require destructive approval evidence at apply time.
154	pub fn requires_destructive_approval(self) -> bool {
155		matches!(self, Self::Install | Self::Destroy | Self::Convert | Self::Recovery)
156	}
157
158	/// True when planning/applying must not contact the target host or mutate providers.
159	pub fn forbids_target_mutation(self) -> bool {
160		matches!(self, Self::Observe | Self::BuildOnly)
161	}
162}
163
164/// Whether a selector is a released deployment-target resource id.
165pub fn is_deployment_target_selector(selector: &str) -> bool {
166	selector.starts_with(DEPLOYMENT_TARGET_PREFIX)
167		&& selector.len() > DEPLOYMENT_TARGET_PREFIX.len()
168		&& !selector[DEPLOYMENT_TARGET_PREFIX.len()..].contains('/')
169}
170
171/// Reject camelCase / wrong resource path spellings used in early drafts.
172pub fn reject_invalid_deployment_target_spelling(selector: &str) -> Result<(), String> {
173	if selector.starts_with("deploymentTarget/") {
174		return Err(
175			"deployment target selectors must use deployment-target/<host> (hyphenated), not deploymentTarget/"
176				.to_string(),
177		);
178	}
179	if selector.starts_with("deployment_target/") {
180		return Err(
181			"deployment target selectors must use deployment-target/<host> (hyphenated), not deployment_target/"
182				.to_string(),
183		);
184	}
185	Ok(())
186}
187
188pub fn deployment_target_host_name(resource_id: &str) -> Option<&str> {
189	resource_id.strip_prefix(DEPLOYMENT_TARGET_PREFIX).filter(|name| {
190		!name.is_empty()
191			&& !name.contains('/')
192			&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
193	})
194}
195
196/// Approval requirement derived at plan time for destructive host actions.
197/// Evidence must bind to the exact plan digest and listed resource IDs.
198#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct ApprovalRequirement {
201	pub kind: ApprovalKind,
202	pub risk: PlanActionRisk,
203	pub resource_ids: Vec<String>,
204	/// When true, apply refuses unless evidence.planDigest matches the plan.
205	pub requires_plan_digest_binding: bool,
206}
207
208/// Derive plan-level approval requirements from mutating actions.
209///
210/// Any action with risk `destructive` or `identity-critical` requires
211/// digest-bound interactive confirmation or `--approval-evidence` at apply.
212/// Multiple high-risk actions collapse into one requirement so a single
213/// evidence file can authorize the plan (design: exact digest + resource set).
214pub fn approval_requirements_from_actions(
215	actions: &[crate::plan::PlanAction],
216) -> Vec<ApprovalRequirement> {
217	let mut resource_ids = Vec::new();
218	let mut has_destructive = false;
219	let mut has_identity = false;
220	for action in actions {
221		match action.risk {
222			PlanActionRisk::Destructive => {
223				has_destructive = true;
224				if !resource_ids.contains(&action.resource) {
225					resource_ids.push(action.resource.clone());
226				}
227			}
228			PlanActionRisk::IdentityCritical => {
229				has_identity = true;
230				if !resource_ids.contains(&action.resource) {
231					resource_ids.push(action.resource.clone());
232				}
233			}
234			PlanActionRisk::ReadOnly | PlanActionRisk::Reversible | PlanActionRisk::ServiceImpacting => {}
235		}
236	}
237	if resource_ids.is_empty() {
238		return Vec::new();
239	}
240	resource_ids.sort();
241	// Prefer Destroy when any destructive action is present; otherwise retain
242	// the distinct identity-critical approval class.
243	let (kind, risk) = if has_destructive {
244		(ApprovalKind::Destroy, PlanActionRisk::Destructive)
245	} else if has_identity {
246		(ApprovalKind::Identity, PlanActionRisk::IdentityCritical)
247	} else {
248		return Vec::new();
249	};
250	vec![ApprovalRequirement { kind, risk, resource_ids, requires_plan_digest_binding: true }]
251}
252
253pub fn approval_requirements_for_install(
254	actions: &[crate::plan::PlanAction],
255	mode: InstallMode,
256) -> Vec<ApprovalRequirement> {
257	let kind = match mode {
258		InstallMode::Create | InstallMode::Replace => ApprovalKind::Recreate,
259		InstallMode::Reinstall => ApprovalKind::Overwrite,
260	};
261	approval_requirements_from_actions(actions)
262		.into_iter()
263		.map(|mut requirement| {
264			requirement.kind = kind;
265			requirement
266		})
267		.collect()
268}
269
270#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
271#[serde(rename_all = "kebab-case")]
272pub enum ApprovalKind {
273	Destroy,
274	Recreate,
275	Overwrite,
276	Convert,
277	Identity,
278}
279
280#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
281#[serde(rename_all = "camelCase", deny_unknown_fields)]
282pub struct ApprovalEvidence {
283	pub kind: ApprovalKind,
284	/// Highest risk class covered by this evidence (must meet every requirement).
285	pub risk: PlanActionRisk,
286	pub plan_digest: String,
287	pub resource_ids: Vec<String>,
288	pub expires_at_unix: u64,
289	/// Operator identity that authorized the plan (never a secret).
290	pub principal: String,
291	/// Unix time when the evidence was created or imported.
292	#[serde(default, skip_serializing_if = "is_zero_u64")]
293	pub approved_at_unix: u64,
294}
295
296fn is_zero_u64(value: &u64) -> bool {
297	*value == 0
298}
299
300impl ApprovalEvidence {
301	pub fn validate_for_plan(
302		&self,
303		plan_digest: &str,
304		required: &[ApprovalRequirement],
305		now_unix: u64,
306	) -> Result<(), String> {
307		if self.expires_at_unix < now_unix {
308			return Err(format!(
309				"approval evidence expired at {} (current time {now_unix})",
310				self.expires_at_unix
311			));
312		}
313		if self.principal.trim().is_empty()
314			|| self.principal.len() > 128
315			|| self.principal.chars().any(char::is_control)
316		{
317			return Err("approval evidence principal (operator) is invalid".to_string());
318		}
319		if required.is_empty() {
320			return Err("approval evidence supplied without a requirement".to_string());
321		}
322		if self.resource_ids.is_empty() {
323			return Err("approval evidence resource_ids must not be empty".to_string());
324		}
325		// Evidence must not repeat resource IDs.
326		let unique: BTreeSet<_> = self.resource_ids.iter().collect();
327		if unique.len() != self.resource_ids.len() {
328			return Err("approval evidence resource_ids must be unique".to_string());
329		}
330		for requirement in required {
331			if requirement.requires_plan_digest_binding && self.plan_digest != plan_digest {
332				return Err(format!(
333					"approval evidence planDigest {} does not match plan {}",
334					self.plan_digest, plan_digest
335				));
336			}
337			if self.kind != requirement.kind {
338				return Err(format!(
339					"approval evidence kind {:?} does not match required {:?}",
340					self.kind, requirement.kind
341				));
342			}
343			if risk_rank(self.risk) < risk_rank(requirement.risk) {
344				return Err(format!(
345					"approval evidence risk {:?} is below required {:?}",
346					self.risk, requirement.risk
347				));
348			}
349			let evidence: BTreeSet<_> = self.resource_ids.iter().collect();
350			let required_ids: BTreeSet<_> = requirement.resource_ids.iter().collect();
351			if evidence != required_ids {
352				return Err(format!(
353					"approval evidence resources must exactly match the plan (evidence=[{}], required=[{}])",
354					self.resource_ids.join(", "),
355					requirement.resource_ids.join(", ")
356				));
357			}
358		}
359		Ok(())
360	}
361}
362
363fn risk_rank(risk: PlanActionRisk) -> u8 {
364	match risk {
365		PlanActionRisk::ReadOnly => 0,
366		PlanActionRisk::Reversible => 1,
367		PlanActionRisk::ServiceImpacting => 2,
368		PlanActionRisk::Destructive => 3,
369		PlanActionRisk::IdentityCritical => 4,
370	}
371}
372
373/// Observed state for a deployment target (metadata and optional live paths).
374#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct ObservedDeploymentTarget {
377	pub resource_id: String,
378	pub hostname: String,
379	pub system: String,
380	pub class: String,
381	/// Declared management address from metadata when present; not probed.
382	#[serde(default, skip_serializing_if = "Option::is_none")]
383	pub declared_address: Option<String>,
384	/// True when observation contacted the target (SSH/local profile read).
385	pub target_contacted: bool,
386	pub observed_digest: String,
387	/// Live active system store path when observed (`/run/current-system` resolve).
388	#[serde(default, skip_serializing_if = "Option::is_none")]
389	pub active_system_path: Option<String>,
390	/// Desired system store path from local pure build when computed for plan.
391	#[serde(default, skip_serializing_if = "Option::is_none")]
392	pub desired_system_path: Option<String>,
393	/// True when both paths are present and equal (activation may be noop).
394	#[serde(default)]
395	pub system_path_matches: bool,
396}
397
398#[cfg(test)]
399#[path = "host_lifecycle_tests.rs"]
400mod tests;