Skip to main content

greentic_runner_host/
gtbind.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7
8#[derive(Debug, Clone)]
9pub struct PackBinding {
10    pub pack_id: String,
11    pub pack_ref: String,
12    pub pack_locator: Option<String>,
13    pub flows: Vec<String>,
14}
15
16#[derive(Debug, Clone)]
17pub struct TenantBindings {
18    pub tenant: String,
19    pub packs: Vec<PackBinding>,
20    pub env_passthrough: Vec<String>,
21}
22
23#[derive(Debug, Deserialize)]
24struct GtBindFile {
25    tenant: String,
26    pack_id: String,
27    pack_ref: String,
28    #[serde(default)]
29    pack_locator: Option<String>,
30    #[serde(default)]
31    flows: Vec<GtBindFlow>,
32    #[serde(default)]
33    env_passthrough: Vec<String>,
34}
35
36#[derive(Debug, Deserialize)]
37struct GtBindFlow {
38    id: String,
39}
40
41pub fn collect_gtbind_paths(paths: &[PathBuf], dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
42    let mut resolved = Vec::new();
43    for path in paths {
44        if path.is_dir() {
45            resolved.extend(scan_dir(path)?);
46        } else if path.is_file() {
47            resolved.push(path.to_path_buf());
48        } else {
49            bail!("bindings path does not exist: {}", path.display());
50        }
51    }
52    for dir in dirs {
53        if !dir.is_dir() {
54            bail!("bindings dir does not exist: {}", dir.display());
55        }
56        resolved.extend(scan_dir(dir)?);
57    }
58    resolved.sort();
59    resolved.dedup();
60    Ok(resolved)
61}
62
63pub fn load_gtbinds(paths: &[PathBuf]) -> Result<HashMap<String, TenantBindings>> {
64    let mut tenants: HashMap<String, TenantBindings> = HashMap::new();
65    for path in paths {
66        let content = fs::read_to_string(path)
67            .with_context(|| format!("failed to read gtbind {}", path.display()))?;
68        let raw: GtBindFile = serde_yaml_bw::from_str(&content)
69            .with_context(|| format!("failed to parse gtbind {}", path.display()))?;
70        if raw.pack_id.trim().is_empty() {
71            bail!("gtbind {} missing pack_id", path.display());
72        }
73        if raw.pack_ref.trim().is_empty() {
74            bail!("gtbind {} missing pack_ref", path.display());
75        }
76        if raw.tenant.trim().is_empty() {
77            bail!("gtbind {} missing tenant", path.display());
78        }
79        let flows = raw
80            .flows
81            .into_iter()
82            .map(|flow| flow.id)
83            .filter(|id| !id.trim().is_empty())
84            .collect::<Vec<_>>();
85        let pack = PackBinding {
86            pack_id: raw.pack_id,
87            pack_ref: raw.pack_ref,
88            pack_locator: raw.pack_locator,
89            flows,
90        };
91        let entry = tenants
92            .entry(raw.tenant.clone())
93            .or_insert_with(|| TenantBindings {
94                tenant: raw.tenant.clone(),
95                packs: Vec::new(),
96                env_passthrough: Vec::new(),
97            });
98        merge_pack(entry, pack)?;
99        merge_env(entry, raw.env_passthrough);
100    }
101    Ok(tenants)
102}
103
104fn scan_dir(dir: &Path) -> Result<Vec<PathBuf>> {
105    let mut entries = Vec::new();
106    for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
107        let entry = entry?;
108        let path = entry.path();
109        if path.extension().and_then(|ext| ext.to_str()) == Some("gtbind") {
110            entries.push(path);
111        }
112    }
113    Ok(entries)
114}
115
116fn merge_pack(tenant: &mut TenantBindings, pack: PackBinding) -> Result<()> {
117    if let Some(existing) = tenant
118        .packs
119        .iter_mut()
120        .find(|entry| entry.pack_id == pack.pack_id)
121    {
122        if existing.pack_ref != pack.pack_ref {
123            bail!(
124                "pack_ref mismatch for tenant {} pack {}",
125                tenant.tenant,
126                pack.pack_id
127            );
128        }
129        match (&existing.pack_locator, &pack.pack_locator) {
130            (Some(existing), Some(incoming)) if existing != incoming => {
131                bail!(
132                    "pack_locator mismatch for tenant {} pack {}",
133                    tenant.tenant,
134                    pack.pack_id
135                );
136            }
137            (None, Some(incoming)) => {
138                existing.pack_locator = Some(incoming.clone());
139            }
140            _ => {}
141        }
142        let mut flows = HashSet::new();
143        flows.extend(existing.flows.iter().cloned());
144        flows.extend(pack.flows);
145        existing.flows = flows.into_iter().collect();
146        existing.flows.sort();
147        return Ok(());
148    }
149    tenant.packs.push(pack);
150    tenant.packs.sort_by(|a, b| a.pack_id.cmp(&b.pack_id));
151    Ok(())
152}
153
154fn merge_env(tenant: &mut TenantBindings, envs: Vec<String>) {
155    let mut merged = HashSet::new();
156    merged.extend(tenant.env_passthrough.iter().cloned());
157    merged.extend(envs);
158    tenant.env_passthrough = merged.into_iter().collect();
159    tenant.env_passthrough.sort();
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use tempfile::TempDir;
166
167    fn write_file(path: &Path, body: &str) {
168        fs::write(path, body).expect("write file");
169    }
170
171    #[test]
172    fn collect_gtbind_paths_scans_dirs_and_dedups() {
173        let temp = TempDir::new().expect("tempdir");
174        let a = temp.path().join("a.gtbind");
175        let b = temp.path().join("b.gtbind");
176        let ignored = temp.path().join("ignored.txt");
177        write_file(&a, "tenant: demo\npack_id: pack\npack_ref: pack@1\n");
178        write_file(&b, "tenant: demo\npack_id: pack2\npack_ref: pack2@1\n");
179        write_file(&ignored, "ignore");
180
181        let paths = collect_gtbind_paths(std::slice::from_ref(&a), &[temp.path().to_path_buf()])
182            .expect("collect paths");
183
184        assert_eq!(paths, vec![a, b]);
185    }
186
187    #[test]
188    fn load_gtbinds_merges_flows_locators_and_env() {
189        let temp = TempDir::new().expect("tempdir");
190        let one = temp.path().join("one.gtbind");
191        let two = temp.path().join("two.gtbind");
192        write_file(
193            &one,
194            r#"
195tenant: demo
196pack_id: pack.main
197pack_ref: pack.main@1.0.0
198pack_locator: fs:///packs/main.gtpack
199flows:
200  - id: flow-a
201  - id: ""
202env_passthrough: [TOKEN, API_KEY]
203"#,
204        );
205        write_file(
206            &two,
207            r#"
208tenant: demo
209pack_id: pack.main
210pack_ref: pack.main@1.0.0
211flows:
212  - id: flow-b
213env_passthrough: [API_KEY, SECRET]
214"#,
215        );
216
217        let tenants = load_gtbinds(&[one, two]).expect("load gtbinds");
218        let tenant = tenants.get("demo").expect("tenant");
219        assert_eq!(tenant.packs.len(), 1);
220        assert_eq!(tenant.packs[0].flows, vec!["flow-a", "flow-b"]);
221        assert_eq!(
222            tenant.packs[0].pack_locator.as_deref(),
223            Some("fs:///packs/main.gtpack")
224        );
225        assert_eq!(tenant.env_passthrough, vec!["API_KEY", "SECRET", "TOKEN"]);
226    }
227
228    #[test]
229    fn load_gtbinds_rejects_missing_required_fields() {
230        let temp = TempDir::new().expect("tempdir");
231        let file = temp.path().join("broken.gtbind");
232        write_file(&file, "tenant: demo\npack_id: ''\npack_ref: pack@1\n");
233
234        assert!(load_gtbinds(&[file]).is_err());
235    }
236
237    #[test]
238    fn merge_pack_rejects_conflicting_refs_and_locators() {
239        let mut tenant = TenantBindings {
240            tenant: "demo".into(),
241            packs: vec![PackBinding {
242                pack_id: "pack.main".into(),
243                pack_ref: "pack.main@1.0.0".into(),
244                pack_locator: Some("fs:///packs/a.gtpack".into()),
245                flows: vec!["flow-a".into()],
246            }],
247            env_passthrough: Vec::new(),
248        };
249
250        assert!(
251            merge_pack(
252                &mut tenant,
253                PackBinding {
254                    pack_id: "pack.main".into(),
255                    pack_ref: "pack.main@2.0.0".into(),
256                    pack_locator: Some("fs:///packs/a.gtpack".into()),
257                    flows: vec![],
258                }
259            )
260            .is_err()
261        );
262
263        assert!(
264            merge_pack(
265                &mut tenant,
266                PackBinding {
267                    pack_id: "pack.main".into(),
268                    pack_ref: "pack.main@1.0.0".into(),
269                    pack_locator: Some("fs:///packs/b.gtpack".into()),
270                    flows: vec![],
271                }
272            )
273            .is_err()
274        );
275    }
276}