Skip to main content

greentic_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
62        .secret_requirements
63        .iter()
64        .map(|req| req.key.as_str().to_string())
65        .collect();
66
67    let mut resolved = HashSet::new();
68    let mut secret_values = HashMap::new();
69    for secret in &bindings.secrets {
70        if !allowed_secrets.contains(secret) {
71            return Err(CompError::SecretNotDeclared(secret.clone()));
72        }
73        if !resolved.insert(secret.clone()) {
74            continue;
75        }
76        let value = secret_resolver(secret, tenant)
77            .map_err(|err| CompError::secret_resolution(secret.clone(), err))?;
78        secret_values.insert(secret.clone(), value.into_bytes());
79    }
80
81    Ok(TenantBinding {
82        config: bindings.config.clone(),
83        secrets: secret_values,
84    })
85}
86
87fn validate_config(schema: &Validator, config: &Value) -> Result<(), CompError> {
88    let mut errors = schema.iter_errors(config);
89    if let Some(first_error) = errors.next() {
90        let message = std::iter::once(first_error.to_string())
91            .chain(errors.map(|err| err.to_string()))
92            .collect::<Vec<_>>()
93            .join(", ");
94        Err(CompError::SchemaValidation(message))
95    } else {
96        Ok(())
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use component_manifest::{CapabilityRef, ComponentInfo, WitCompat};
104    use greentic_types::{
105        EnvId, SecretFormat, SecretKey, SecretRequirement, SecretScope, TenantCtx, TenantId,
106    };
107    use jsonschema::validator_for;
108    use serde_json::{Map, json};
109
110    fn component_fixture() -> (ComponentInfo, Validator) {
111        let manifest_json = json!({
112            "capabilities": ["telemetry"],
113            "exports": [{"operation": "noop"}],
114            "config_schema": {
115                "type": "object",
116                "properties": {"enabled": {"type": "boolean"}},
117                "required": ["enabled"],
118                "additionalProperties": false
119            },
120            "secret_requirements": [{
121                "key": "API_TOKEN",
122                "required": true,
123                "scope": { "env": "dev", "tenant": "tenant" },
124                "format": "text"
125            }],
126            "wit_compat": {
127                "package": "greentic:component",
128                "min": "0.4.0",
129                "max": "0.4.x"
130            }
131        });
132
133        let config_schema_json = manifest_json.get("config_schema").cloned().unwrap();
134        let schema = validator_for(&config_schema_json).unwrap();
135
136        let mut secret_requirement = SecretRequirement::default();
137        secret_requirement.key = SecretKey::new("API_TOKEN").unwrap();
138        secret_requirement.required = true;
139        secret_requirement.scope = Some(SecretScope {
140            env: "dev".into(),
141            tenant: "tenant".into(),
142            team: None,
143        });
144        secret_requirement.format = Some(SecretFormat::Text);
145
146        let info = ComponentInfo {
147            name: Some("fixture".into()),
148            description: None,
149            capabilities: vec![CapabilityRef("telemetry".into())],
150            exports: vec![component_manifest::CompiledExportSchema {
151                operation: "noop".into(),
152                description: None,
153                input_schema: None,
154                output_schema: None,
155            }],
156            config_schema: config_schema_json,
157            secret_requirements: vec![secret_requirement],
158            wit_compat: WitCompat {
159                package: "greentic:component".into(),
160                min: "0.4.0".into(),
161                max: Some("0.4.x".into()),
162            },
163            metadata: Map::new(),
164            raw: manifest_json,
165        };
166
167        (info, schema)
168    }
169
170    fn tenant_ctx() -> TenantCtx {
171        TenantCtx::new(EnvId("dev".into()), TenantId("tenant".into()))
172    }
173
174    #[test]
175    fn resolves_valid_binding() {
176        let (info, schema) = component_fixture();
177        let tenant = tenant_ctx();
178        let bindings = Bindings {
179            config: json!({"enabled": true}),
180            secrets: vec!["API_TOKEN".into()],
181        };
182        let mut resolver = |key: &str, _ctx: &TenantCtx| -> Result<String, CompError> {
183            Ok(format!("value-for-{key}"))
184        };
185
186        let binding = resolve_binding(&info, &schema, &bindings, &tenant, &mut resolver).unwrap();
187        assert_eq!(
188            binding.secrets.get("API_TOKEN").unwrap(),
189            b"value-for-API_TOKEN"
190        );
191    }
192
193    #[test]
194    fn rejects_unknown_secret() {
195        let (info, schema) = component_fixture();
196        let tenant = tenant_ctx();
197        let bindings = Bindings {
198            config: json!({"enabled": true}),
199            secrets: vec!["UNKNOWN".into()],
200        };
201        let mut resolver =
202            |_key: &str, _ctx: &TenantCtx| -> Result<String, CompError> { Ok("secret".into()) };
203
204        let err = resolve_binding(&info, &schema, &bindings, &tenant, &mut resolver).unwrap_err();
205        assert!(matches!(err, CompError::SecretNotDeclared(_)));
206    }
207
208    #[test]
209    fn rejects_invalid_config() {
210        let (info, schema) = component_fixture();
211        let tenant = tenant_ctx();
212        let bindings = Bindings {
213            config: json!({"enabled": "not-bool"}),
214            secrets: vec![],
215        };
216        let mut resolver =
217            |_key: &str, _ctx: &TenantCtx| -> Result<String, CompError> { Ok("secret".into()) };
218
219        let err = resolve_binding(&info, &schema, &bindings, &tenant, &mut resolver).unwrap_err();
220        assert!(matches!(err, CompError::SchemaValidation(_)));
221    }
222}