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 SecretScope::new(env, tenant, team).ok()
268}
269
270fn parse_format(value: &str) -> Option<SecretFormat> {
271 match value.trim().to_ascii_lowercase().as_str() {
272 "json" => Some(SecretFormat::Json),
273 "text" => Some(SecretFormat::Text),
274 "opaque" => Some(SecretFormat::Opaque),
275 "binary" | "bytes" | "byte" | "bin" => Some(SecretFormat::Binary),
276 _ => None,
277 }
278}
279
280fn runtime_scope() -> SecretScope {
281 SecretScope::new(RUNTIME_SENTINEL, RUNTIME_SENTINEL, None)
282 .expect("sentinel scope should be valid")
283}
284
285fn default_format() -> SecretFormat {
286 SecretFormat::Text
287}
288
289fn example_to_string(value: &Value) -> Option<String> {
290 match value {
291 Value::String(s) => Some(s.clone()),
292 other => serde_json::to_string(other).ok(),
293 }
294}
295
296fn dedup(requirements: Vec<SecretRequirement>) -> Vec<SecretRequirement> {
297 use std::collections::HashSet;
298
299 let mut seen = HashSet::new();
300 let mut out = Vec::with_capacity(requirements.len());
301 for req in requirements {
302 let scope_key = req.scope.clone();
303 if seen.insert((req.key.clone(), scope_key)) {
304 out.push(req);
305 }
306 }
307 out
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use serde_json::json;
314
315 #[test]
316 fn maps_describe_v1_secret_requirements() {
317 let describe_v1 = json!({
318 "name": "demo",
319 "secret_requirements": [
320 {
321 "key": "api-key",
322 "required": false,
323 "format": "json",
324 "scope": { "env": "dev", "tenant": "acme" },
325 "description": "auth key"
326 }
327 ]
328 });
329
330 let (reqs, used_legacy) = secret_requirements(Some(&describe_v1), &Maybe::Unsupported);
331 assert!(!used_legacy);
332 assert_eq!(reqs.len(), 1);
333 let req = &reqs[0];
334 assert_eq!(req.key.as_str(), "api-key");
335 assert!(!req.required);
336 assert_eq!(req.format, Some(SecretFormat::Json));
337 let scope = req.scope.as_ref().expect("scope set");
338 assert_eq!(scope.env(), "dev");
339 assert_eq!(scope.tenant(), "acme");
340 assert_eq!(req.description.as_deref(), Some("auth key"));
341 }
342
343 #[test]
344 fn maps_legacy_list_secrets_strings() {
345 let secrets = Maybe::Data(json!(["token", "secondary"]));
346 let (reqs, used_legacy) = secret_requirements(None, &secrets);
347 assert!(used_legacy);
348 assert_eq!(reqs.len(), 2);
349
350 for req in reqs {
351 assert!(req.required);
352 assert_eq!(req.format, Some(SecretFormat::Text));
353 let scope = req.scope.as_ref().expect("scope set");
354 assert_eq!(scope.env(), RUNTIME_SENTINEL);
355 assert_eq!(scope.tenant(), RUNTIME_SENTINEL);
356 }
357 }
358}