Skip to main content

nxd_core/adapters/
plugin_fixture.rs

1use crate::plugin_protocol::{
2	MAX_EVENTS, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, v1, validate_canonical_json, validate_context,
3};
4use serde_json::Value;
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::PathBuf;
8use std::pin::Pin;
9use std::sync::Mutex;
10use std::time::Duration;
11use tokio_stream::Stream;
12use tonic::{Request, Response, Status};
13use v1::provider_service_server::ProviderService;
14
15#[derive(Debug)]
16pub struct FixtureProvider {
17	mode: String,
18	state: Mutex<BTreeMap<String, Value>>,
19	/// Optional durable state path so multi-process plan/apply/verify shares state.
20	state_file: Option<PathBuf>,
21}
22
23impl FixtureProvider {
24	pub fn new(mode: impl Into<String>) -> Self {
25		Self { mode: mode.into(), state: Mutex::new(BTreeMap::new()), state_file: None }
26	}
27
28	pub fn with_state_file(mode: impl Into<String>, state_file: PathBuf) -> Self {
29		let state = if state_file.is_file() {
30			fs::read_to_string(&state_file)
31				.ok()
32				.and_then(|content| serde_json::from_str(&content).ok())
33				.unwrap_or_default()
34		} else {
35			BTreeMap::new()
36		};
37		Self { mode: mode.into(), state: Mutex::new(state), state_file: Some(state_file) }
38	}
39
40	async fn before_request(&self) {
41		if self.mode == "hang" {
42			tokio::time::sleep(Duration::from_secs(30)).await;
43		}
44	}
45
46	fn context(context: Option<&v1::RequestContext>) -> Result<(), Status> {
47		validate_context(context).map_err(|error| Status::failed_precondition(format!("{error:?}")))
48	}
49
50	fn resources(input: &[u8]) -> Result<BTreeMap<String, Value>, Status> {
51		let value = validate_canonical_json(input)
52			.map_err(|error| Status::invalid_argument(format!("{error:?}")))?;
53		serde_json::from_value(
54			value.get("resources").cloned().unwrap_or_else(|| Value::Object(Default::default())),
55		)
56		.map_err(|_| Status::invalid_argument("resources must be an object"))
57	}
58
59	fn persist_locked(&self, state: &BTreeMap<String, Value>) -> Result<(), Status> {
60		let Some(path) = &self.state_file else {
61			return Ok(());
62		};
63		let encoded =
64			serde_json::to_string(state).map_err(|_| Status::internal("state serialization failed"))?;
65		fs::write(path, encoded).map_err(|_| Status::internal("state file write failed"))?;
66		Ok(())
67	}
68
69	/// Exercise the confidential Observe/Plan/Verify channel without ever
70	/// reflecting the supplied value in a response or status message.
71	fn require_session_secret(
72		&self,
73		secrets: &std::collections::HashMap<String, Vec<u8>>,
74	) -> Result<(), Status> {
75		if self.mode != "session-secret" {
76			return Ok(());
77		}
78		match secrets.get("secret/session") {
79			Some(value) if value.as_slice() == b"fixture-session-secret" => Ok(()),
80			_ => Err(Status::permission_denied("required provider session secret was not supplied")),
81		}
82	}
83}
84
85#[tonic::async_trait]
86impl ProviderService for FixtureProvider {
87	async fn get_metadata(
88		&self,
89		request: Request<v1::GetMetadataRequest>,
90	) -> Result<Response<v1::GetMetadataResponse>, Status> {
91		self.before_request().await;
92		Self::context(request.get_ref().context.as_ref())?;
93		Ok(Response::new(v1::GetMetadataResponse {
94			protocol_version: if self.mode == "incompatible" {
95				"2.0".to_string()
96			} else {
97				PROTOCOL_VERSION.to_string()
98			},
99			provider_kind: "synthetic-fixture".to_string(),
100			provider_version: "0.1.0".to_string(),
101			capabilities: vec![
102				"observe".to_string(),
103				"plan".to_string(),
104				"apply".to_string(),
105				"verify".to_string(),
106				"capture".to_string(),
107				"cancel".to_string(),
108			],
109			max_message_bytes: MAX_MESSAGE_BYTES as u32,
110			max_events: MAX_EVENTS as u32,
111			resource_kinds: vec![],
112		}))
113	}
114
115	async fn observe(
116		&self,
117		request: Request<v1::ObserveRequest>,
118	) -> Result<Response<v1::ObserveResponse>, Status> {
119		self.before_request().await;
120		let request = request.into_inner();
121		Self::context(request.context.as_ref())?;
122		self.require_session_secret(&request.secrets)?;
123		if self.mode == "malformed" {
124			return Ok(Response::new(v1::ObserveResponse { observations_json: b"{".to_vec() }));
125		}
126		let state = self.state.lock().map_err(|_| Status::internal("state lock failed"))?;
127		let observations_json = serde_json::to_vec(&serde_json::json!({"resources": &*state}))
128			.map_err(|_| Status::internal("state serialization failed"))?;
129		Ok(Response::new(v1::ObserveResponse { observations_json }))
130	}
131
132	async fn capture(
133		&self,
134		request: Request<v1::CaptureRequest>,
135	) -> Result<Response<v1::CaptureResponse>, Status> {
136		self.before_request().await;
137		let request = request.into_inner();
138		Self::context(request.context.as_ref())?;
139		self.require_session_secret(&request.secrets)?;
140		let selected = request.resource_ids.into_iter().collect::<std::collections::BTreeSet<_>>();
141		let state = self.state.lock().map_err(|_| Status::internal("state lock failed"))?;
142		let mut artifacts = Vec::new();
143		for resource_id in selected {
144			let Some(value) = state.get(&resource_id) else {
145				continue;
146			};
147			let content =
148				serde_json::to_vec(value).map_err(|_| Status::internal("capture serialization failed"))?;
149			artifacts.push(v1::CaptureArtifact {
150				resource_id: resource_id.clone(),
151				relative_path: format!("resources/{}.json", resource_id.replace('/', "_")),
152				content_digest: format!("sha256:{}", crate::plan::sha256_hex_bytes(&content)),
153				content,
154				mandatory: true,
155				media_type: "application/json".into(),
156			});
157		}
158		Ok(Response::new(v1::CaptureResponse { artifacts }))
159	}
160
161	async fn plan(
162		&self,
163		request: Request<v1::PlanRequest>,
164	) -> Result<Response<v1::PlanResponse>, Status> {
165		self.before_request().await;
166		let request = request.into_inner();
167		Self::context(request.context.as_ref())?;
168		self.require_session_secret(&request.secrets)?;
169		let desired = Self::resources(&request.desired_json)?;
170		let observed = Self::resources(&request.observations_json)?;
171		let mut actions = Vec::new();
172		for (resource_id, value) in &desired {
173			if observed.get(resource_id) != Some(value) {
174				let operation = if observed.contains_key(resource_id) { "update" } else { "create" };
175				let details = serde_json::json!({"value": value});
176				let outputs = if self.mode == "confidential-output" {
177					vec![v1::ActionOutputDecl {
178						binding_id: format!("run/{operation}:{resource_id}/generated"),
179						output_name: "generated".into(),
180						classification: v1::OutputClassification::Confidential.into(),
181						max_bytes: 4096,
182						value_type: v1::BindingValueType::OpaqueBytes.into(),
183						sink_binding: "secret/generated".into(),
184						allow_overwrite: false,
185					}]
186				} else {
187					vec![]
188				};
189				actions.push(v1::PlannedAction {
190					action_id: format!("{operation}:{resource_id}"),
191					resource_id: resource_id.clone(),
192					operation: operation.to_string(),
193					risk: v1::Risk::Reversible.into(),
194					details_json: serde_json::to_vec(&details)
195						.map_err(|_| Status::internal("action serialization failed"))?,
196					secret_references: Vec::new(),
197					depends_on: Vec::new(),
198					contract: Some(v1::ActionContract {
199						deployment_target: String::new(),
200						target_placement: v1::TargetPlacement::Unspecified.into(),
201						outputs,
202						inputs: vec![],
203						timeout_seconds: 600,
204					}),
205				});
206			}
207		}
208		for resource_id in observed.keys().filter(|id| !desired.contains_key(*id)) {
209			actions.push(v1::PlannedAction {
210				action_id: format!("delete:{resource_id}"),
211				resource_id: resource_id.clone(),
212				operation: "delete".to_string(),
213				risk: v1::Risk::Destructive.into(),
214				details_json: b"{}".to_vec(),
215				secret_references: Vec::new(),
216				depends_on: Vec::new(),
217				contract: Some(v1::ActionContract {
218					deployment_target: String::new(),
219					target_placement: v1::TargetPlacement::Unspecified.into(),
220					outputs: vec![],
221					inputs: vec![],
222					timeout_seconds: 600,
223				}),
224			});
225		}
226		actions.sort_by(|left, right| left.action_id.cmp(&right.action_id));
227		Ok(Response::new(v1::PlanResponse { actions }))
228	}
229
230	type ApplyStream =
231		Pin<Box<dyn Stream<Item = Result<v1::ApplyResponse, Status>> + Send + 'static>>;
232
233	async fn apply(
234		&self,
235		request: Request<v1::ApplyRequest>,
236	) -> Result<Response<Self::ApplyStream>, Status> {
237		self.before_request().await;
238		let request = request.into_inner();
239		Self::context(request.context.as_ref())?;
240		let action = request.action.ok_or_else(|| Status::invalid_argument("action is required"))?;
241		let mut state = self.state.lock().map_err(|_| Status::internal("state lock failed"))?;
242		match action.operation.as_str() {
243			"create" | "update" => {
244				let details = validate_canonical_json(&action.details_json)
245					.map_err(|error| Status::invalid_argument(format!("{error:?}")))?;
246				let value = details
247					.get("value")
248					.cloned()
249					.ok_or_else(|| Status::invalid_argument("action value is required"))?;
250				state.insert(action.resource_id.clone(), value);
251			}
252			"delete" => {
253				state.remove(&action.resource_id);
254			}
255			_ => return Err(Status::invalid_argument("unsupported operation")),
256		}
257		self.persist_locked(&state)?;
258		drop(state);
259		// Flood past the supervisor streamed-progress guard (MAX_EVENTS * 64).
260		let count =
261			if self.mode == "flood" { MAX_EVENTS.saturating_mul(64).saturating_add(2) } else { 2 };
262		let mode = self.mode.clone();
263		let events = (0..count).map(move |index| {
264			let confidential_outputs = if mode == "confidential-output" && index + 1 == count {
265				[("generated".to_string(), b"fixture-confidential".to_vec())].into_iter().collect()
266			} else {
267				Default::default()
268			};
269			let safe_message = if mode == "unsafe-progress" {
270				"ordinary provider detail\ntoken=field-secret\nopaque-action-secret".to_string()
271			} else {
272				"synthetic fixture event".to_string()
273			};
274			Ok(v1::ApplyResponse {
275				sequence: (index + 1) as u32,
276				phase: if index + 1 == count {
277					v1::EventPhase::Completed.into()
278				} else {
279					v1::EventPhase::Progress.into()
280				},
281				action_id: action.action_id.clone(),
282				safe_message,
283				error: None,
284				detail: false,
285				confidential_outputs,
286				public_outputs: Default::default(),
287			})
288		});
289		Ok(Response::new(Box::pin(tokio_stream::iter(events))))
290	}
291
292	async fn verify(
293		&self,
294		request: Request<v1::VerifyRequest>,
295	) -> Result<Response<v1::VerifyResponse>, Status> {
296		self.before_request().await;
297		let request = request.into_inner();
298		Self::context(request.context.as_ref())?;
299		self.require_session_secret(&request.secrets)?;
300		let action = request.action.ok_or_else(|| Status::invalid_argument("action is required"))?;
301		let state = self.state.lock().map_err(|_| Status::internal("state lock failed"))?;
302		let satisfied = match action.operation.as_str() {
303			"create" | "update" => state.contains_key(&action.resource_id),
304			"delete" => !state.contains_key(&action.resource_id),
305			_ => false,
306		};
307		Ok(Response::new(v1::VerifyResponse {
308			satisfied,
309			observation_json: serde_json::to_vec(&serde_json::json!({
310				"resource": state.get(&action.resource_id)
311			}))
312			.map_err(|_| Status::internal("verification serialization failed"))?,
313			error: None,
314		}))
315	}
316
317	async fn cancel(
318		&self,
319		request: Request<v1::CancelRequest>,
320	) -> Result<Response<v1::CancelResponse>, Status> {
321		Self::context(request.get_ref().context.as_ref())?;
322		Ok(Response::new(v1::CancelResponse { accepted: true }))
323	}
324}