1use anyhow::{Context, Result};
2use greentic_types::{SecretFormat, SecretKey, SecretRequirement, SecretScope};
3use serde_json::Value;
4use tracing::warn;
5
6use crate::{ExecConfig, ExecError, ExecRequest, exec};
7
8#[cfg(feature = "describe-v1")]
9const DESCRIBE_INTERFACE: &str = "greentic:component/describe-v1@1.0.0";
10#[cfg(feature = "describe-v1")]
11const DESCRIBE_EXPORT: &str = "greentic:component/describe-v1@1.0.0#describe-json";
12
13#[derive(Debug)]
14pub enum Maybe<T> {
15 Data(T),
16 Unsupported,
17}
18
19#[derive(Debug)]
20pub struct ToolDescribe {
21 pub describe_v1: Option<Value>,
22 pub capabilities: Maybe<Vec<String>>,
23 pub secrets: Maybe<Value>,
24 pub config_schema: Maybe<Value>,
25 pub secret_requirements: Vec<SecretRequirement>,
26}
27
28pub fn describe_tool(name: &str, cfg: &ExecConfig) -> Result<ToolDescribe> {
29 #[cfg(feature = "describe-v1")]
30 {
31 if let Some(document) = try_describe_v1(name, cfg)? {
32 let (secret_requirements, used_legacy) =
33 secret_requirements(Some(&document), &Maybe::Unsupported);
34 if used_legacy {
35 warn!(
36 tool = name,
37 "legacy secrets descriptors were mapped; emit `secret_requirements` in describe-json"
38 );
39 }
40 return Ok(ToolDescribe {
41 describe_v1: Some(document),
42 capabilities: Maybe::Unsupported,
43 secrets: Maybe::Unsupported,
44 config_schema: Maybe::Unsupported,
45 secret_requirements,
46 });
47 }
48 }
49
50 fn try_action(name: &str, action: &str, cfg: &ExecConfig) -> Result<Maybe<Value>> {
51 let req = ExecRequest {
52 component: name.to_string(),
53 action: action.to_string(),
54 args: Value::Object(Default::default()),
55 tenant: None,
56 };
57
58 match exec(req, cfg) {
59 Ok(v) => Ok(Maybe::Data(v)),
60 Err(ExecError::NotFound { .. }) => Ok(Maybe::Unsupported),
61 Err(ExecError::Tool { code, payload, .. }) if code == "iface-error.not-found" => {
62 let _ = payload;
63 Ok(Maybe::Unsupported)
64 }
65 Err(e) => Err(e.into()),
66 }
67 }
68
69 let capabilities_value = try_action(name, "capabilities", cfg)?;
70 let secrets = try_action(name, "list_secrets", cfg)?;
71 let config_schema = try_action(name, "config_schema", cfg)?;
72
73 let capabilities = match capabilities_value {
74 Maybe::Data(value) => {
75 if let Some(arr) = value.as_array() {
76 let list = arr
77 .iter()
78 .filter_map(|v| v.as_str().map(|s| s.to_string()))
79 .collect::<Vec<_>>();
80 Maybe::Data(list)
81 } else {
82 Maybe::Data(Vec::new())
83 }
84 }
85 Maybe::Unsupported => Maybe::Unsupported,
86 };
87
88 let (secret_requirements, used_legacy) = secret_requirements(None, &secrets);
89 if used_legacy {
90 warn!(
91 tool = name,
92 "legacy secrets descriptors were mapped; emit `secret_requirements` in tool metadata"
93 );
94 }
95
96 Ok(ToolDescribe {
97 describe_v1: None,
98 capabilities,
99 secrets,
100 config_schema,
101 secret_requirements,
102 })
103}
104
105#[cfg(feature = "describe-v1")]
106fn try_describe_v1(name: &str, cfg: &ExecConfig) -> Result<Option<Value>> {
107 use wasmtime::component::{Component, Linker};
108 use wasmtime::{Config, Engine, Store};
109
110 let resolved =
111 crate::resolve::resolve(name, &cfg.store).map_err(|err| ExecError::resolve(name, err))?;
112 let verified = crate::verify::verify(name, resolved, &cfg.security)
113 .map_err(|err| ExecError::verification(name, err))?;
114
115 let mut config = Config::new();
116 config.wasm_component_model(true);
117 config.async_support(false);
118 config.epoch_interruption(true);
119
120 let engine = Engine::new(&config)?;
121 let component = match Component::from_binary(&engine, verified.resolved.bytes.as_ref()) {
122 Ok(component) => component,
123 Err(_) => return Ok(None),
124 };
125 let linker = Linker::new(&engine);
126 let mut store = Store::new(&engine, ());
127
128 let instance = match linker.instantiate(&mut store, &component) {
129 Ok(instance) => instance,
130 Err(_) => return Ok(None),
131 };
132 if instance
133 .get_export(&mut store, None, DESCRIBE_INTERFACE)
134 .is_none()
135 {
136 return Ok(None);
137 }
138
139 let func = match instance.get_typed_func::<(), (String,)>(&mut store, DESCRIBE_EXPORT) {
140 Ok(func) => func,
141 Err(err) => {
142 let msg = err.to_string();
143 if msg.contains("unknown export") {
144 return Ok(None);
145 }
146 return Err(err);
147 }
148 };
149
150 let (raw,) = func.call(&mut store, ())?;
151 let value: Value =
152 serde_json::from_str(&raw).with_context(|| "describe-json returned invalid JSON")?;
153 Ok(Some(value))
154}
155
156pub const RUNTIME_SENTINEL: &str = "runtime";
157
158fn secret_requirements(
159 describe_v1: Option<&Value>,
160 secrets: &Maybe<Value>,
161) -> (Vec<SecretRequirement>, bool) {
162 if let Some(requirements) = describe_v1.and_then(|doc| doc.get("secret_requirements")) {
163 let parsed = normalize_requirements(requirements);
164 return (dedup(parsed), false);
165 }
166
167 if let Maybe::Data(value) = secrets {
168 let parsed = normalize_requirements(value);
169 return (dedup(parsed), true);
170 }
171
172 (Vec::new(), matches!(secrets, Maybe::Data(_)))
173}
174
175fn normalize_requirements(value: &Value) -> Vec<SecretRequirement> {
176 if let Some(arr) = value.as_array() {
177 return arr.iter().filter_map(parse_requirement).collect();
178 }
179
180 if let Some(obj) = value.as_object() {
181 if let Some(reqs) = obj.get("secret_requirements").and_then(Value::as_array) {
182 return reqs.iter().filter_map(parse_requirement).collect();
183 }
184 if let Some(reqs) = obj.get("secrets").and_then(Value::as_array) {
185 return reqs.iter().filter_map(parse_requirement).collect();
186 }
187 }
188
189 Vec::new()
190}
191
192fn parse_requirement(value: &Value) -> Option<SecretRequirement> {
193 match value {
194 Value::String(key) => {
195 let key = SecretKey::new(key).ok()?;
196 let mut req = SecretRequirement::default();
197 req.key = key;
198 req.required = true;
199 req.scope = Some(runtime_scope());
200 req.format = Some(default_format());
201 Some(req)
202 }
203 Value::Object(obj) => {
204 let key_raw = obj
205 .get("key")
206 .or_else(|| obj.get("name"))
207 .and_then(Value::as_str)?;
208 let key = SecretKey::new(key_raw).ok()?;
209 let required = obj
210 .get("required")
211 .and_then(Value::as_bool)
212 .unwrap_or_else(|| {
213 !obj.get("optional")
214 .and_then(Value::as_bool)
215 .unwrap_or(false)
216 });
217
218 let scope = parse_scope(obj.get("scope"))
219 .or_else(|| parse_scope(Some(&Value::Object(obj.clone()))))
220 .unwrap_or_else(runtime_scope);
221
222 let format = obj
223 .get("format")
224 .and_then(Value::as_str)
225 .and_then(parse_format)
226 .unwrap_or_else(default_format);
227
228 let description = obj
229 .get("description")
230 .and_then(Value::as_str)
231 .map(ToOwned::to_owned);
232
233 let schema = obj.get("schema").cloned();
234 let examples = obj
235 .get("examples")
236 .and_then(Value::as_array)
237 .map(|arr| {
238 arr.iter()
239 .filter_map(example_to_string)
240 .collect::<Vec<String>>()
241 })
242 .unwrap_or_default();
243
244 let mut req = SecretRequirement::default();
245 req.key = key;
246 req.required = required;
247 req.description = description;
248 req.scope = Some(scope);
249 req.format = Some(format);
250 req.schema = schema;
251 req.examples = examples;
252 Some(req)
253 }
254 _ => None,
255 }
256}
257
258fn parse_scope(value: Option<&Value>) -> Option<SecretScope> {
259 let obj = value?.as_object()?;
260 let env = obj.get("env").and_then(Value::as_str)?;
261 let tenant = obj.get("tenant").and_then(Value::as_str)?;
262 let team = obj
263 .get("team")
264 .and_then(Value::as_str)
265 .map(ToOwned::to_owned);
266
267 Some(SecretScope {
268 env: env.to_owned(),
269 tenant: tenant.to_owned(),
270 team,
271 })
272}
273
274fn parse_format(value: &str) -> Option<SecretFormat> {
275 match value.trim().to_ascii_lowercase().as_str() {
276 "json" => Some(SecretFormat::Json),
277 "text" => Some(SecretFormat::Text),
278 "opaque" => Some(SecretFormat::Bytes),
279 "binary" | "bytes" | "byte" | "bin" => Some(SecretFormat::Bytes),
280 _ => None,
281 }
282}
283
284fn runtime_scope() -> SecretScope {
285 SecretScope {
286 env: RUNTIME_SENTINEL.to_owned(),
287 tenant: RUNTIME_SENTINEL.to_owned(),
288 team: None,
289 }
290}
291
292fn default_format() -> SecretFormat {
293 SecretFormat::Text
294}
295
296fn example_to_string(value: &Value) -> Option<String> {
297 match value {
298 Value::String(s) => Some(s.clone()),
299 other => serde_json::to_string(other).ok(),
300 }
301}
302
303fn dedup(requirements: Vec<SecretRequirement>) -> Vec<SecretRequirement> {
304 use std::collections::HashSet;
305
306 let mut seen = HashSet::new();
307 let mut out = Vec::with_capacity(requirements.len());
308 for req in requirements {
309 let scope_key = req
310 .scope
311 .as_ref()
312 .map(|scope| (scope.env.clone(), scope.tenant.clone(), scope.team.clone()));
313 if seen.insert((req.key.clone(), scope_key)) {
314 out.push(req);
315 }
316 }
317 out
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use serde_json::json;
324
325 #[test]
326 fn maps_describe_v1_secret_requirements() {
327 let describe_v1 = json!({
328 "name": "demo",
329 "secret_requirements": [
330 {
331 "key": "api-key",
332 "required": false,
333 "format": "json",
334 "scope": { "env": "dev", "tenant": "acme" },
335 "description": "auth key"
336 }
337 ]
338 });
339
340 let (reqs, used_legacy) = secret_requirements(Some(&describe_v1), &Maybe::Unsupported);
341 assert!(!used_legacy);
342 assert_eq!(reqs.len(), 1);
343 let req = &reqs[0];
344 assert_eq!(req.key.as_str(), "api-key");
345 assert!(!req.required);
346 assert_eq!(req.format, Some(SecretFormat::Json));
347 let scope = req.scope.as_ref().expect("scope set");
348 assert_eq!(scope.env, "dev");
349 assert_eq!(scope.tenant, "acme");
350 assert_eq!(req.description.as_deref(), Some("auth key"));
351 }
352
353 #[test]
354 fn maps_legacy_list_secrets_strings() {
355 let secrets = Maybe::Data(json!(["token", "secondary"]));
356 let (reqs, used_legacy) = secret_requirements(None, &secrets);
357 assert!(used_legacy);
358 assert_eq!(reqs.len(), 2);
359
360 for req in reqs {
361 assert!(req.required);
362 assert_eq!(req.format, Some(SecretFormat::Text));
363 let scope = req.scope.as_ref().expect("scope set");
364 assert_eq!(scope.env, RUNTIME_SENTINEL);
365 assert_eq!(scope.tenant, RUNTIME_SENTINEL);
366 }
367 }
368}