greentic_dev/
component_resolver.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use anyhow::{Context, Result, anyhow, bail};
6use greentic_component::describe::{DescribePayload, DescribeVersion};
7use greentic_component::lifecycle::Lifecycle;
8use greentic_component::manifest::ComponentManifest;
9use greentic_component::prepare::PreparedComponent;
10use greentic_component::prepare_component;
11use greentic_flow::flow_bundle::NodeRef;
12use jsonschema::{Draft, Validator};
13use semver::{Version, VersionReq};
14use serde::Serialize;
15use serde_json::Value as JsonValue;
16
17#[derive(Debug, Clone)]
18pub struct ResolvedComponent {
19    pub name: String,
20    pub version: Version,
21    pub wasm_path: PathBuf,
22    #[allow(dead_code)]
23    pub manifest_path: PathBuf,
24    pub schema_json: Option<String>,
25    pub manifest_json: Option<String>,
26    pub capabilities_json: Option<JsonValue>,
27    #[allow(dead_code)]
28    pub limits_json: Option<JsonValue>,
29    pub world: String,
30    pub wasm_hash: String,
31    #[allow(dead_code)]
32    describe: DescribePayload,
33}
34
35#[derive(Debug, Clone)]
36pub struct ResolvedNode {
37    pub node_id: String,
38    pub component: Arc<ResolvedComponent>,
39    pub pointer: String,
40    pub config: JsonValue,
41}
42
43#[derive(Debug, Clone)]
44pub struct NodeSchemaError {
45    pub node_id: String,
46    pub component: String,
47    pub pointer: String,
48    pub message: String,
49}
50
51#[derive(Debug, Clone, Hash, PartialEq, Eq)]
52struct ComponentCacheKey {
53    name: String,
54    version: Version,
55}
56
57impl ComponentCacheKey {
58    fn new(name: impl Into<String>, version: &Version) -> Self {
59        Self {
60            name: name.into(),
61            version: version.clone(),
62        }
63    }
64}
65
66pub struct ComponentResolver {
67    component_dir: Option<PathBuf>,
68    cache: HashMap<ComponentCacheKey, Arc<ResolvedComponent>>,
69    schema_cache: HashMap<String, Arc<CachedSchema>>,
70}
71
72struct CachedSchema(Validator);
73
74impl ComponentResolver {
75    pub fn new(component_dir: Option<PathBuf>) -> Self {
76        Self {
77            component_dir,
78            cache: HashMap::new(),
79            schema_cache: HashMap::new(),
80        }
81    }
82
83    pub fn resolve_component(
84        &mut self,
85        name: &str,
86        version_req: &VersionReq,
87    ) -> Result<Arc<ResolvedComponent>> {
88        self.load_component(name, version_req)
89    }
90
91    pub fn resolve_node(&mut self, node: &NodeRef, flow_doc: &JsonValue) -> Result<ResolvedNode> {
92        let component_key = &node.component;
93        let pointer = format!("/nodes/{}/{}", node.node_id, component_key.name);
94        let config = extract_node_payload(flow_doc, &node.node_id, &component_key.name)
95            .with_context(|| {
96                format!(
97                    "failed to extract payload for node `{}` ({})",
98                    node.node_id, component_key.name
99                )
100            })?;
101
102        let version_req = parse_version_req(&component_key.version_req).with_context(|| {
103            format!(
104                "node `{}` has invalid semver requirement `{}`",
105                node.node_id, component_key.version_req
106            )
107        })?;
108
109        let component = self
110            .load_component(&component_key.name, &version_req)
111            .with_context(|| {
112                format!(
113                    "node `{}`: failed to prepare component `{}`",
114                    node.node_id, component_key.name
115                )
116            })?;
117
118        Ok(ResolvedNode {
119            node_id: node.node_id.clone(),
120            component,
121            pointer,
122            config,
123        })
124    }
125
126    pub fn validate_node(&mut self, node: &ResolvedNode) -> Result<Vec<NodeSchemaError>> {
127        let Some(schema_json) = &node.component.schema_json else {
128            return Ok(Vec::new());
129        };
130
131        let validator = self.compile_schema(schema_json)?;
132        let mut issues = Vec::new();
133        if let Err(error) = validator.0.validate(&node.config) {
134            for error in std::iter::once(error).chain(validator.0.iter_errors(&node.config)) {
135                let suffix = error.instance_path().to_string();
136                let pointer = if suffix.is_empty() || suffix == "/" {
137                    node.pointer.clone()
138                } else {
139                    format!("{}{}", node.pointer, suffix)
140                };
141                issues.push(NodeSchemaError {
142                    node_id: node.node_id.clone(),
143                    component: node.component.name.clone(),
144                    pointer,
145                    message: error.to_string(),
146                });
147            }
148        }
149        Ok(issues)
150    }
151
152    fn compile_schema(&mut self, schema_json: &str) -> Result<Arc<CachedSchema>> {
153        if let Some(existing) = self.schema_cache.get(schema_json) {
154            return Ok(existing.clone());
155        }
156
157        let schema_value: JsonValue =
158            serde_json::from_str(schema_json).context("invalid schema JSON")?;
159        let compiled = jsonschema::options()
160            .with_draft(Draft::Draft7)
161            .build(&schema_value)
162            .map_err(|error| anyhow!("failed to compile schema JSON: {error}"))?;
163        let entry = Arc::new(CachedSchema(compiled));
164        self.schema_cache
165            .insert(schema_json.to_string(), entry.clone());
166        Ok(entry)
167    }
168
169    fn load_component(
170        &mut self,
171        name: &str,
172        version_req: &VersionReq,
173    ) -> Result<Arc<ResolvedComponent>> {
174        let target = component_target(name, self.component_dir.as_deref());
175        let target_display = match &target {
176            ComponentTarget::Direct(id) => id.clone(),
177            ComponentTarget::Path(path) => path.display().to_string(),
178        };
179
180        let prepared = prepare_component(target.as_ref()).with_context(|| {
181            format!(
182                "resolver looked for `{name}` via `{target_display}` but prepare_component failed"
183            )
184        })?;
185
186        if !version_req.matches(&prepared.manifest.version) {
187            bail!(
188                "component `{name}` version `{}` does not satisfy requirement `{version_req}`",
189                prepared.manifest.version
190            );
191        }
192
193        let key = ComponentCacheKey::new(name, &prepared.manifest.version);
194        if let Some(existing) = self.cache.get(&key) {
195            return Ok(existing.clone());
196        }
197
198        let resolved = Arc::new(to_resolved_component(prepared)?);
199        self.cache.insert(key, resolved.clone());
200        Ok(resolved)
201    }
202}
203
204enum ComponentTarget {
205    Direct(String),
206    Path(PathBuf),
207}
208
209impl ComponentTarget {
210    fn as_ref(&self) -> &str {
211        match self {
212            ComponentTarget::Direct(id) => id,
213            ComponentTarget::Path(path) => path.to_str().expect("component path utf-8"),
214        }
215    }
216}
217
218fn component_target(name: &str, root: Option<&Path>) -> ComponentTarget {
219    if let Some(dir) = root {
220        let candidate = dir.join(name);
221        return ComponentTarget::Path(candidate);
222    }
223    ComponentTarget::Direct(name.to_string())
224}
225
226fn parse_version_req(input: &str) -> Result<VersionReq> {
227    if input.trim().is_empty() {
228        VersionReq::parse("*").map_err(Into::into)
229    } else {
230        VersionReq::parse(input).map_err(Into::into)
231    }
232}
233
234fn to_resolved_component(prepared: PreparedComponent) -> Result<ResolvedComponent> {
235    let manifest_json =
236        serde_json::to_string(&prepared.manifest).context("failed to serialize manifest")?;
237    let capabilities_json = serde_json::to_value(&prepared.manifest.capabilities)
238        .context("failed to serialize capabilities")?;
239    let limits_json = prepared
240        .manifest
241        .limits
242        .as_ref()
243        .map(|limits| serde_json::to_value(limits).expect("limits serialize"));
244    let schema_json = select_schema(&prepared.describe);
245
246    Ok(ResolvedComponent {
247        name: prepared.manifest.id.as_str().to_string(),
248        version: prepared.manifest.version.clone(),
249        wasm_path: prepared.wasm_path.clone(),
250        manifest_path: prepared.manifest_path.clone(),
251        schema_json,
252        manifest_json: Some(manifest_json),
253        capabilities_json: Some(capabilities_json),
254        limits_json,
255        world: prepared.manifest.world.as_str().to_string(),
256        wasm_hash: prepared.wasm_hash.clone(),
257        describe: prepared.describe,
258    })
259}
260
261fn extract_node_payload(
262    document: &JsonValue,
263    node_id: &str,
264    component_name: &str,
265) -> Result<JsonValue> {
266    let nodes = document
267        .get("nodes")
268        .and_then(|value| value.as_object())
269        .context("flow document missing `nodes` object")?;
270
271    let node_entry = nodes
272        .get(node_id)
273        .and_then(|value| value.as_object())
274        .context(format!("flow document missing node `{node_id}`"))?;
275
276    let payload = node_entry.get(component_name).cloned().context(format!(
277        "node `{node_id}` missing component payload `{component_name}`"
278    ))?;
279
280    Ok(payload)
281}
282
283fn select_schema(describe: &DescribePayload) -> Option<String> {
284    choose_latest_version(&describe.versions)
285        .map(|entry| serde_json::to_string(&entry.schema).expect("describe schema serializes"))
286}
287
288fn choose_latest_version(versions: &[DescribeVersion]) -> Option<DescribeVersion> {
289    let mut sorted = versions.to_vec();
290    sorted.sort_by(|a, b| b.version.cmp(&a.version));
291    sorted.into_iter().next()
292}
293
294#[allow(dead_code)]
295#[derive(Serialize)]
296struct PreparedComponentView<'a> {
297    manifest: &'a ComponentManifest,
298    manifest_path: String,
299    wasm_path: String,
300    wasm_hash: &'a str,
301    world_ok: bool,
302    hash_verified: bool,
303    describe: &'a DescribePayload,
304    lifecycle: &'a Lifecycle,
305}
306
307#[allow(dead_code)]
308pub fn inspect(target: &str, compact_json: bool) -> Result<()> {
309    let prepared = prepare_component(target)
310        .with_context(|| format!("failed to prepare component `{target}`"))?;
311    let view = PreparedComponentView {
312        manifest: &prepared.manifest,
313        manifest_path: prepared.manifest_path.display().to_string(),
314        wasm_path: prepared.wasm_path.display().to_string(),
315        wasm_hash: &prepared.wasm_hash,
316        world_ok: prepared.world_ok,
317        hash_verified: prepared.hash_verified,
318        describe: &prepared.describe,
319        lifecycle: &prepared.lifecycle,
320    };
321
322    if compact_json {
323        println!("{}", serde_json::to_string(&view)?);
324    } else {
325        println!("{}", serde_json::to_string_pretty(&view)?);
326    }
327    Ok(())
328}