Skip to main content

nxd_core/domain/
plan.rs

1use crate::contract::{API_VERSION, canonical_json};
2use crate::host_lifecycle::{
3	ApprovalRequirement, EnrollmentStrategy, InstallMode, LifecycleIntent,
4};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::{BTreeMap, BTreeSet};
8
9#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
10#[serde(rename_all = "camelCase", deny_unknown_fields)]
11pub struct PlanEnvelope {
12	pub api_version: String,
13	pub kind: PlanKind,
14	pub metadata: PlanMetadata,
15	pub spec: PlanSpec,
16}
17
18#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct EventEnvelope {
21	pub api_version: String,
22	pub kind: EventKind,
23	pub metadata: EventMetadata,
24	pub spec: EventSpec,
25}
26
27#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
28pub enum EventKind {
29	Event,
30}
31
32#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct EventMetadata {
35	pub run_id: String,
36	pub sequence: u64,
37}
38
39#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct EventSpec {
42	pub phase: EventPhase,
43	pub action: Option<String>,
44	pub message: String,
45}
46
47#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
48#[serde(rename_all = "kebab-case")]
49pub enum EventPhase {
50	RunStarted,
51	ActionStarted,
52	ActionCompleted,
53	ActionFailed,
54	RunCompleted,
55	RunFailed,
56	RunCancelled,
57}
58
59impl EventEnvelope {
60	pub fn new(run_id: &str, sequence: u64, phase: EventPhase, action: Option<String>) -> Self {
61		let message = match phase {
62			EventPhase::RunStarted => "run started",
63			EventPhase::ActionStarted => "action started",
64			EventPhase::ActionCompleted => "action completed",
65			EventPhase::ActionFailed => "action failed",
66			EventPhase::RunCompleted => "run completed",
67			EventPhase::RunFailed => "run failed",
68			EventPhase::RunCancelled => "run cancelled",
69		};
70		Self {
71			api_version: API_VERSION.to_string(),
72			kind: EventKind::Event,
73			metadata: EventMetadata { run_id: run_id.to_string(), sequence },
74			spec: EventSpec { phase, action, message: message.to_string() },
75		}
76	}
77}
78
79#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
80pub enum PlanKind {
81	Plan,
82}
83
84#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86pub struct PlanMetadata {
87	pub name: String,
88}
89
90#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
91#[serde(rename_all = "camelCase", deny_unknown_fields)]
92pub struct PlanSpec {
93	pub source_digest: String,
94	pub config_digest: String,
95	pub provider_digest: String,
96	pub selectors: Vec<String>,
97	pub expires_at_unix: u64,
98	pub preconditions: Vec<PlanPrecondition>,
99	pub actions: Vec<PlanAction>,
100	/// Operator lifecycle intent for host plans; contributes to plan digest.
101	#[serde(default, skip_serializing_if = "Option::is_none")]
102	pub lifecycle_intent: Option<LifecycleIntent>,
103	/// Explicit existing-guest policy for install plans; contributes to the digest.
104	#[serde(default, skip_serializing_if = "Option::is_none")]
105	pub install_mode: Option<InstallMode>,
106	/// Explicit install/convert enrollment delivery; contributes to the digest.
107	#[serde(default, skip_serializing_if = "Option::is_none")]
108	pub enrollment_strategy: Option<EnrollmentStrategy>,
109	/// Destructive approval requirements; apply must validate evidence when non-empty.
110	#[serde(default, skip_serializing_if = "Vec::is_empty")]
111	pub approval_requirements: Vec<ApprovalRequirement>,
112	/// Provider runtimes that produced this plan (identity-bound).
113	#[serde(default, skip_serializing_if = "Vec::is_empty")]
114	pub provider_runtimes: Vec<ProviderRuntimeRecord>,
115	/// Secret executables required by action bindings in this exact plan.
116	#[serde(default, skip_serializing_if = "Vec::is_empty")]
117	pub secret_runtimes: Vec<SecretRuntimeRecord>,
118}
119
120/// Immutable identity of a linked or external provider used for plan/apply/verify.
121#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
122#[serde(rename_all = "camelCase", deny_unknown_fields)]
123pub struct ProviderRuntimeRecord {
124	pub provider_instance: String,
125	pub provider_kind: String,
126	pub provider_version: String,
127	pub runtime_digest: String,
128	#[serde(default, skip_serializing_if = "Option::is_none")]
129	pub nxd_revision: Option<String>,
130	#[serde(default, skip_serializing_if = "Option::is_none")]
131	pub protocol: Option<String>,
132	#[serde(default, skip_serializing_if = "Option::is_none")]
133	pub executable_path: Option<String>,
134	#[serde(default, skip_serializing_if = "Option::is_none")]
135	pub executable_digest: Option<String>,
136	#[serde(default, skip_serializing_if = "Vec::is_empty")]
137	pub capabilities: Vec<String>,
138	#[serde(default, skip_serializing_if = "Vec::is_empty")]
139	pub resource_schema_digests: Vec<String>,
140	#[serde(default, skip_serializing_if = "Vec::is_empty")]
141	pub arguments: Vec<String>,
142}
143
144#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct SecretRuntimeRecord {
147	pub resolver_id: String,
148	pub protocol: String,
149	pub executable_path: String,
150	pub executable_digest: String,
151	#[serde(default, skip_serializing_if = "Vec::is_empty")]
152	pub arguments: Vec<String>,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub struct PlanInputDigests {
157	pub source: String,
158	pub config: String,
159	pub provider: String,
160}
161
162#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase", deny_unknown_fields)]
164pub struct PlanAction {
165	pub id: String,
166	pub provider_instance: String,
167	pub resource: String,
168	pub operation: PlanActionOperation,
169	pub risk: PlanActionRisk,
170	pub depends_on: Vec<String>,
171	pub lock_keys: Vec<String>,
172	pub timeout_seconds: u64,
173	pub secret_references: Vec<String>,
174	pub details: Value,
175}
176
177#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
178#[serde(rename_all = "camelCase", deny_unknown_fields)]
179pub struct PlanPrecondition {
180	pub resource: String,
181	pub observed_digest: String,
182}
183
184#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
185#[serde(rename_all = "lowercase")]
186pub enum PlanActionOperation {
187	Create,
188	Update,
189	Delete,
190	Noop,
191}
192
193#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
194#[serde(rename_all = "kebab-case")]
195pub enum PlanActionRisk {
196	ReadOnly,
197	Reversible,
198	ServiceImpacting,
199	Destructive,
200	IdentityCritical,
201}
202
203#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct RunEnvelope {
206	pub api_version: String,
207	pub kind: RunKind,
208	pub metadata: RunMetadata,
209	pub spec: RunSpec,
210	pub status: RunStatus,
211}
212
213#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
214pub enum RunKind {
215	Run,
216}
217
218#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
219#[serde(rename_all = "camelCase", deny_unknown_fields)]
220pub struct RunMetadata {
221	pub id: String,
222}
223
224#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
225#[serde(rename_all = "camelCase", deny_unknown_fields)]
226pub struct RunSpec {
227	pub plan_digest: String,
228}
229
230#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
231#[serde(rename_all = "camelCase", deny_unknown_fields)]
232pub struct RunStatus {
233	pub phase: RunPhase,
234	pub completed_actions: Vec<String>,
235}
236
237#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
238#[serde(rename_all = "kebab-case")]
239pub enum RunPhase {
240	Pending,
241	Running,
242	Succeeded,
243	Failed,
244	Cancelled,
245}
246
247impl PlanEnvelope {
248	/// Build a plan and synthesize approval requirements from high-risk actions.
249	///
250	/// Destructive and identity-critical actions require digest-bound confirmation
251	/// or `--approval-evidence` at apply (see `host_lifecycle::approval_requirements_from_actions`).
252	pub fn new(
253		name: impl Into<String>,
254		input_digests: PlanInputDigests,
255		selectors: Vec<String>,
256		expires_at_unix: u64,
257		preconditions: Vec<PlanPrecondition>,
258		actions: Vec<PlanAction>,
259	) -> Self {
260		let approval_requirements = crate::host_lifecycle::approval_requirements_from_actions(&actions);
261		Self::new_with_lifecycle(
262			name,
263			input_digests,
264			selectors,
265			expires_at_unix,
266			preconditions,
267			actions,
268			None,
269			approval_requirements,
270		)
271	}
272
273	#[allow(clippy::too_many_arguments)]
274	pub fn new_with_lifecycle(
275		name: impl Into<String>,
276		input_digests: PlanInputDigests,
277		selectors: Vec<String>,
278		expires_at_unix: u64,
279		preconditions: Vec<PlanPrecondition>,
280		actions: Vec<PlanAction>,
281		lifecycle_intent: Option<LifecycleIntent>,
282		approval_requirements: Vec<ApprovalRequirement>,
283	) -> Self {
284		// When the caller does not supply explicit requirements (empty vec) but
285		// actions are high-risk, still synthesize — except deployment-target plans
286		// which pass explicit requirements (possibly empty for observe/switch).
287		let approval_requirements = if approval_requirements.is_empty() && lifecycle_intent.is_none() {
288			crate::host_lifecycle::approval_requirements_from_actions(&actions)
289		} else {
290			approval_requirements
291		};
292		Self {
293			api_version: API_VERSION.to_string(),
294			kind: PlanKind::Plan,
295			metadata: PlanMetadata { name: name.into() },
296			spec: PlanSpec {
297				source_digest: input_digests.source,
298				config_digest: input_digests.config,
299				provider_digest: input_digests.provider,
300				selectors,
301				expires_at_unix,
302				preconditions,
303				actions,
304				lifecycle_intent,
305				install_mode: None,
306				enrollment_strategy: None,
307				approval_requirements,
308				provider_runtimes: Vec::new(),
309				secret_runtimes: Vec::new(),
310			},
311		}
312	}
313
314	/// Attach composed provider executable identities to a plan (protocol path).
315	pub fn with_provider_runtimes(mut self, runtimes: Vec<ProviderRuntimeRecord>) -> Self {
316		self.spec.provider_runtimes = runtimes;
317		self
318	}
319
320	pub fn with_secret_runtimes(mut self, runtimes: Vec<SecretRuntimeRecord>) -> Self {
321		self.spec.secret_runtimes = runtimes;
322		self
323	}
324
325	pub fn digest(&self) -> Result<String, serde_json::Error> {
326		sha256_hex(&canonical_json(self)?)
327	}
328}
329
330pub fn sha256_hex(input: &str) -> Result<String, serde_json::Error> {
331	Ok(format!("sha256:{}", sha256_hex_bytes(input.as_bytes())))
332}
333
334pub fn parse_plan_envelope(input: &str) -> Result<PlanEnvelope, serde_json::Error> {
335	serde_json::from_str(input)
336}
337
338#[derive(Clone, Debug, PartialEq, Eq)]
339pub enum PlanApplyError {
340	DuplicateActionId(String),
341	MissingActionDependency { action: String, dependency: String },
342	ActionDependencyCycle(Vec<String>),
343}
344
345pub trait CancellationToken: Send + Sync {
346	fn is_cancelled(&self) -> bool;
347}
348
349pub trait EventSink {
350	fn emit(&mut self, event: EventEnvelope);
351}
352
353#[derive(Clone, Copy, Debug, Default)]
354pub struct NeverCancelled;
355
356impl CancellationToken for NeverCancelled {
357	fn is_cancelled(&self) -> bool {
358		false
359	}
360}
361
362impl EventSink for Vec<EventEnvelope> {
363	fn emit(&mut self, event: EventEnvelope) {
364		self.push(event);
365	}
366}
367
368pub fn ordered_plan_actions(actions: &[PlanAction]) -> Result<Vec<PlanAction>, PlanApplyError> {
369	let mut by_id = BTreeMap::new();
370	for action in actions {
371		if by_id.insert(action.id.clone(), action).is_some() {
372			return Err(PlanApplyError::DuplicateActionId(action.id.clone()));
373		}
374	}
375
376	for action in actions {
377		for dependency in &action.depends_on {
378			if !by_id.contains_key(dependency) {
379				return Err(PlanApplyError::MissingActionDependency {
380					action: action.id.clone(),
381					dependency: dependency.clone(),
382				});
383			}
384		}
385	}
386
387	let mut ordered = Vec::with_capacity(actions.len());
388	let mut emitted = BTreeSet::new();
389
390	while ordered.len() < actions.len() {
391		let mut progressed = false;
392		for (id, action) in &by_id {
393			if emitted.contains(id) {
394				continue;
395			}
396			if action.depends_on.iter().all(|dependency| emitted.contains(dependency)) {
397				ordered.push((*action).clone());
398				emitted.insert(id.clone());
399				progressed = true;
400			}
401		}
402		if !progressed {
403			let cycle = by_id.keys().filter(|id| !emitted.contains(*id)).cloned().collect::<Vec<_>>();
404			return Err(PlanApplyError::ActionDependencyCycle(cycle));
405		}
406	}
407
408	Ok(ordered)
409}
410
411pub fn sha256_hex_bytes(input: &[u8]) -> String {
412	const INITIAL: [u32; 8] = [
413		0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
414	];
415	const K: [u32; 64] = [
416		0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
417		0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
418		0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
419		0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
420		0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
421		0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
422		0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
423		0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
424	];
425
426	let mut message = input.to_vec();
427	let bit_len = (message.len() as u64) * 8;
428	message.push(0x80);
429	while (message.len() % 64) != 56 {
430		message.push(0);
431	}
432	message.extend_from_slice(&bit_len.to_be_bytes());
433
434	let mut state = INITIAL;
435	for chunk in message.chunks_exact(64) {
436		let mut words = [0u32; 64];
437		for (index, word) in words.iter_mut().take(16).enumerate() {
438			let offset = index * 4;
439			*word = u32::from_be_bytes([
440				chunk[offset],
441				chunk[offset + 1],
442				chunk[offset + 2],
443				chunk[offset + 3],
444			]);
445		}
446		for index in 16..64 {
447			let s0 = words[index - 15].rotate_right(7)
448				^ words[index - 15].rotate_right(18)
449				^ (words[index - 15] >> 3);
450			let s1 = words[index - 2].rotate_right(17)
451				^ words[index - 2].rotate_right(19)
452				^ (words[index - 2] >> 10);
453			words[index] =
454				words[index - 16].wrapping_add(s0).wrapping_add(words[index - 7]).wrapping_add(s1);
455		}
456
457		let mut a = state[0];
458		let mut b = state[1];
459		let mut c = state[2];
460		let mut d = state[3];
461		let mut e = state[4];
462		let mut f = state[5];
463		let mut g = state[6];
464		let mut h = state[7];
465
466		for index in 0..64 {
467			let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
468			let ch = (e & f) ^ ((!e) & g);
469			let temp1 =
470				h.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[index]).wrapping_add(words[index]);
471			let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
472			let maj = (a & b) ^ (a & c) ^ (b & c);
473			let temp2 = s0.wrapping_add(maj);
474
475			h = g;
476			g = f;
477			f = e;
478			e = d.wrapping_add(temp1);
479			d = c;
480			c = b;
481			b = a;
482			a = temp1.wrapping_add(temp2);
483		}
484
485		state[0] = state[0].wrapping_add(a);
486		state[1] = state[1].wrapping_add(b);
487		state[2] = state[2].wrapping_add(c);
488		state[3] = state[3].wrapping_add(d);
489		state[4] = state[4].wrapping_add(e);
490		state[5] = state[5].wrapping_add(f);
491		state[6] = state[6].wrapping_add(g);
492		state[7] = state[7].wrapping_add(h);
493	}
494
495	let mut output = String::with_capacity(64);
496	for word in state {
497		output.push_str(&format!("{word:08x}"));
498	}
499	output
500}
501
502#[cfg(test)]
503mod tests;