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}