1use crate::contract::canonical_json;
7use crate::contract::{
8 CanonicalConfig, PROVIDER_PROTOCOL_V1, ProviderInstance, SECRET_PROTOCOL_V1,
9 SecretResolverInstance,
10};
11use crate::plan::PlanEnvelope;
12use crate::plan::sha256_hex_bytes;
13use crate::plugin_protocol::v1;
14use crate::ports::provider::Provider;
15use serde_json::json;
16use std::collections::BTreeMap;
17use std::ffi::OsString;
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22type ProviderFactory =
23 dyn Fn(&[OsString]) -> Result<Arc<dyn Provider>, CompositionError> + Send + Sync;
24
25#[derive(Clone, Default)]
27pub struct LinkedProviderRegistry {
28 factories: BTreeMap<String, Arc<ProviderFactory>>,
29}
30
31impl LinkedProviderRegistry {
32 pub fn register<F>(&mut self, kind: impl Into<String>, factory: F) -> Result<(), CompositionError>
33 where
34 F: Fn(&[OsString]) -> Result<Arc<dyn Provider>, CompositionError> + Send + Sync + 'static,
35 {
36 let kind = kind.into();
37 if kind.is_empty() || self.factories.insert(kind.clone(), Arc::new(factory)).is_some() {
38 return Err(CompositionError::InvalidConfig(format!(
39 "duplicate linked provider kind {kind}"
40 )));
41 }
42 Ok(())
43 }
44
45 fn create(
46 &self,
47 kind: &str,
48 arguments: &[OsString],
49 ) -> Result<Option<Arc<dyn Provider>>, CompositionError> {
50 self.factories.get(kind).map(|factory| factory(arguments)).transpose()
51 }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ExecutableIdentity {
57 pub path: PathBuf,
58 pub digest: String,
60 pub protocol: String,
61}
62
63#[derive(Clone)]
65pub struct ProviderExecutable {
66 pub instance_id: String,
67 pub kind: String,
68 pub identity: ExecutableIdentity,
69 pub arguments: Vec<OsString>,
70 pub linked: Option<Arc<dyn Provider>>,
71 pub expected_version: Option<String>,
72 pub expected_runtime_digest: Option<String>,
73}
74
75impl std::fmt::Debug for ProviderExecutable {
76 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 formatter
78 .debug_struct("ProviderExecutable")
79 .field("instance_id", &self.instance_id)
80 .field("kind", &self.kind)
81 .field("identity", &self.identity)
82 .field("arguments", &self.arguments)
83 .field("linked", &self.linked.is_some())
84 .field("expected_version", &self.expected_version)
85 .field("expected_runtime_digest", &self.expected_runtime_digest)
86 .finish()
87 }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct SecretExecutable {
93 pub resolver_id: String,
94 pub identity: ExecutableIdentity,
95 pub arguments: Vec<OsString>,
96}
97
98#[derive(Clone, Debug, Default)]
100pub struct ExternalRuntimeGraph {
101 pub providers: BTreeMap<String, ProviderExecutable>,
103 pub secret_resolvers: BTreeMap<String, SecretExecutable>,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub enum CompositionError {
109 InvalidConfig(String),
110 MissingExecutable(String),
111 CapabilityUnavailable(String),
112}
113
114impl std::fmt::Display for CompositionError {
115 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Self::InvalidConfig(message)
118 | Self::MissingExecutable(message)
119 | Self::CapabilityUnavailable(message) => write!(formatter, "{message}"),
120 }
121 }
122}
123
124impl std::error::Error for CompositionError {}
125
126impl ExternalRuntimeGraph {
127 pub fn from_plan(plan: &PlanEnvelope) -> Result<Self, CompositionError> {
130 Self::from_plan_with_linked(plan, &LinkedProviderRegistry::default())
131 }
132
133 pub fn from_plan_with_linked(
134 plan: &PlanEnvelope,
135 linked: &LinkedProviderRegistry,
136 ) -> Result<Self, CompositionError> {
137 let mut graph = Self::default();
138 for runtime in &plan.spec.provider_runtimes {
139 let arguments = runtime.arguments.iter().map(OsString::from).collect::<Vec<_>>();
140 if runtime.nxd_revision.is_some() {
141 if runtime.nxd_revision.as_deref() != Some(env!("NXD_GIT_REVISION")) {
142 return Err(CompositionError::CapabilityUnavailable(format!(
143 "linked provider {} NXD revision differs from the reviewed plan",
144 runtime.provider_instance
145 )));
146 }
147 let provider = linked.create(&runtime.provider_kind, &arguments)?.ok_or_else(|| {
148 CompositionError::CapabilityUnavailable(format!(
149 "linked provider kind {} is not registered",
150 runtime.provider_kind
151 ))
152 })?;
153 graph.providers.insert(
154 runtime.provider_instance.clone(),
155 ProviderExecutable {
156 instance_id: runtime.provider_instance.clone(),
157 kind: runtime.provider_kind.clone(),
158 identity: ExecutableIdentity {
159 path: PathBuf::new(),
160 digest: runtime.runtime_digest.clone(),
161 protocol: "linked".into(),
162 },
163 arguments,
164 linked: Some(provider),
165 expected_version: Some(runtime.provider_version.clone()),
166 expected_runtime_digest: Some(runtime.runtime_digest.clone()),
167 },
168 );
169 continue;
170 }
171 let protocol = runtime.protocol.as_deref().ok_or_else(|| {
172 CompositionError::InvalidConfig(format!(
173 "external provider {} is missing protocol",
174 runtime.provider_instance
175 ))
176 })?;
177 let executable_path = runtime.executable_path.as_deref().ok_or_else(|| {
178 CompositionError::InvalidConfig(format!(
179 "external provider {} is missing executablePath",
180 runtime.provider_instance
181 ))
182 })?;
183 let identity = executable_identity(
184 executable_path,
185 protocol,
186 &format!("provider {}", runtime.provider_instance),
187 )?;
188 if Some(identity.digest.as_str()) != runtime.executable_digest.as_deref()
189 || identity.digest != runtime.runtime_digest
190 {
191 return Err(CompositionError::CapabilityUnavailable(format!(
192 "provider {} executable digest differs from the reviewed plan",
193 runtime.provider_instance
194 )));
195 }
196 graph.providers.insert(
197 runtime.provider_instance.clone(),
198 ProviderExecutable {
199 instance_id: runtime.provider_instance.clone(),
200 kind: runtime.provider_kind.clone(),
201 identity,
202 arguments,
203 linked: None,
204 expected_version: Some(runtime.provider_version.clone()),
205 expected_runtime_digest: None,
206 },
207 );
208 }
209 for runtime in &plan.spec.secret_runtimes {
210 let identity = executable_identity(
211 &runtime.executable_path,
212 &runtime.protocol,
213 &format!("secret resolver {}", runtime.resolver_id),
214 )?;
215 if identity.digest != runtime.executable_digest {
216 return Err(CompositionError::CapabilityUnavailable(format!(
217 "secret resolver {} executable digest differs from the reviewed plan",
218 runtime.resolver_id
219 )));
220 }
221 graph.secret_resolvers.insert(
222 runtime.resolver_id.clone(),
223 SecretExecutable {
224 resolver_id: runtime.resolver_id.clone(),
225 identity,
226 arguments: runtime.arguments.iter().map(OsString::from).collect(),
227 },
228 );
229 }
230 Ok(graph)
231 }
232
233 pub fn from_canonical(config: &CanonicalConfig) -> Result<Self, CompositionError> {
238 Self::from_canonical_with_linked(config, &LinkedProviderRegistry::default())
239 }
240
241 pub fn from_canonical_with_linked(
242 config: &CanonicalConfig,
243 linked: &LinkedProviderRegistry,
244 ) -> Result<Self, CompositionError> {
245 let mut identities = BTreeMap::new();
246 let mut providers = BTreeMap::new();
247 for instance in &config.spec.provider_instances {
248 if let Some(executable) = provider_executable(instance, &mut identities, linked)?
249 && providers.insert(executable.instance_id.clone(), executable).is_some()
250 {
251 return Err(CompositionError::InvalidConfig(format!(
252 "duplicate provider instance {}",
253 instance.id
254 )));
255 }
256 }
257
258 let mut secret_resolvers = BTreeMap::new();
259 for resolver in &config.spec.secret_resolvers {
260 let executable = secret_executable(resolver, &mut identities)?;
261 if secret_resolvers.insert(executable.resolver_id.clone(), executable).is_some() {
262 return Err(CompositionError::InvalidConfig(format!(
263 "duplicate secret resolver {}",
264 resolver.id
265 )));
266 }
267 }
268
269 for binding in &config.spec.secret_bindings {
271 if !secret_resolvers.contains_key(&binding.resolver) && !secret_resolvers.is_empty() {
272 return Err(CompositionError::CapabilityUnavailable(format!(
273 "secret binding {} requires secret resolver {} which is not configured",
274 binding.id, binding.resolver
275 )));
276 }
277 }
278
279 Ok(Self { providers, secret_resolvers })
280 }
281
282 pub fn provider(&self, instance_id: &str) -> Option<&ProviderExecutable> {
283 self.providers.get(instance_id)
284 }
285
286 pub fn secret_resolver(&self, resolver_id: &str) -> Option<&SecretExecutable> {
287 self.secret_resolvers.get(resolver_id)
288 }
289
290 pub fn require_provider(
292 &self,
293 instance_id: &str,
294 ) -> Result<&ProviderExecutable, CompositionError> {
295 self.provider(instance_id).ok_or_else(|| {
296 CompositionError::CapabilityUnavailable(format!(
297 "provider instance {instance_id} is not composed"
298 ))
299 })
300 }
301
302 pub fn require_secret_resolver(
304 &self,
305 resolver_id: &str,
306 ) -> Result<&SecretExecutable, CompositionError> {
307 self.secret_resolver(resolver_id).ok_or_else(|| {
308 CompositionError::CapabilityUnavailable(format!(
309 "secret resolver {resolver_id} is not configured"
310 ))
311 })
312 }
313}
314
315fn provider_executable(
316 instance: &ProviderInstance,
317 identities: &mut BTreeMap<(String, String), ExecutableIdentity>,
318 linked: &LinkedProviderRegistry,
319) -> Result<Option<ProviderExecutable>, CompositionError> {
320 match (&instance.command, &instance.protocol) {
321 (None, None) => {
322 let arguments = instance.arguments.iter().map(OsString::from).collect::<Vec<_>>();
323 let Some(provider) = linked.create(&instance.kind, &arguments)? else { return Ok(None) };
324 Ok(Some(ProviderExecutable {
325 instance_id: instance.id.clone(),
326 kind: instance.kind.clone(),
327 identity: ExecutableIdentity {
328 path: PathBuf::new(),
329 digest: linked_identity_digest(&instance.kind),
330 protocol: "linked".into(),
331 },
332 arguments,
333 linked: Some(provider),
334 expected_version: None,
335 expected_runtime_digest: None,
336 }))
337 }
338 (Some(command), Some(protocol)) => {
339 if protocol != PROVIDER_PROTOCOL_V1 {
340 return Err(CompositionError::InvalidConfig(format!(
341 "provider {} protocol must be {PROVIDER_PROTOCOL_V1}",
342 instance.id
343 )));
344 }
345 let identity = executable_identity_cached(
346 identities,
347 command,
348 protocol,
349 &format!("provider {}", instance.id),
350 )?;
351 Ok(Some(ProviderExecutable {
352 instance_id: instance.id.clone(),
353 kind: instance.kind.clone(),
354 identity,
355 arguments: instance.arguments.iter().map(OsString::from).collect(),
356 linked: None,
357 expected_version: None,
358 expected_runtime_digest: None,
359 }))
360 }
361 _ => Err(CompositionError::InvalidConfig(format!(
362 "provider {} must declare both command and protocol",
363 instance.id
364 ))),
365 }
366}
367
368fn linked_identity_digest(kind: &str) -> String {
369 let identity = format!("{}\n{kind}", env!("NXD_GIT_REVISION"));
370 format!("sha256:{}", sha256_hex_bytes(identity.as_bytes()))
371}
372
373pub fn linked_runtime_digest(
374 kind: &str,
375 provider_version: &str,
376 capabilities: impl IntoIterator<Item = impl AsRef<str>>,
377 resource_kinds: &[v1::ResourceKindDescriptor],
378) -> Result<String, serde_json::Error> {
379 let mut capabilities =
380 capabilities.into_iter().map(|value| value.as_ref().to_string()).collect::<Vec<_>>();
381 capabilities.sort();
382 let mut resource_schema_digests = resource_kinds
383 .iter()
384 .map(|descriptor| {
385 format!("{}:sha256:{}", descriptor.kind, sha256_hex_bytes(&descriptor.schema_json))
386 })
387 .collect::<Vec<_>>();
388 resource_schema_digests.sort();
389 Ok(format!(
390 "sha256:{}",
391 sha256_hex_bytes(
392 canonical_json(&json!({
393 "nxdRevision": env!("NXD_GIT_REVISION"),
394 "providerKind": kind,
395 "providerVersion": provider_version,
396 "capabilities": capabilities,
397 "resourceSchemaDigests": resource_schema_digests,
398 }))?
399 .as_bytes()
400 )
401 ))
402}
403
404fn secret_executable(
405 resolver: &SecretResolverInstance,
406 identities: &mut BTreeMap<(String, String), ExecutableIdentity>,
407) -> Result<SecretExecutable, CompositionError> {
408 if resolver.protocol != SECRET_PROTOCOL_V1 {
409 return Err(CompositionError::InvalidConfig(format!(
410 "secret resolver {} protocol must be {SECRET_PROTOCOL_V1}",
411 resolver.id
412 )));
413 }
414 let identity = executable_identity_cached(
415 identities,
416 &resolver.command,
417 &resolver.protocol,
418 &format!("secret resolver {}", resolver.id),
419 )?;
420 Ok(SecretExecutable {
421 resolver_id: resolver.id.clone(),
422 identity,
423 arguments: resolver.arguments.iter().map(OsString::from).collect(),
424 })
425}
426
427fn executable_identity_cached(
428 identities: &mut BTreeMap<(String, String), ExecutableIdentity>,
429 command: &str,
430 protocol: &str,
431 label: &str,
432) -> Result<ExecutableIdentity, CompositionError> {
433 let key = (command.to_string(), protocol.to_string());
434 if let Some(identity) = identities.get(&key) {
435 return Ok(identity.clone());
436 }
437 let identity = executable_identity(command, protocol, label)?;
438 identities.insert(key, identity.clone());
439 Ok(identity)
440}
441
442fn executable_identity(
443 command: &str,
444 protocol: &str,
445 label: &str,
446) -> Result<ExecutableIdentity, CompositionError> {
447 let path = PathBuf::from(command);
448 if !path.is_absolute() {
449 return Err(CompositionError::InvalidConfig(format!(
450 "{label} command must be an absolute path: {command}"
451 )));
452 }
453 if !path.is_file() {
454 return Err(CompositionError::MissingExecutable(format!(
455 "{label} executable is missing or not a file: {}",
456 path.display()
457 )));
458 }
459 let digest = file_digest(&path).map_err(|error| {
460 CompositionError::MissingExecutable(format!(
461 "{label} executable at {} could not be read for identity: {error}",
462 path.display()
463 ))
464 })?;
465 Ok(ExecutableIdentity { path, digest, protocol: protocol.to_string() })
466}
467
468fn file_digest(path: &Path) -> Result<String, String> {
469 let bytes = fs::read(path).map_err(|error| error.to_string())?;
470 Ok(format!("sha256:{}", sha256_hex_bytes(&bytes)))
471}
472
473#[cfg(test)]
474#[path = "external_runtime_tests.rs"]
475mod tests;