Skip to main content

greentic_runner/gen_bindings/
mod.rs

1use anyhow::{Context, Result, bail};
2use serde::{Deserialize, Serialize};
3use serde_yaml_bw::{self as serde_yaml, Value};
4use std::{
5    collections::HashSet,
6    fs,
7    path::{Path, PathBuf},
8};
9use url::Url;
10
11use self::component::ComponentFeatures;
12use greentic_types::cbor::decode_pack_manifest;
13use runner_core::normalize_under_root;
14
15pub mod component;
16pub mod input;
17
18fn yaml_string(value: impl Into<String>) -> Value {
19    Value::String(value.into(), None)
20}
21
22#[derive(Debug)]
23pub struct PackMetadata {
24    pub tenant: String,
25    pub pack_id: String,
26    pub pack_ref: String,
27    pub flows: Vec<FlowMetadata>,
28    pub hints: BindingsHints,
29}
30
31#[derive(Debug)]
32pub struct FlowMetadata {
33    pub name: String,
34    pub document: Value,
35}
36
37#[derive(Debug, Clone, Deserialize, Default)]
38pub struct BindingsHints {
39    #[serde(default)]
40    pub network: NetworkHints,
41    #[serde(default)]
42    pub secrets: SecretsHints,
43    #[serde(default)]
44    pub env: EnvHints,
45    #[serde(default)]
46    pub mcp: McpHints,
47}
48
49#[derive(Debug, Clone, Deserialize, Default)]
50pub struct NetworkHints {
51    #[serde(default)]
52    pub allow: Vec<String>,
53}
54
55#[derive(Debug, Clone, Deserialize, Default)]
56pub struct SecretsHints {
57    #[serde(default)]
58    pub required: Vec<String>,
59}
60
61#[derive(Debug, Clone, Deserialize, Default)]
62pub struct EnvHints {
63    #[serde(default)]
64    pub passthrough: Vec<String>,
65}
66
67#[derive(Debug, Clone, Deserialize, Default)]
68pub struct McpHints {
69    #[serde(default)]
70    pub servers: Vec<McpServer>,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct McpServer {
75    pub name: String,
76    pub transport: String,
77    pub endpoint: String,
78    #[serde(default)]
79    pub caps: Vec<String>,
80}
81
82pub fn load_pack(pack_dir: &Path) -> Result<PackMetadata> {
83    // Accept absolute or relative input but normalize it under its parent to avoid escapes.
84    let (root, candidate) = if pack_dir.is_absolute() {
85        let parent = pack_dir.parent().ok_or_else(|| {
86            anyhow::anyhow!("pack directory has no parent: {}", pack_dir.display())
87        })?;
88        let root = parent
89            .canonicalize()
90            .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
91        let name = pack_dir
92            .file_name()
93            .ok_or_else(|| anyhow::anyhow!("pack directory has no name: {}", pack_dir.display()))?;
94        (root, PathBuf::from(name))
95    } else {
96        let cwd = std::env::current_dir().context("failed to resolve current directory")?;
97        (cwd, pack_dir.to_path_buf())
98    };
99    let pack_dir = normalize_under_root(&root, &candidate)?;
100    if !pack_dir.is_dir() {
101        anyhow::bail!("pack directory {} does not exist", pack_dir.display());
102    }
103
104    let manifest = pack_dir.join("pack.yaml");
105    let content = fs::read_to_string(&manifest)
106        .with_context(|| format!("failed to read {}", manifest.display()))?;
107    let pack_manifest: PackManifest =
108        serde_yaml::from_str(&content).with_context(|| "failed to parse pack manifest")?;
109
110    let hints_path = pack_dir.join("bindings.hints.yaml");
111    let hints = if hints_path.exists() {
112        serde_yaml::from_reader(fs::File::open(&hints_path)?)
113            .with_context(|| format!("failed to read hints {}", hints_path.display()))?
114    } else {
115        BindingsHints::default()
116    };
117
118    let flows_dir = pack_dir.join("flows");
119    let mut flows = Vec::new();
120    if flows_dir.is_dir() {
121        for entry in fs::read_dir(&flows_dir)? {
122            let entry = entry?;
123            let path = entry.path();
124            if path.extension().and_then(|s| s.to_str()) == Some("yaml") {
125                let flow = load_flow(&path)?;
126                flows.push(flow);
127            }
128        }
129    }
130
131    let pack_id = pack_manifest
132        .pack_id
133        .filter(|id| !id.trim().is_empty())
134        .ok_or_else(|| anyhow::anyhow!("pack.yaml missing pack_id"))?;
135    let version = pack_manifest
136        .version
137        .filter(|value| !value.trim().is_empty())
138        .ok_or_else(|| anyhow::anyhow!("pack.yaml missing version"))?;
139    let pack_ref = format!("{}@{}", pack_id, version);
140
141    let tenant = pack_manifest
142        .name
143        .or_else(|| {
144            pack_dir
145                .file_name()
146                .and_then(|n| n.to_str())
147                .map(|s| s.to_string())
148        })
149        .unwrap_or_else(|| pack_id.clone());
150
151    Ok(PackMetadata {
152        tenant,
153        pack_id,
154        pack_ref,
155        flows,
156        hints,
157    })
158}
159
160pub fn load_pack_root(pack_root: &Path) -> Result<PackMetadata> {
161    let manifest = pack_root.join("pack.yaml");
162    if manifest.is_file() {
163        return load_pack(pack_root);
164    }
165    let cbor_path = pack_root.join("manifest.cbor");
166    if cbor_path.is_file() {
167        return load_pack_manifest_cbor(pack_root, &cbor_path);
168    }
169    bail!(
170        "pack directory {} does not include pack.yaml or manifest.cbor",
171        pack_root.display()
172    );
173}
174
175fn load_flow(path: &Path) -> Result<FlowMetadata> {
176    let content = fs::read_to_string(path)?;
177    let fallback = path
178        .file_stem()
179        .and_then(|s| s.to_str())
180        .unwrap_or("<unknown>");
181    load_flow_content(&content, fallback, &path.display().to_string())
182}
183
184fn load_flow_content(content: &str, fallback_name: &str, label: &str) -> Result<FlowMetadata> {
185    let parsed: Value =
186        serde_yaml::from_str(content).with_context(|| format!("failed to parse flow {}", label))?;
187    let name = parsed
188        .get("name")
189        .and_then(Value::as_str)
190        .map(|s| s.to_string())
191        .unwrap_or_else(|| fallback_name.to_string());
192    Ok(FlowMetadata {
193        name,
194        document: parsed,
195    })
196}
197
198fn load_pack_manifest_cbor(pack_root: &Path, cbor_path: &Path) -> Result<PackMetadata> {
199    let bytes =
200        fs::read(cbor_path).with_context(|| format!("failed to read {}", cbor_path.display()))?;
201    let manifest = decode_pack_manifest(&bytes)
202        .with_context(|| format!("failed to decode {}", cbor_path.display()))?;
203    let flows = manifest
204        .flows
205        .iter()
206        .map(|entry| {
207            let serialized = serde_yaml::to_string(&entry.flow)
208                .context("failed to serialize flow from manifest")?;
209            let parsed: Value =
210                serde_yaml::from_str(&serialized).context("failed to parse flow from manifest")?;
211            Ok(FlowMetadata {
212                name: entry.id.to_string(),
213                document: parsed,
214            })
215        })
216        .collect::<Result<Vec<_>>>()?;
217
218    let hints_path = pack_root.join("bindings.hints.yaml");
219    let hints = if hints_path.exists() {
220        serde_yaml::from_reader(fs::File::open(&hints_path)?)
221            .with_context(|| format!("failed to read hints {}", hints_path.display()))?
222    } else {
223        BindingsHints::default()
224    };
225
226    let pack_id = manifest.pack_id.to_string();
227    let pack_ref = format!("{}@{}", pack_id, manifest.version);
228    let tenant = pack_id.clone();
229
230    Ok(PackMetadata {
231        tenant,
232        pack_id,
233        pack_ref,
234        flows,
235        hints,
236    })
237}
238
239#[derive(Debug, Deserialize)]
240struct PackManifest {
241    name: Option<String>,
242    pack_id: Option<String>,
243    version: Option<String>,
244}
245
246#[derive(Clone, Default)]
247pub struct GeneratorOptions {
248    pub strict: bool,
249    pub complete: bool,
250    pub component: Option<ComponentFeatures>,
251    pub pack_locator: Option<String>,
252}
253
254#[derive(Debug, Serialize)]
255pub struct GeneratedBindings {
256    pub tenant: String,
257    pub pack_id: String,
258    pub pack_ref: String,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub pack_locator: Option<String>,
261    #[serde(skip_serializing_if = "Vec::is_empty")]
262    pub env_passthrough: Vec<String>,
263    #[serde(skip_serializing_if = "Vec::is_empty")]
264    pub network_allow: Vec<String>,
265    #[serde(skip_serializing_if = "Vec::is_empty")]
266    pub secrets_required: Vec<String>,
267    #[serde(skip_serializing_if = "Vec::is_empty")]
268    pub flows: Vec<FlowHint>,
269    #[serde(skip_serializing_if = "Vec::is_empty")]
270    pub mcp_servers: Vec<McpServer>,
271}
272
273#[derive(Debug, Serialize)]
274pub struct FlowHint {
275    pub id: String,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub name: Option<String>,
278    #[serde(skip_serializing_if = "Vec::is_empty")]
279    pub urls: Vec<String>,
280    #[serde(skip_serializing_if = "Vec::is_empty")]
281    pub secrets: Vec<String>,
282    #[serde(skip_serializing_if = "Vec::is_empty")]
283    pub env: Vec<String>,
284    #[serde(skip_serializing_if = "Vec::is_empty")]
285    pub mcp_components: Vec<String>,
286}
287
288impl FlowHint {
289    fn from_flow(flow: &FlowMetadata) -> Self {
290        let mut bindings = collect_flow_bindings(&flow.document);
291        let meta = find_meta_bindings(&flow.document);
292        bindings.urls.extend(meta.urls);
293        bindings.secrets.extend(meta.secrets);
294        bindings.env.extend(meta.env);
295        bindings.mcp_components.extend(meta.mcp_components);
296        FlowHint {
297            id: flow.name.clone(),
298            name: Some(flow.name.clone()),
299            urls: bindings.urls.clone(),
300            secrets: bindings.secrets.clone(),
301            env: bindings.env.clone(),
302            mcp_components: bindings.mcp_components.clone(),
303        }
304    }
305}
306
307#[derive(Default)]
308struct FlowBindings {
309    urls: Vec<String>,
310    secrets: Vec<String>,
311    env: Vec<String>,
312    mcp_components: Vec<String>,
313}
314
315fn collect_flow_bindings(doc: &Value) -> FlowBindings {
316    let mut bindings = FlowBindings::default();
317    scan_value_for_placeholders(doc, &mut bindings);
318    collect_mcp_components(doc, &mut bindings);
319    bindings
320}
321
322fn scan_value_for_placeholders(value: &Value, bindings: &mut FlowBindings) {
323    match value {
324        Value::String(s, _) => {
325            bindings.secrets.extend(extract_placeholders(s, "secrets."));
326            bindings.env.extend(extract_placeholders(s, "env."));
327            if let Some(origin) = extract_origin(s) {
328                bindings.urls.push(origin);
329            }
330        }
331        Value::Sequence(seq) => {
332            for item in seq {
333                scan_value_for_placeholders(item, bindings);
334            }
335        }
336        Value::Mapping(map) => {
337            for (_, item) in map {
338                scan_value_for_placeholders(item, bindings);
339            }
340        }
341        _ => {}
342    }
343}
344
345fn collect_mcp_components(value: &Value, bindings: &mut FlowBindings) {
346    match value {
347        Value::Mapping(map) => {
348            let exec_key = yaml_string("mcp.exec");
349            let component_key = yaml_string("component");
350            if let Some(Value::Mapping(exec_map)) = map.get(&exec_key)
351                && let Some(Value::String(component, _)) = exec_map.get(&component_key)
352            {
353                bindings.mcp_components.push(component.clone());
354            }
355            for (_, v) in map {
356                collect_mcp_components(v, bindings);
357            }
358        }
359        Value::Sequence(seq) => {
360            for item in seq {
361                collect_mcp_components(item, bindings);
362            }
363        }
364        _ => {}
365    }
366}
367
368fn extract_placeholders(value: &str, prefix: &str) -> Vec<String> {
369    let mut results = Vec::new();
370    let mut start = 0;
371    while let Some(idx) = value[start..].find(prefix) {
372        let idx = start + idx + prefix.len();
373        let rest = &value[idx..];
374        let end = rest
375            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
376            .unwrap_or(rest.len());
377        if end > 0 {
378            results.push(rest[..end].to_string());
379        }
380        start = idx + end;
381    }
382    results
383}
384
385fn extract_origin(text: &str) -> Option<String> {
386    if let Ok(url) = Url::parse(text)
387        && url.scheme().starts_with("http")
388        && let Some(host) = url.host_str()
389    {
390        let port = match url.port() {
391            Some(p) => format!(":{}", p),
392            None => "".to_string(),
393        };
394        return Some(format!("{}://{}{}", url.scheme(), host, port));
395    }
396    None
397}
398
399pub fn generate_bindings(
400    metadata: &PackMetadata,
401    opts: GeneratorOptions,
402) -> Result<GeneratedBindings> {
403    let mut env = base_env_passthrough();
404    env.extend(metadata.hints.env.passthrough.iter().cloned());
405    let flows: Vec<FlowHint> = metadata.flows.iter().map(FlowHint::from_flow).collect();
406    for flow in &flows {
407        env.extend(flow.env.clone());
408    }
409
410    let mut env = unique_sorted(env);
411
412    let mut secrets = metadata.hints.secrets.required.clone();
413    for flow in &flows {
414        secrets.extend(flow.secrets.clone());
415    }
416    let secrets = unique_sorted(secrets);
417
418    let mut network = metadata.hints.network.allow.clone();
419    for flow in &flows {
420        network.extend(flow.urls.clone());
421    }
422    let mut network = unique_sorted(network);
423
424    if opts.complete && network.is_empty() {
425        network.push("https://*".to_string());
426    }
427
428    if opts.complete {
429        for value in base_env_passthrough() {
430            if !env.contains(&value) {
431                env.push(value);
432            }
433        }
434    }
435
436    let mut mcp_servers = metadata.hints.mcp.servers.clone();
437    let mut referenced_components = Vec::new();
438    for flow in &flows {
439        referenced_components.extend(flow.mcp_components.clone());
440    }
441    let referenced_components = unique_sorted(referenced_components);
442    for component in referenced_components {
443        if mcp_servers.iter().any(|server| server.name == component) {
444            continue;
445        }
446        if opts.strict {
447            bail!(
448                "MCP component '{}' referenced but no server hint; add hints or rerun with --complete",
449                component
450            );
451        }
452        mcp_servers.push(McpServer {
453            name: component.clone(),
454            transport: "websocket".into(),
455            endpoint: "ws://localhost:9000".into(),
456            caps: Vec::new(),
457        });
458    }
459
460    if opts.strict {
461        if opts.component.as_ref().map(|c| c.http).unwrap_or(false) && network.is_empty() {
462            bail!(
463                "HTTP capability detected but no network allow rules; add hints or use --complete"
464            );
465        }
466        if opts.component.as_ref().map(|c| c.secrets).unwrap_or(false) && secrets.is_empty() {
467            bail!(
468                "Secrets capability detected but no secrets.required hints; add hints or use --complete"
469            );
470        }
471    }
472
473    Ok(GeneratedBindings {
474        tenant: metadata.tenant.clone(),
475        pack_id: metadata.pack_id.clone(),
476        pack_ref: metadata.pack_ref.clone(),
477        pack_locator: opts.pack_locator.clone(),
478        env_passthrough: env,
479        network_allow: network,
480        secrets_required: secrets,
481        flows,
482        mcp_servers,
483    })
484}
485
486fn base_env_passthrough() -> Vec<String> {
487    vec![
488        "RUST_LOG".into(),
489        "OTEL_EXPORTER_OTLP_ENDPOINT".into(),
490        "OTEL_RESOURCE_ATTRIBUTES".into(),
491    ]
492}
493
494fn unique_sorted(values: Vec<String>) -> Vec<String> {
495    let mut set = HashSet::new();
496    let mut result = Vec::new();
497    for v in values {
498        if set.insert(v.clone()) {
499            result.push(v);
500        }
501    }
502    result.sort_unstable();
503    result
504}
505
506fn find_meta_bindings(meta: &Value) -> FlowBindings {
507    let mut hints = FlowBindings::default();
508    if let Some(bindings) = find_bindings_value(meta) {
509        hints.urls.extend(extract_string_list(bindings, "urls"));
510        hints
511            .secrets
512            .extend(extract_string_list(bindings, "secrets"));
513        hints.env.extend(extract_string_list(bindings, "env"));
514        hints
515            .mcp_components
516            .extend(extract_string_list(bindings, "mcp_components"));
517    }
518    hints
519}
520
521fn find_bindings_value(meta: &Value) -> Option<&Value> {
522    if let Value::Mapping(map) = meta {
523        let bindings_key = yaml_string("bindings");
524        if let Some(bindings) = map.get(&bindings_key) {
525            return Some(bindings);
526        }
527        let meta_key = yaml_string("meta");
528        if let Some(Value::Mapping(inner_map)) = map.get(&meta_key) {
529            let inner_bindings_key = yaml_string("bindings");
530            return inner_map.get(&inner_bindings_key);
531        }
532    }
533    None
534}
535
536fn extract_string_list(bindings: &Value, key: &str) -> Vec<String> {
537    if let Value::Mapping(map) = bindings {
538        let key_value = yaml_string(key);
539        if let Some(value) = map.get(&key_value) {
540            return value_to_list(value);
541        }
542    }
543    Vec::new()
544}
545
546fn value_to_list(value: &Value) -> Vec<String> {
547    match value {
548        Value::Sequence(seq) => seq
549            .iter()
550            .filter_map(|v| v.as_str().map(|s| s.to_string()))
551            .collect(),
552        Value::String(s, _) => vec![s.clone()],
553        _ => Vec::new(),
554    }
555}