greentic_runner/gen_bindings/
mod.rs1use 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 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}