Skip to main content

greentic_operator/
component_qa_ops.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use anyhow::Context;
5use serde_json::{Value as JsonValue, json};
6
7use crate::demo::runner_host::{DemoRunnerHost, OperatorContext};
8use crate::discovery::{self, DiscoveryOptions};
9use crate::domains::{Domain, ProviderPack};
10use crate::secrets_gate;
11use greentic_types::cbor::canonical;
12use greentic_types::decode_pack_manifest;
13use greentic_types::schemas::component::v0_6_0::ComponentQaSpec;
14
15#[derive(Debug, Clone, Copy, Eq, PartialEq)]
16pub enum QaMode {
17    Default,
18    Setup,
19    Upgrade,
20    Remove,
21}
22
23impl QaMode {
24    pub fn as_str(self) -> &'static str {
25        match self {
26            QaMode::Default => "default",
27            QaMode::Setup => "setup",
28            QaMode::Upgrade => "upgrade",
29            QaMode::Remove => "remove",
30        }
31    }
32}
33
34#[derive(Debug, Clone, Copy, Eq, PartialEq)]
35pub enum QaDiagnosticCode {
36    QaSpecFailed,
37    QaSpecInvalid,
38    I18nExportMissing,
39    I18nKeyMissing,
40    ApplyAnswersFailed,
41    ConfigSchemaMismatch,
42}
43
44impl QaDiagnosticCode {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            QaDiagnosticCode::QaSpecFailed => "OP_QA_SPEC_FAILED",
48            QaDiagnosticCode::QaSpecInvalid => "OP_QA_SPEC_INVALID",
49            QaDiagnosticCode::I18nExportMissing => "OP_I18N_EXPORT_MISSING",
50            QaDiagnosticCode::I18nKeyMissing => "OP_I18N_KEY_MISSING",
51            QaDiagnosticCode::ApplyAnswersFailed => "OP_APPLY_ANSWERS_FAILED",
52            QaDiagnosticCode::ConfigSchemaMismatch => "OP_CONFIG_SCHEMA_MISMATCH",
53        }
54    }
55}
56
57#[derive(Debug, Clone)]
58pub struct QaDiagnostic {
59    pub code: QaDiagnosticCode,
60    pub message: String,
61}
62
63impl std::fmt::Display for QaDiagnostic {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(f, "{}: {}", self.code.as_str(), self.message)
66    }
67}
68
69impl std::error::Error for QaDiagnostic {}
70
71pub fn qa_mode_for_flow(flow_id: &str) -> Option<QaMode> {
72    let normalized = flow_id.to_ascii_lowercase();
73    if normalized.contains("remove") {
74        Some(QaMode::Remove)
75    } else if normalized.contains("upgrade") {
76        Some(QaMode::Upgrade)
77    } else if normalized.contains("default") {
78        Some(QaMode::Default)
79    } else if normalized.contains("setup") {
80        Some(QaMode::Setup)
81    } else {
82        None
83    }
84}
85
86#[allow(clippy::too_many_arguments)]
87pub fn apply_answers_via_component_qa(
88    root: &Path,
89    domain: Domain,
90    tenant: &str,
91    team: Option<&str>,
92    pack: &ProviderPack,
93    provider_id: &str,
94    mode: QaMode,
95    current_config: Option<&JsonValue>,
96    answers: &JsonValue,
97) -> Result<Option<JsonValue>, QaDiagnostic> {
98    if !supports_component_qa_contract(&pack.path).map_err(|err| {
99        diagnostic(
100            QaDiagnosticCode::QaSpecFailed,
101            format!("inspect qa contract support: {err}"),
102        )
103    })? {
104        return Ok(None);
105    }
106
107    let cbor_only = root.join("greentic.demo.yaml").exists();
108    let discovery = discovery::discover_with_options(root, DiscoveryOptions { cbor_only })
109        .map_err(|err| {
110            diagnostic(
111                QaDiagnosticCode::QaSpecFailed,
112                format!("discover providers: {err}"),
113            )
114        })?;
115    let secrets_handle =
116        secrets_gate::resolve_secrets_manager(root, tenant, team).map_err(|err| {
117            diagnostic(
118                QaDiagnosticCode::QaSpecFailed,
119                format!("resolve secrets manager: {err}"),
120            )
121        })?;
122    let host = DemoRunnerHost::new(root.to_path_buf(), &discovery, None, secrets_handle, false)
123        .map_err(|err| {
124            diagnostic(
125                QaDiagnosticCode::QaSpecFailed,
126                format!("build runner host: {err}"),
127            )
128        })?;
129    let ctx = OperatorContext {
130        tenant: tenant.to_string(),
131        team: team.map(|value| value.to_string()),
132        correlation_id: None,
133    };
134
135    let qa_payload = serde_json::to_vec(&json!({"mode": mode.as_str()})).map_err(|err| {
136        diagnostic(
137            QaDiagnosticCode::QaSpecFailed,
138            format!("encode qa-spec payload: {err}"),
139        )
140    })?;
141    let qa_out = host
142        .invoke_provider_component_op_direct(
143            domain,
144            pack,
145            provider_id,
146            "qa-spec",
147            &qa_payload,
148            &ctx,
149        )
150        .map_err(|err| {
151            diagnostic(
152                QaDiagnosticCode::QaSpecFailed,
153                format!("invoke qa-spec: {err}"),
154            )
155        })?;
156    if !qa_out.success {
157        let message = qa_out.error.unwrap_or_else(|| "unknown error".to_string());
158        if is_missing_op(&message) {
159            return Ok(None);
160        }
161        return Err(diagnostic(QaDiagnosticCode::QaSpecFailed, message));
162    }
163    let qa_json = qa_out.output.ok_or_else(|| {
164        diagnostic(
165            QaDiagnosticCode::QaSpecFailed,
166            "missing qa-spec output payload".to_string(),
167        )
168    })?;
169    let qa_spec: ComponentQaSpec = serde_json::from_value(qa_json).map_err(|err| {
170        diagnostic(
171            QaDiagnosticCode::QaSpecInvalid,
172            format!("decode qa-spec payload: {err}"),
173        )
174    })?;
175
176    let i18n_payload = serde_json::to_vec(&json!({})).map_err(|err| {
177        diagnostic(
178            QaDiagnosticCode::I18nExportMissing,
179            format!("encode i18n-keys payload: {err}"),
180        )
181    })?;
182    let i18n_out = host
183        .invoke_provider_component_op_direct(
184            domain,
185            pack,
186            provider_id,
187            "i18n-keys",
188            &i18n_payload,
189            &ctx,
190        )
191        .map_err(|err| {
192            diagnostic(
193                QaDiagnosticCode::I18nExportMissing,
194                format!("invoke i18n-keys: {err}"),
195            )
196        })?;
197    if !i18n_out.success {
198        let message = i18n_out
199            .error
200            .unwrap_or_else(|| "unknown error".to_string());
201        return Err(diagnostic(QaDiagnosticCode::I18nExportMissing, message));
202    }
203    let i18n_json = i18n_out.output.ok_or_else(|| {
204        diagnostic(
205            QaDiagnosticCode::I18nExportMissing,
206            "missing i18n-keys payload".to_string(),
207        )
208    })?;
209    let known_keys: Vec<String> = serde_json::from_value(i18n_json).map_err(|err| {
210        diagnostic(
211            QaDiagnosticCode::I18nExportMissing,
212            format!("i18n-keys payload is not a string array: {err}"),
213        )
214    })?;
215    validate_i18n_contract(&qa_spec, &known_keys)?;
216
217    let apply_payload = serde_json::to_vec(&json!({
218        "mode": mode.as_str(),
219        "current_config": current_config.cloned().unwrap_or_else(|| json!({})),
220        "answers": answers,
221    }))
222    .map_err(|err| {
223        diagnostic(
224            QaDiagnosticCode::ApplyAnswersFailed,
225            format!("encode apply-answers payload: {err}"),
226        )
227    })?;
228    let apply_out = host
229        .invoke_provider_component_op_direct(
230            domain,
231            pack,
232            provider_id,
233            "apply-answers",
234            &apply_payload,
235            &ctx,
236        )
237        .map_err(|err| {
238            diagnostic(
239                QaDiagnosticCode::ApplyAnswersFailed,
240                format!("invoke apply-answers: {err}"),
241            )
242        })?;
243    if !apply_out.success {
244        let message = apply_out
245            .error
246            .unwrap_or_else(|| "unknown error".to_string());
247        return Err(diagnostic(QaDiagnosticCode::ApplyAnswersFailed, message));
248    }
249    let apply_json = apply_out.output.ok_or_else(|| {
250        diagnostic(
251            QaDiagnosticCode::ApplyAnswersFailed,
252            "missing apply-answers payload".to_string(),
253        )
254    })?;
255    let config = extract_config_from_apply_output(apply_json);
256
257    if let Some(schema) = read_pack_config_schema(&pack.path).map_err(|err| {
258        diagnostic(
259            QaDiagnosticCode::ConfigSchemaMismatch,
260            format!("read config schema: {err}"),
261        )
262    })? && let Some(reason) = validate_config_strict(&config, &schema)
263    {
264        return Err(diagnostic(QaDiagnosticCode::ConfigSchemaMismatch, reason));
265    }
266
267    Ok(Some(config))
268}
269
270pub fn persist_answers_artifacts(
271    providers_root: &Path,
272    provider_id: &str,
273    mode: QaMode,
274    answers: &JsonValue,
275) -> anyhow::Result<(PathBuf, PathBuf)> {
276    let answers_dir = providers_root.join(provider_id).join("answers");
277    std::fs::create_dir_all(&answers_dir)?;
278    let json_path = answers_dir.join(format!("{}.answers.json", mode.as_str()));
279    let cbor_path = answers_dir.join(format!("{}.answers.cbor", mode.as_str()));
280    let json_bytes = serde_json::to_vec_pretty(answers)?;
281    let cbor_bytes =
282        canonical::to_canonical_cbor(answers).map_err(|err| anyhow::anyhow!("{err}"))?;
283    std::fs::write(&json_path, json_bytes)?;
284    std::fs::write(&cbor_path, cbor_bytes)?;
285    Ok((json_path, cbor_path))
286}
287
288fn validate_i18n_contract(
289    qa_spec: &ComponentQaSpec,
290    known_keys: &[String],
291) -> Result<(), QaDiagnostic> {
292    let known_key_set = known_keys
293        .iter()
294        .cloned()
295        .collect::<std::collections::BTreeSet<_>>();
296    let missing = qa_spec
297        .i18n_keys()
298        .into_iter()
299        .filter(|key| !known_key_set.contains(key))
300        .collect::<Vec<_>>();
301    if !missing.is_empty() {
302        return Err(diagnostic(
303            QaDiagnosticCode::I18nKeyMissing,
304            format!("unknown keys referenced by qa-spec: {}", missing.join(", ")),
305        ));
306    }
307    Ok(())
308}
309
310fn extract_config_from_apply_output(apply_json: JsonValue) -> JsonValue {
311    if let Some(value) = apply_json.get("config") {
312        value.clone()
313    } else {
314        apply_json
315    }
316}
317
318fn supports_component_qa_contract(pack_path: &Path) -> anyhow::Result<bool> {
319    let bytes = match read_manifest_cbor_bytes(pack_path) {
320        Ok(bytes) => bytes,
321        Err(_) => return Ok(false),
322    };
323    let decoded = match decode_pack_manifest(&bytes) {
324        Ok(value) => value,
325        Err(_) => return Ok(false),
326    };
327    let Some(provider_ext) = decoded.provider_extension_inline() else {
328        return Ok(false);
329    };
330    let supports = provider_ext.providers.iter().any(|provider| {
331        provider.ops.iter().any(|op| op == "qa-spec")
332            && provider.ops.iter().any(|op| op == "apply-answers")
333            && provider.ops.iter().any(|op| op == "i18n-keys")
334    });
335    Ok(supports)
336}
337
338fn read_pack_config_schema(pack_path: &Path) -> anyhow::Result<Option<JsonValue>> {
339    let bytes = read_manifest_cbor_bytes(pack_path)?;
340    let decoded = decode_pack_manifest(&bytes)
341        .with_context(|| format!("decode manifest.cbor {}", pack_path.display()))?;
342    let schema = decoded
343        .components
344        .first()
345        .and_then(|component| component.config_schema.clone());
346    Ok(schema)
347}
348
349fn read_manifest_cbor_bytes(pack_path: &Path) -> anyhow::Result<Vec<u8>> {
350    let file = std::fs::File::open(pack_path)?;
351    let mut archive = zip::ZipArchive::new(file)?;
352    let mut manifest = archive
353        .by_name("manifest.cbor")
354        .with_context(|| format!("manifest.cbor missing in {}", pack_path.display()))?;
355    let mut bytes = Vec::new();
356    std::io::Read::read_to_end(&mut manifest, &mut bytes)?;
357    Ok(bytes)
358}
359
360fn validate_config_strict(config: &JsonValue, schema: &JsonValue) -> Option<String> {
361    if schema.is_object()
362        && let Err(err) = jsonschema::validate(schema, config)
363    {
364        return Some(err.to_string());
365    }
366    validate_config_shallow(config, schema)
367}
368
369fn validate_config_shallow(config: &JsonValue, schema: &JsonValue) -> Option<String> {
370    let schema_obj = schema.as_object()?;
371
372    if let Some(expected) = schema_obj.get("type").and_then(JsonValue::as_str)
373        && !matches_json_type(config, expected)
374    {
375        return Some(format!(
376            "config type mismatch: expected `{expected}`, got `{}`",
377            json_type_name(config)
378        ));
379    }
380
381    if let Some(required) = schema_obj.get("required").and_then(JsonValue::as_array)
382        && let Some(map) = config.as_object()
383    {
384        for key in required.iter().filter_map(JsonValue::as_str) {
385            if !map.contains_key(key) {
386                return Some(format!("missing required config key `{key}`"));
387            }
388        }
389    }
390
391    if let (Some(properties), Some(map)) = (
392        schema_obj.get("properties").and_then(JsonValue::as_object),
393        config.as_object(),
394    ) {
395        for (key, value) in map {
396            if let Some(prop_schema) = properties.get(key)
397                && let Some(expected) = prop_schema.get("type").and_then(JsonValue::as_str)
398                && !matches_json_type(value, expected)
399            {
400                return Some(format!(
401                    "config key `{key}` type mismatch: expected `{expected}`, got `{}`",
402                    json_type_name(value)
403                ));
404            }
405        }
406        if schema_obj
407            .get("additionalProperties")
408            .is_some_and(|value| value == &JsonValue::Bool(false))
409        {
410            for key in map.keys() {
411                if !properties.contains_key(key) {
412                    return Some(format!("unknown config key `{key}`"));
413                }
414            }
415        }
416    }
417
418    None
419}
420
421fn matches_json_type(value: &JsonValue, expected: &str) -> bool {
422    match expected {
423        "object" => value.is_object(),
424        "array" => value.is_array(),
425        "string" => value.is_string(),
426        "boolean" => value.is_boolean(),
427        "number" => value.is_number(),
428        "integer" => {
429            value.as_i64().is_some()
430                || value.as_u64().is_some()
431                || value.as_f64().is_some_and(|number| number.fract() == 0.0)
432        }
433        "null" => value.is_null(),
434        _ => true,
435    }
436}
437
438fn json_type_name(value: &JsonValue) -> &'static str {
439    if value.is_object() {
440        "object"
441    } else if value.is_array() {
442        "array"
443    } else if value.is_string() {
444        "string"
445    } else if value.is_boolean() {
446        "boolean"
447    } else if value.is_number() {
448        "number"
449    } else {
450        "null"
451    }
452}
453
454fn is_missing_op(message: &str) -> bool {
455    let lower = message.to_ascii_lowercase();
456    lower.contains("not found") || lower.contains("opnotfound") || lower.contains("op not found")
457}
458
459fn diagnostic(code: QaDiagnosticCode, message: String) -> QaDiagnostic {
460    QaDiagnostic { code, message }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use greentic_types::i18n_text::I18nText;
467    use greentic_types::schemas::component::v0_6_0::{
468        ComponentQaSpec, QaMode as SpecQaMode, Question, QuestionKind,
469    };
470    use std::collections::BTreeMap;
471
472    #[test]
473    fn shallow_schema_type_mismatch_is_reported() {
474        let config = json!({"enabled":"yes"});
475        let schema = json!({
476            "type": "object",
477            "properties": {
478                "enabled": {"type":"boolean"}
479            },
480            "required": ["enabled"]
481        });
482        let message = validate_config_shallow(&config, &schema).unwrap();
483        assert!(message.contains("enabled"));
484        assert!(message.contains("boolean"));
485    }
486
487    #[test]
488    fn strict_schema_reports_missing_required_property() {
489        let config = json!({});
490        let schema = json!({
491            "type": "object",
492            "properties": {
493                "token": {"type":"string"}
494            },
495            "required": ["token"]
496        });
497        let message = validate_config_strict(&config, &schema).unwrap();
498        assert!(message.to_ascii_lowercase().contains("token"));
499    }
500
501    #[test]
502    fn shallow_schema_accepts_valid_object() {
503        let config = json!({"enabled": true, "name": "demo"});
504        let schema = json!({
505            "type": "object",
506            "properties": {
507                "enabled": {"type":"boolean"},
508                "name": {"type":"string"}
509            },
510            "required": ["enabled", "name"]
511        });
512        assert!(validate_config_shallow(&config, &schema).is_none());
513    }
514
515    #[test]
516    fn missing_op_detection_matches_common_messages() {
517        assert!(is_missing_op("op not found"));
518        assert!(is_missing_op("OperatorErrorCode::OpNotFound"));
519        assert!(!is_missing_op("invalid input"));
520    }
521
522    #[test]
523    fn qa_mode_infers_from_flow_names() {
524        assert_eq!(qa_mode_for_flow("setup_default"), Some(QaMode::Default));
525        assert_eq!(qa_mode_for_flow("setup_upgrade"), Some(QaMode::Upgrade));
526        assert_eq!(qa_mode_for_flow("setup_remove"), Some(QaMode::Remove));
527        assert_eq!(qa_mode_for_flow("setup"), Some(QaMode::Setup));
528        assert_eq!(qa_mode_for_flow("verify_webhooks"), None);
529    }
530
531    #[test]
532    fn qa_contract_success_path_validates_i18n() {
533        let qa_spec = sample_qa_spec();
534        let known_keys = vec![
535            "qa.title".to_string(),
536            "qa.question.label".to_string(),
537            "qa.question.help".to_string(),
538            "qa.question.error".to_string(),
539        ];
540        assert!(validate_i18n_contract(&qa_spec, &known_keys).is_ok());
541    }
542
543    #[test]
544    fn qa_contract_reports_missing_i18n_keys() {
545        let qa_spec = sample_qa_spec();
546        let known_keys = vec!["qa.title".to_string()];
547        let err = validate_i18n_contract(&qa_spec, &known_keys).unwrap_err();
548        assert_eq!(err.code, QaDiagnosticCode::I18nKeyMissing);
549        assert!(err.message.contains("unknown keys"));
550    }
551
552    #[test]
553    fn extract_apply_output_prefers_config_field() {
554        let config = extract_config_from_apply_output(json!({"config": {"token":"x"}}));
555        assert_eq!(config, json!({"token":"x"}));
556    }
557
558    fn sample_qa_spec() -> ComponentQaSpec {
559        ComponentQaSpec {
560            mode: SpecQaMode::Setup,
561            title: I18nText {
562                key: "qa.title".to_string(),
563                fallback: None,
564            },
565            description: None,
566            questions: vec![Question {
567                id: "token".to_string(),
568                label: I18nText {
569                    key: "qa.question.label".to_string(),
570                    fallback: None,
571                },
572                help: Some(I18nText {
573                    key: "qa.question.help".to_string(),
574                    fallback: None,
575                }),
576                error: Some(I18nText {
577                    key: "qa.question.error".to_string(),
578                    fallback: None,
579                }),
580                kind: QuestionKind::Text,
581                required: true,
582                default: None,
583                skip_if: None,
584            }],
585            defaults: BTreeMap::new(),
586        }
587    }
588}