component_runtime/
binder.rs

1use std::collections::{HashMap, HashSet};
2
3use jsonschema::Validator;
4use serde_json::Value;
5
6use crate::error::CompError;
7use crate::loader::{ComponentHandle, TenantBinding};
8use greentic_types::TenantCtx;
9
10#[derive(Debug, Clone)]
11pub struct Bindings {
12    pub config: Value,
13    pub secrets: Vec<String>,
14}
15
16impl Bindings {
17    pub fn new(config: Value, secrets: Vec<String>) -> Self {
18        Self { config, secrets }
19    }
20}
21
22#[derive(Debug, Default)]
23pub struct Binder;
24
25impl Binder {
26    pub fn bind(
27        &self,
28        handle: &ComponentHandle,
29        tenant: &TenantCtx,
30        bindings: &Bindings,
31        secret_resolver: &mut dyn FnMut(&str, &TenantCtx) -> Result<String, CompError>,
32    ) -> Result<(), CompError> {
33        let inner = &handle.inner;
34        let binding = resolve_binding(
35            &inner.info,
36            inner.config_schema.as_ref(),
37            bindings,
38            tenant,
39            secret_resolver,
40        )?;
41
42        let key = binding_key(tenant);
43        let mut guard = inner.bindings.lock().expect("binding mutex poisoned");
44        guard.insert(key, binding);
45        Ok(())
46    }
47}
48
49pub(crate) fn binding_key(ctx: &TenantCtx) -> String {
50    format!("{}::{}", ctx.env.as_str(), ctx.tenant.as_str())
51}
52
53pub(crate) fn resolve_binding(
54    info: &component_manifest::ComponentInfo,
55    schema: &Validator,
56    bindings: &Bindings,
57    tenant: &TenantCtx,
58    secret_resolver: &mut dyn FnMut(&str, &TenantCtx) -> Result<String, CompError>,
59) -> Result<TenantBinding, CompError> {
60    validate_config(schema, &bindings.config)?;
61    let allowed_secrets: HashSet<String> = info.secrets.iter().cloned().collect();
62
63    let mut resolved = HashSet::new();
64    let mut secret_values = HashMap::new();
65    for secret in &bindings.secrets {
66        if !allowed_secrets.contains(secret) {
67            return Err(CompError::SecretNotDeclared(secret.clone()));
68        }
69        if !resolved.insert(secret.clone()) {
70            continue;
71        }
72        let value = secret_resolver(secret, tenant)
73            .map_err(|err| CompError::secret_resolution(secret.clone(), err))?;
74        secret_values.insert(secret.clone(), value);
75    }
76
77    Ok(TenantBinding {
78        config: bindings.config.clone(),
79        secrets: secret_values,
80    })
81}
82
83fn validate_config(schema: &Validator, config: &Value) -> Result<(), CompError> {
84    let mut errors = schema.iter_errors(config);
85    if let Some(first_error) = errors.next() {
86        let message = std::iter::once(first_error.to_string())
87            .chain(errors.map(|err| err.to_string()))
88            .collect::<Vec<_>>()
89            .join(", ");
90        Err(CompError::SchemaValidation(message))
91    } else {
92        Ok(())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use component_manifest::{CapabilityRef, ComponentInfo, WitCompat};
100    use greentic_types::{EnvId, TenantCtx, TenantId};
101    use jsonschema::validator_for;
102    use serde_json::{Map, json};
103
104    fn component_fixture() -> (ComponentInfo, Validator) {
105        let manifest_json = json!({
106            "capabilities": ["telemetry"],
107            "exports": [{"operation": "noop"}],
108            "config_schema": {
109                "type": "object",
110                "properties": {"enabled": {"type": "boolean"}},
111                "required": ["enabled"],
112                "additionalProperties": false
113            },
114            "secrets": ["API_TOKEN"],
115            "wit_compat": {
116                "package": "greentic:component",
117                "min": "0.4.0",
118                "max": "0.4.x"
119            }
120        });
121
122        let config_schema_json = manifest_json.get("config_schema").cloned().unwrap();
123        let schema = validator_for(&config_schema_json).unwrap();
124
125        let info = ComponentInfo {
126            name: Some("fixture".into()),
127            description: None,
128            capabilities: vec![CapabilityRef("telemetry".into())],
129            exports: vec![component_manifest::CompiledExportSchema {
130                operation: "noop".into(),
131                description: None,
132                input_schema: None,
133                output_schema: None,
134            }],
135            config_schema: config_schema_json,
136            secrets: vec!["API_TOKEN".into()],
137            wit_compat: WitCompat {
138                package: "greentic:component".into(),
139                min: "0.4.0".into(),
140                max: Some("0.4.x".into()),
141            },
142            metadata: Map::new(),
143            raw: manifest_json,
144        };
145
146        (info, schema)
147    }
148
149    fn tenant_ctx() -> TenantCtx {
150        TenantCtx::new(EnvId("dev".into()), TenantId("tenant".into()))
151    }
152
153    #[test]
154    fn resolves_valid_binding() {
155        let (info, schema) = component_fixture();
156        let tenant = tenant_ctx();
157        let bindings = Bindings {
158            config: json!({"enabled": true}),
159            secrets: vec!["API_TOKEN".into()],
160        };
161        let mut resolver = |key: &str, _ctx: &TenantCtx| -> Result<String, CompError> {
162            Ok(format!("value-for-{key}"))
163        };
164
165        let binding = resolve_binding(&info, &schema, &bindings, &tenant, &mut resolver).unwrap();
166        assert_eq!(
167            binding.secrets.get("API_TOKEN").unwrap(),
168            "value-for-API_TOKEN"
169        );
170    }
171
172    #[test]
173    fn rejects_unknown_secret() {
174        let (info, schema) = component_fixture();
175        let tenant = tenant_ctx();
176        let bindings = Bindings {
177            config: json!({"enabled": true}),
178            secrets: vec!["UNKNOWN".into()],
179        };
180        let mut resolver =
181            |_key: &str, _ctx: &TenantCtx| -> Result<String, CompError> { Ok("secret".into()) };
182
183        let err = resolve_binding(&info, &schema, &bindings, &tenant, &mut resolver).unwrap_err();
184        assert!(matches!(err, CompError::SecretNotDeclared(_)));
185    }
186
187    #[test]
188    fn rejects_invalid_config() {
189        let (info, schema) = component_fixture();
190        let tenant = tenant_ctx();
191        let bindings = Bindings {
192            config: json!({"enabled": "not-bool"}),
193            secrets: vec![],
194        };
195        let mut resolver =
196            |_key: &str, _ctx: &TenantCtx| -> Result<String, CompError> { Ok("secret".into()) };
197
198        let err = resolve_binding(&info, &schema, &bindings, &tenant, &mut resolver).unwrap_err();
199        assert!(matches!(err, CompError::SchemaValidation(_)));
200    }
201}