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