greentic_component_runtime/
binder.rs1use 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}