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 for install, convert, and reenroll plans.
95///
96/// Enrollment is always ephemeral: the newly minted credential is routed
97/// directly to a declared Nix runtime-secret input and delivered to the host at
98/// activation, never written to a durable consumer repository or captured into
99/// the build closure. The strategy is persisted in the reviewed plan and
100/// therefore covered by the plan digest.
101///
102/// This names the credential's lifetime, not the enrolled node's. A control
103/// plane may have its own same-named node-lifetime flag — Headscale does — and
104/// the two are independent.
105#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
106#[serde(rename_all = "kebab-case")]
107pub enum EnrollmentStrategy {
108	/// Route the newly minted value directly to a declared Nix runtime-secret
109	/// input. No durable consumer-repository write is permitted.
110	#[default]
111	Ephemeral,
112}
113
114impl EnrollmentStrategy {
115	pub fn as_str(self) -> &'static str {
116		match self {
117			Self::Ephemeral => "ephemeral",
118		}
119	}
120}
121
122impl LifecycleIntent {
123	pub fn as_str(self) -> &'static str {
124		match self {
125			Self::Observe => "observe",
126			Self::BuildOnly => "build-only",
127			Self::Switch => "switch",
128			Self::Boot => "boot",
129			Self::Test => "test",
130			Self::Install => "install",
131			Self::Destroy => "destroy",
132			Self::Convert => "convert",
133			Self::Recovery => "recovery",
134			Self::Exec => "exec",
135		}
136	}
137
138	pub fn parse(value: &str) -> Result<Self, String> {
139		match value {
140			"observe" => Ok(Self::Observe),
141			"build-only" => Ok(Self::BuildOnly),
142			"switch" => Ok(Self::Switch),
143			"boot" => Ok(Self::Boot),
144			"test" => Ok(Self::Test),
145			"install" => Ok(Self::Install),
146			"destroy" => Ok(Self::Destroy),
147			"convert" => Ok(Self::Convert),
148			"recovery" => Ok(Self::Recovery),
149			"exec" => Ok(Self::Exec),
150			other => Err(format!(
151				"unsupported lifecycle intent {other:?}; expected observe|build-only|switch|boot|test|install|destroy|convert|recovery|exec"
152			)),
153		}
154	}
155
156	/// Intents that may require destructive approval evidence at apply time.
157	pub fn requires_destructive_approval(self) -> bool {
158		matches!(self, Self::Install | Self::Destroy | Self::Convert | Self::Recovery)
159	}
160
161	/// True when planning/applying must not contact the target host or mutate providers.
162	pub fn forbids_target_mutation(self) -> bool {
163		matches!(self, Self::Observe | Self::BuildOnly)
164	}
165}
166
167/// Whether a selector is a released deployment-target resource id.
168pub fn is_deployment_target_selector(selector: &str) -> bool {
169	selector.starts_with(DEPLOYMENT_TARGET_PREFIX)
170		&& selector.len() > DEPLOYMENT_TARGET_PREFIX.len()
171		&& !selector[DEPLOYMENT_TARGET_PREFIX.len()..].contains('/')
172}
173
174/// Reject camelCase / wrong resource path spellings used in early drafts.
175pub fn reject_invalid_deployment_target_spelling(selector: &str) -> Result<(), String> {
176	if selector.starts_with("deploymentTarget/") {
177		return Err(
178			"deployment target selectors must use deployment-target/<host> (hyphenated), not deploymentTarget/"
179				.to_string(),
180		);
181	}
182	if selector.starts_with("deployment_target/") {
183		return Err(
184			"deployment target selectors must use deployment-target/<host> (hyphenated), not deployment_target/"
185				.to_string(),
186		);
187	}
188	Ok(())
189}
190
191pub fn deployment_target_host_name(resource_id: &str) -> Option<&str> {
192	resource_id.strip_prefix(DEPLOYMENT_TARGET_PREFIX).filter(|name| {
193		!name.is_empty()
194			&& !name.contains('/')
195			&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
196	})
197}
198
199/// Approval requirement derived at plan time for destructive host actions.
200/// Evidence must bind to the exact plan digest and listed resource IDs.
201#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct ApprovalRequirement {
204	pub kind: ApprovalKind,
205	pub risk: PlanActionRisk,
206	pub resource_ids: Vec<String>,
207	/// When true, apply refuses unless evidence.planDigest matches the plan.
208	pub requires_plan_digest_binding: bool,
209}
210
211/// Derive plan-level approval requirements from mutating actions.
212///
213/// Any action with risk `destructive` or `identity-critical` requires
214/// digest-bound interactive confirmation or `--approval-evidence` at apply.
215/// Multiple high-risk actions collapse into one requirement so a single
216/// evidence file can authorize the plan (design: exact digest + resource set).
217pub fn approval_requirements_from_actions(
218	actions: &[crate::plan::PlanAction],
219) -> Vec<ApprovalRequirement> {
220	let mut resource_ids = Vec::new();
221	let mut has_destructive = false;
222	let mut has_identity = false;
223	for action in actions {
224		match action.risk {
225			PlanActionRisk::Destructive => {
226				has_destructive = true;
227				if !resource_ids.contains(&action.resource) {
228					resource_ids.push(action.resource.clone());
229				}
230			}
231			PlanActionRisk::IdentityCritical => {
232				has_identity = true;
233				if !resource_ids.contains(&action.resource) {
234					resource_ids.push(action.resource.clone());
235				}
236			}
237			PlanActionRisk::ReadOnly | PlanActionRisk::Reversible | PlanActionRisk::ServiceImpacting => {}
238		}
239	}
240	if resource_ids.is_empty() {
241		return Vec::new();
242	}
243	resource_ids.sort();
244	// Prefer Destroy when any destructive action is present; otherwise retain
245	// the distinct identity-critical approval class.
246	let (kind, risk) = if has_destructive {
247		(ApprovalKind::Destroy, PlanActionRisk::Destructive)
248	} else if has_identity {
249		(ApprovalKind::Identity, PlanActionRisk::IdentityCritical)
250	} else {
251		return Vec::new();
252	};
253	vec![ApprovalRequirement { kind, risk, resource_ids, requires_plan_digest_binding: true }]
254}
255
256pub fn approval_requirements_for_install(
257	actions: &[crate::plan::PlanAction],
258	mode: InstallMode,
259) -> Vec<ApprovalRequirement> {
260	let kind = match mode {
261		InstallMode::Create | InstallMode::Replace => ApprovalKind::Recreate,
262		InstallMode::Reinstall => ApprovalKind::Overwrite,
263	};
264	approval_requirements_from_actions(actions)
265		.into_iter()
266		.map(|mut requirement| {
267			requirement.kind = kind;
268			requirement
269		})
270		.collect()
271}
272
273#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
274#[serde(rename_all = "kebab-case")]
275pub enum ApprovalKind {
276	Destroy,
277	Recreate,
278	Overwrite,
279	Convert,
280	Identity,
281}
282
283#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
284#[serde(rename_all = "camelCase", deny_unknown_fields)]
285pub struct ApprovalEvidence {
286	pub kind: ApprovalKind,
287	/// Highest risk class covered by this evidence (must meet every requirement).
288	pub risk: PlanActionRisk,
289	pub plan_digest: String,
290	pub resource_ids: Vec<String>,
291	pub expires_at_unix: u64,
292	/// Operator identity that authorized the plan (never a secret).
293	pub principal: String,
294	/// Unix time when the evidence was created or imported.
295	#[serde(default, skip_serializing_if = "is_zero_u64")]
296	pub approved_at_unix: u64,
297}
298
299fn is_zero_u64(value: &u64) -> bool {
300	*value == 0
301}
302
303impl ApprovalEvidence {
304	pub fn validate_for_plan(
305		&self,
306		plan_digest: &str,
307		required: &[ApprovalRequirement],
308		now_unix: u64,
309	) -> Result<(), String> {
310		if self.expires_at_unix < now_unix {
311			return Err(format!(
312				"approval evidence expired at {} (current time {now_unix})",
313				self.expires_at_unix
314			));
315		}
316		if self.principal.trim().is_empty()
317			|| self.principal.len() > 128
318			|| self.principal.chars().any(char::is_control)
319		{
320			return Err("approval evidence principal (operator) is invalid".to_string());
321		}
322		if required.is_empty() {
323			return Err("approval evidence supplied without a requirement".to_string());
324		}
325		if self.resource_ids.is_empty() {
326			return Err("approval evidence resource_ids must not be empty".to_string());
327		}
328		// Evidence must not repeat resource IDs.
329		let unique: BTreeSet<_> = self.resource_ids.iter().collect();
330		if unique.len() != self.resource_ids.len() {
331			return Err("approval evidence resource_ids must be unique".to_string());
332		}
333		for requirement in required {
334			if requirement.requires_plan_digest_binding && self.plan_digest != plan_digest {
335				return Err(format!(
336					"approval evidence planDigest {} does not match plan {}",
337					self.plan_digest, plan_digest
338				));
339			}
340			if self.kind != requirement.kind {
341				return Err(format!(
342					"approval evidence kind {:?} does not match required {:?}",
343					self.kind, requirement.kind
344				));
345			}
346			if risk_rank(self.risk) < risk_rank(requirement.risk) {
347				return Err(format!(
348					"approval evidence risk {:?} is below required {:?}",
349					self.risk, requirement.risk
350				));
351			}
352			let evidence: BTreeSet<_> = self.resource_ids.iter().collect();
353			let required_ids: BTreeSet<_> = requirement.resource_ids.iter().collect();
354			if evidence != required_ids {
355				return Err(format!(
356					"approval evidence resources must exactly match the plan (evidence=[{}], required=[{}])",
357					self.resource_ids.join(", "),
358					requirement.resource_ids.join(", ")
359				));
360			}
361		}
362		Ok(())
363	}
364}
365
366fn risk_rank(risk: PlanActionRisk) -> u8 {
367	match risk {
368		PlanActionRisk::ReadOnly => 0,
369		PlanActionRisk::Reversible => 1,
370		PlanActionRisk::ServiceImpacting => 2,
371		PlanActionRisk::Destructive => 3,
372		PlanActionRisk::IdentityCritical => 4,
373	}
374}
375
376/// Observed state for a deployment target (metadata and optional live paths).
377#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
378#[serde(rename_all = "camelCase", deny_unknown_fields)]
379pub struct ObservedDeploymentTarget {
380	pub resource_id: String,
381	pub hostname: String,
382	pub system: String,
383	pub class: String,
384	/// Declared management address from metadata when present; not probed.
385	#[serde(default, skip_serializing_if = "Option::is_none")]
386	pub declared_address: Option<String>,
387	/// True when observation contacted the target (SSH/local profile read).
388	pub target_contacted: bool,
389	pub observed_digest: String,
390	/// Live active system store path when observed (`/run/current-system` resolve).
391	#[serde(default, skip_serializing_if = "Option::is_none")]
392	pub active_system_path: Option<String>,
393	/// Desired system store path from local pure build when computed for plan.
394	#[serde(default, skip_serializing_if = "Option::is_none")]
395	pub desired_system_path: Option<String>,
396	/// True when both paths are present and equal (activation may be noop).
397	#[serde(default)]
398	pub system_path_matches: bool,
399}
400
401#[cfg(test)]
402#[path = "host_lifecycle_tests.rs"]
403mod tests;