greentic-runner-host 0.5.14

Host runtime shim for Greentic runner: config, pack loading, activity handling
Documentation
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::Deserialize;

#[derive(Debug, Clone)]
pub struct PackBinding {
    pub pack_id: String,
    pub pack_ref: String,
    pub pack_locator: Option<String>,
    pub flows: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct TenantBindings {
    pub tenant: String,
    pub packs: Vec<PackBinding>,
    pub env_passthrough: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct GtBindFile {
    tenant: String,
    pack_id: String,
    pack_ref: String,
    #[serde(default)]
    pack_locator: Option<String>,
    #[serde(default)]
    flows: Vec<GtBindFlow>,
    #[serde(default)]
    env_passthrough: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct GtBindFlow {
    id: String,
}

pub fn collect_gtbind_paths(paths: &[PathBuf], dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
    let mut resolved = Vec::new();
    for path in paths {
        if path.is_dir() {
            resolved.extend(scan_dir(path)?);
        } else if path.is_file() {
            resolved.push(path.to_path_buf());
        } else {
            bail!("bindings path does not exist: {}", path.display());
        }
    }
    for dir in dirs {
        if !dir.is_dir() {
            bail!("bindings dir does not exist: {}", dir.display());
        }
        resolved.extend(scan_dir(dir)?);
    }
    resolved.sort();
    resolved.dedup();
    Ok(resolved)
}

pub fn load_gtbinds(paths: &[PathBuf]) -> Result<HashMap<String, TenantBindings>> {
    let mut tenants: HashMap<String, TenantBindings> = HashMap::new();
    for path in paths {
        let content = fs::read_to_string(path)
            .with_context(|| format!("failed to read gtbind {}", path.display()))?;
        let raw: GtBindFile = serde_yaml_bw::from_str(&content)
            .with_context(|| format!("failed to parse gtbind {}", path.display()))?;
        if raw.pack_id.trim().is_empty() {
            bail!("gtbind {} missing pack_id", path.display());
        }
        if raw.pack_ref.trim().is_empty() {
            bail!("gtbind {} missing pack_ref", path.display());
        }
        if raw.tenant.trim().is_empty() {
            bail!("gtbind {} missing tenant", path.display());
        }
        let flows = raw
            .flows
            .into_iter()
            .map(|flow| flow.id)
            .filter(|id| !id.trim().is_empty())
            .collect::<Vec<_>>();
        let pack = PackBinding {
            pack_id: raw.pack_id,
            pack_ref: raw.pack_ref,
            pack_locator: raw.pack_locator,
            flows,
        };
        let entry = tenants
            .entry(raw.tenant.clone())
            .or_insert_with(|| TenantBindings {
                tenant: raw.tenant.clone(),
                packs: Vec::new(),
                env_passthrough: Vec::new(),
            });
        merge_pack(entry, pack)?;
        merge_env(entry, raw.env_passthrough);
    }
    Ok(tenants)
}

fn scan_dir(dir: &Path) -> Result<Vec<PathBuf>> {
    let mut entries = Vec::new();
    for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) == Some("gtbind") {
            entries.push(path);
        }
    }
    Ok(entries)
}

fn merge_pack(tenant: &mut TenantBindings, pack: PackBinding) -> Result<()> {
    if let Some(existing) = tenant
        .packs
        .iter_mut()
        .find(|entry| entry.pack_id == pack.pack_id)
    {
        if existing.pack_ref != pack.pack_ref {
            bail!(
                "pack_ref mismatch for tenant {} pack {}",
                tenant.tenant,
                pack.pack_id
            );
        }
        match (&existing.pack_locator, &pack.pack_locator) {
            (Some(existing), Some(incoming)) if existing != incoming => {
                bail!(
                    "pack_locator mismatch for tenant {} pack {}",
                    tenant.tenant,
                    pack.pack_id
                );
            }
            (None, Some(incoming)) => {
                existing.pack_locator = Some(incoming.clone());
            }
            _ => {}
        }
        let mut flows = HashSet::new();
        flows.extend(existing.flows.iter().cloned());
        flows.extend(pack.flows);
        existing.flows = flows.into_iter().collect();
        existing.flows.sort();
        return Ok(());
    }
    tenant.packs.push(pack);
    tenant.packs.sort_by(|a, b| a.pack_id.cmp(&b.pack_id));
    Ok(())
}

fn merge_env(tenant: &mut TenantBindings, envs: Vec<String>) {
    let mut merged = HashSet::new();
    merged.extend(tenant.env_passthrough.iter().cloned());
    merged.extend(envs);
    tenant.env_passthrough = merged.into_iter().collect();
    tenant.env_passthrough.sort();
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn write_file(path: &Path, body: &str) {
        fs::write(path, body).expect("write file");
    }

    #[test]
    fn collect_gtbind_paths_scans_dirs_and_dedups() {
        let temp = TempDir::new().expect("tempdir");
        let a = temp.path().join("a.gtbind");
        let b = temp.path().join("b.gtbind");
        let ignored = temp.path().join("ignored.txt");
        write_file(&a, "tenant: demo\npack_id: pack\npack_ref: pack@1\n");
        write_file(&b, "tenant: demo\npack_id: pack2\npack_ref: pack2@1\n");
        write_file(&ignored, "ignore");

        let paths = collect_gtbind_paths(std::slice::from_ref(&a), &[temp.path().to_path_buf()])
            .expect("collect paths");

        assert_eq!(paths, vec![a, b]);
    }

    #[test]
    fn load_gtbinds_merges_flows_locators_and_env() {
        let temp = TempDir::new().expect("tempdir");
        let one = temp.path().join("one.gtbind");
        let two = temp.path().join("two.gtbind");
        write_file(
            &one,
            r#"
tenant: demo
pack_id: pack.main
pack_ref: pack.main@1.0.0
pack_locator: fs:///packs/main.gtpack
flows:
  - id: flow-a
  - id: ""
env_passthrough: [TOKEN, API_KEY]
"#,
        );
        write_file(
            &two,
            r#"
tenant: demo
pack_id: pack.main
pack_ref: pack.main@1.0.0
flows:
  - id: flow-b
env_passthrough: [API_KEY, SECRET]
"#,
        );

        let tenants = load_gtbinds(&[one, two]).expect("load gtbinds");
        let tenant = tenants.get("demo").expect("tenant");
        assert_eq!(tenant.packs.len(), 1);
        assert_eq!(tenant.packs[0].flows, vec!["flow-a", "flow-b"]);
        assert_eq!(
            tenant.packs[0].pack_locator.as_deref(),
            Some("fs:///packs/main.gtpack")
        );
        assert_eq!(tenant.env_passthrough, vec!["API_KEY", "SECRET", "TOKEN"]);
    }

    #[test]
    fn load_gtbinds_rejects_missing_required_fields() {
        let temp = TempDir::new().expect("tempdir");
        let file = temp.path().join("broken.gtbind");
        write_file(&file, "tenant: demo\npack_id: ''\npack_ref: pack@1\n");

        assert!(load_gtbinds(&[file]).is_err());
    }

    #[test]
    fn merge_pack_rejects_conflicting_refs_and_locators() {
        let mut tenant = TenantBindings {
            tenant: "demo".into(),
            packs: vec![PackBinding {
                pack_id: "pack.main".into(),
                pack_ref: "pack.main@1.0.0".into(),
                pack_locator: Some("fs:///packs/a.gtpack".into()),
                flows: vec!["flow-a".into()],
            }],
            env_passthrough: Vec::new(),
        };

        assert!(
            merge_pack(
                &mut tenant,
                PackBinding {
                    pack_id: "pack.main".into(),
                    pack_ref: "pack.main@2.0.0".into(),
                    pack_locator: Some("fs:///packs/a.gtpack".into()),
                    flows: vec![],
                }
            )
            .is_err()
        );

        assert!(
            merge_pack(
                &mut tenant,
                PackBinding {
                    pack_id: "pack.main".into(),
                    pack_ref: "pack.main@1.0.0".into(),
                    pack_locator: Some("fs:///packs/b.gtpack".into()),
                    flows: vec![],
                }
            )
            .is_err()
        );
    }
}