Skip to main content

packc/cli/
inspect.rs

1#![forbid(unsafe_code)]
2
3use std::io::Write;
4use std::{
5    collections::HashMap,
6    fs, io,
7    path::{Path, PathBuf},
8    process::{Command, Stdio},
9};
10
11use anyhow::{Context, Result, anyhow, bail};
12use clap::Parser;
13use greentic_pack::static_routes::{StaticRouteV1, parse_static_routes_extension};
14use greentic_pack::validate::{
15    ComponentReferencesExistValidator, OauthCapabilityRequirementsValidator,
16    ProviderReferencesExistValidator, ReferencedFilesExistValidator, SbomConsistencyValidator,
17    SecretRequirementsValidator, StaticRoutesValidator, ValidateCtx, run_validators,
18};
19use greentic_pack::{PackLoad, SigningPolicy, open_pack};
20use greentic_types::component_source::ComponentSourceRef;
21use greentic_types::pack::extensions::component_manifests::{
22    ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
23};
24use greentic_types::pack::extensions::component_sources::{
25    ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
26};
27use greentic_types::pack_manifest::{ExtensionInline as PackManifestExtensionInline, PackManifest};
28use greentic_types::provider::ProviderDecl;
29use greentic_types::validate::{Diagnostic, Severity, ValidationReport};
30use serde::Serialize;
31use serde_cbor;
32use serde_json::Value;
33use tempfile::TempDir;
34
35use crate::build;
36use crate::extension_refs::{
37    default_extensions_file_path, default_extensions_lock_file_path, read_extensions_file,
38    read_extensions_lock_file, validate_extensions_lock_alignment,
39};
40use crate::extensions::DEPLOYER_EXTENSION_KEY;
41use crate::pack_lock_doctor::{PackLockDoctorInput, run_pack_lock_doctor};
42use crate::runtime::RuntimeContext;
43use crate::validator::{
44    DEFAULT_VALIDATOR_ALLOW, LocalValidator, ValidatorConfig, ValidatorPolicy, run_wasm_validators,
45};
46
47const EXT_BUILD_MODE_ID: &str = "greentic.pack-mode.v1";
48
49#[derive(Clone, Copy, PartialEq, Eq)]
50enum PackBuildMode {
51    Prod,
52    Dev,
53}
54
55#[derive(Debug, Parser)]
56pub struct InspectArgs {
57    /// Path to a pack (.gtpack or source dir). Defaults to current directory.
58    #[arg(value_name = "PATH")]
59    pub path: Option<PathBuf>,
60
61    /// Path to a compiled .gtpack archive
62    #[arg(long, value_name = "FILE", conflicts_with = "input")]
63    pub pack: Option<PathBuf>,
64
65    /// Path to a pack source directory containing pack.yaml
66    #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
67    pub input: Option<PathBuf>,
68
69    /// Force archive inspection (disables auto-detection)
70    #[arg(long)]
71    pub archive: bool,
72
73    /// Force source inspection (disables auto-detection)
74    #[arg(long)]
75    pub source: bool,
76
77    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
78    #[arg(long = "allow-oci-tags", default_value_t = false)]
79    pub allow_oci_tags: bool,
80
81    /// Disable per-flow doctor checks
82    #[arg(long = "no-flow-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
83    pub flow_doctor: bool,
84
85    /// Disable per-component doctor checks
86    #[arg(long = "no-component-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
87    pub component_doctor: bool,
88
89    /// Output format
90    #[arg(long, value_enum, default_value = "human")]
91    pub format: InspectFormat,
92
93    /// Enable validation (default)
94    #[arg(long, default_value_t = true)]
95    pub validate: bool,
96
97    /// Disable validation
98    #[arg(long = "no-validate", default_value_t = false)]
99    pub no_validate: bool,
100
101    /// Directory containing validator packs (.gtpack)
102    #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
103    pub validators_root: PathBuf,
104
105    /// Validator pack or component reference (path or oci://...)
106    #[arg(long, value_name = "REF")]
107    pub validator_pack: Vec<String>,
108
109    /// Validator component wasm (format: <COMPONENT_ID>=<FILE>)
110    #[arg(long, value_name = "COMPONENT=FILE")]
111    pub validator_wasm: Vec<String>,
112
113    /// Allowed OCI prefixes for validator refs
114    #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
115    pub validator_allow: Vec<String>,
116
117    /// Validator cache directory
118    #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
119    pub validator_cache_dir: PathBuf,
120
121    /// Validator loading policy
122    #[arg(long, value_enum, default_value = "optional")]
123    pub validator_policy: ValidatorPolicy,
124
125    /// Allow online resolution of component refs during pack lock checks (default: offline)
126    #[arg(long, default_value_t = false)]
127    pub online: bool,
128
129    /// Allow describe cache fallback when components cannot execute describe()
130    #[arg(long = "use-describe-cache", default_value_t = false)]
131    pub use_describe_cache: bool,
132}
133
134pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
135    let mode = resolve_mode(&args)?;
136    let format = resolve_format(&args, json);
137    let validate_enabled = if args.no_validate {
138        false
139    } else {
140        args.validate
141    };
142
143    let load = match &mode {
144        InspectMode::Archive(path) => inspect_pack_file(path)?,
145        InspectMode::Source(path) => inspect_source_dir(path, runtime, args.allow_oci_tags).await?,
146    };
147    let build_mode = detect_pack_build_mode(&load);
148    if matches!(mode, InspectMode::Archive(_)) && build_mode == PackBuildMode::Prod {
149        let forbidden = find_forbidden_source_paths(&load.files);
150        if !forbidden.is_empty() {
151            bail!(
152                "production pack contains forbidden source files: {}",
153                forbidden.join(", ")
154            );
155        }
156    }
157    let validation = if validate_enabled {
158        let mut output =
159            run_pack_validation(&load, source_mode_pack_dir(&mode), &args, runtime).await?;
160        let mut doctor_diagnostics = Vec::new();
161        let mut doctor_errors = false;
162        if args.component_doctor {
163            let use_describe_cache = args.use_describe_cache
164                || std::env::var("GREENTIC_PACK_USE_DESCRIBE_CACHE").is_ok()
165                || cfg!(test);
166            let pack_dir = match &mode {
167                InspectMode::Source(path) => Some(path.as_path()),
168                InspectMode::Archive(_) => None,
169            };
170            let pack_lock_output = run_pack_lock_doctor(PackLockDoctorInput {
171                load: &load,
172                pack_dir,
173                runtime,
174                allow_oci_tags: args.allow_oci_tags,
175                use_describe_cache,
176                online: args.online,
177            })?;
178            doctor_errors |= pack_lock_output.has_errors;
179            doctor_diagnostics.extend(pack_lock_output.diagnostics);
180        }
181        if args.flow_doctor {
182            doctor_errors |= run_flow_doctors(&load, &mut doctor_diagnostics, build_mode)?;
183        }
184        if args.component_doctor {
185            doctor_errors |= run_component_doctors(&load, &mut doctor_diagnostics)?;
186        }
187        output.report.diagnostics.extend(doctor_diagnostics);
188        output.has_errors |= doctor_errors;
189        Some(output)
190    } else {
191        None
192    };
193
194    match format {
195        InspectFormat::Json => {
196            let mut payload = serde_json::json!({
197                "manifest": load.manifest,
198                "report": {
199                    "signature_ok": load.report.signature_ok,
200                    "sbom_ok": load.report.sbom_ok,
201                    "warnings": load.report.warnings,
202                },
203                "sbom": load.sbom,
204                "static_routes": load_static_routes(&load),
205            });
206            if let Some(report) = validation.as_ref() {
207                payload["validation"] = serde_json::to_value(report)?;
208            }
209            println!("{}", to_sorted_json(payload)?);
210        }
211        InspectFormat::Human => {
212            print_human(&load, validation.as_ref());
213        }
214    }
215
216    if validate_enabled
217        && validation
218            .as_ref()
219            .map(|report| report.has_errors)
220            .unwrap_or(false)
221    {
222        bail!("pack validation failed");
223    }
224
225    Ok(())
226}
227
228fn to_sorted_json(value: Value) -> Result<String> {
229    let sorted = sort_json(value);
230    Ok(serde_json::to_string_pretty(&sorted)?)
231}
232
233fn sort_json(value: Value) -> Value {
234    match value {
235        Value::Object(map) => {
236            let mut entries: Vec<(String, Value)> = map.into_iter().collect();
237            entries.sort_by(|a, b| a.0.cmp(&b.0));
238            let mut sorted = serde_json::Map::new();
239            for (key, value) in entries {
240                sorted.insert(key, sort_json(value));
241            }
242            Value::Object(sorted)
243        }
244        Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
245        other => other,
246    }
247}
248
249fn run_flow_doctors(
250    load: &PackLoad,
251    diagnostics: &mut Vec<Diagnostic>,
252    build_mode: PackBuildMode,
253) -> Result<bool> {
254    if load.manifest.flows.is_empty() {
255        return Ok(false);
256    }
257
258    let mut has_errors = false;
259
260    for flow in &load.manifest.flows {
261        let Some(bytes) = load.files.get(&flow.file_yaml) else {
262            if build_mode == PackBuildMode::Prod {
263                continue;
264            }
265            diagnostics.push(Diagnostic {
266                severity: Severity::Error,
267                code: "PACK_FLOW_DOCTOR_MISSING_FLOW".to_string(),
268                message: "flow file missing from pack".to_string(),
269                path: Some(flow.file_yaml.clone()),
270                hint: Some("rebuild the pack to include flow sources".to_string()),
271                data: Value::Null,
272            });
273            has_errors = true;
274            continue;
275        };
276
277        let mut command = Command::new("greentic-flow");
278        command
279            .args(["doctor", "--json", "--stdin"])
280            .stdin(Stdio::piped())
281            .stdout(Stdio::piped())
282            .stderr(Stdio::piped());
283        let mut child = match command.spawn() {
284            Ok(child) => child,
285            Err(err) if err.kind() == io::ErrorKind::NotFound => {
286                diagnostics.push(Diagnostic {
287                    severity: Severity::Warn,
288                    code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
289                    message: "greentic-flow not available; skipping flow doctor checks".to_string(),
290                    path: None,
291                    hint: Some("install greentic-flow or pass --no-flow-doctor".to_string()),
292                    data: Value::Null,
293                });
294                return Ok(false);
295            }
296            Err(err) => return Err(err).context("run greentic-flow doctor"),
297        };
298        if let Some(mut stdin) = child.stdin.take() {
299            stdin
300                .write_all(bytes)
301                .context("write flow content to greentic-flow stdin")?;
302        }
303        let output = child
304            .wait_with_output()
305            .context("wait for greentic-flow doctor")?;
306
307        if !output.status.success() {
308            if flow_doctor_unsupported(&output) {
309                diagnostics.push(Diagnostic {
310                    severity: Severity::Warn,
311                    code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
312                    message: "greentic-flow does not support --stdin; skipping flow doctor checks"
313                        .to_string(),
314                    path: None,
315                    hint: Some("update greentic-flow or pass --no-flow-doctor".to_string()),
316                    data: json_diagnostic_data(&output),
317                });
318                return Ok(false);
319            }
320            has_errors = true;
321            diagnostics.push(Diagnostic {
322                severity: Severity::Error,
323                code: "PACK_FLOW_DOCTOR_FAILED".to_string(),
324                message: "flow doctor failed".to_string(),
325                path: Some(flow.file_yaml.clone()),
326                hint: Some("run `greentic-flow doctor` for details".to_string()),
327                data: json_diagnostic_data(&output),
328            });
329        }
330    }
331
332    Ok(has_errors)
333}
334
335fn flow_doctor_unsupported(output: &std::process::Output) -> bool {
336    let mut combined = String::new();
337    combined.push_str(&String::from_utf8_lossy(&output.stdout));
338    combined.push_str(&String::from_utf8_lossy(&output.stderr));
339    let combined = combined.to_lowercase();
340    combined.contains("--stdin") && combined.contains("unknown")
341        || combined.contains("found argument '--stdin'")
342        || combined.contains("unexpected argument '--stdin'")
343        || combined.contains("unrecognized option '--stdin'")
344}
345
346fn run_component_doctors(load: &PackLoad, diagnostics: &mut Vec<Diagnostic>) -> Result<bool> {
347    if load.manifest.components.is_empty() {
348        return Ok(false);
349    }
350
351    let temp = TempDir::new().context("allocate temp dir for component doctor")?;
352    let mut has_errors = false;
353
354    let mut manifest_paths = std::collections::HashMap::new();
355    if let Some(gpack_manifest) = load.gpack_manifest.as_ref()
356        && let Some(manifest_extension) = gpack_manifest
357            .extensions
358            .as_ref()
359            .and_then(|map| map.get(EXT_COMPONENT_MANIFEST_INDEX_V1))
360            .and_then(|entry| entry.inline.as_ref())
361            .and_then(|inline| match inline {
362                PackManifestExtensionInline::Other(value) => Some(value),
363                _ => None,
364            })
365            .and_then(|value| ComponentManifestIndexV1::from_extension_value(value).ok())
366    {
367        for entry in manifest_extension.entries {
368            manifest_paths.insert(entry.component_id, entry.manifest_file);
369        }
370    }
371
372    for component in &load.manifest.components {
373        let Some(wasm_bytes) = load.files.get(&component.file_wasm) else {
374            diagnostics.push(Diagnostic {
375                severity: Severity::Warn,
376                code: "PACK_COMPONENT_DOCTOR_MISSING_WASM".to_string(),
377                message: "component wasm missing from pack; skipping component doctor".to_string(),
378                path: Some(component.file_wasm.clone()),
379                hint: Some("rebuild with --bundle=cache or supply cached artifacts".to_string()),
380                data: Value::Null,
381            });
382            continue;
383        };
384
385        if component.manifest_file.is_none() {
386            if manifest_paths.contains_key(&component.name) {
387                continue;
388            }
389            diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
390            continue;
391        }
392
393        let manifest_bytes = if let Some(path) = component.manifest_file.as_deref()
394            && let Some(bytes) = load.files.get(path)
395        {
396            bytes.clone()
397        } else {
398            diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
399            continue;
400        };
401
402        let component_dir = temp.path().join(sanitize_component_id(&component.name));
403        fs::create_dir_all(&component_dir)
404            .with_context(|| format!("create temp dir for {}", component.name))?;
405        let wasm_path = component_dir.join("component.wasm");
406        let manifest_value = match serde_json::from_slice::<Value>(&manifest_bytes) {
407            Ok(value) => value,
408            Err(_) => match serde_cbor::from_slice::<Value>(&manifest_bytes) {
409                Ok(value) => value,
410                Err(err) => {
411                    diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
412                    tracing::debug!(
413                        manifest = %component.name,
414                        "failed to parse component manifest for doctor: {err}"
415                    );
416                    continue;
417                }
418            },
419        };
420
421        if !component_manifest_has_required_fields(&manifest_value) {
422            diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
423            continue;
424        }
425
426        let manifest_bytes =
427            serde_json::to_vec_pretty(&manifest_value).context("serialize component manifest")?;
428
429        let manifest_path = component_dir.join("component.manifest.json");
430        fs::write(&wasm_path, wasm_bytes)?;
431        fs::write(&manifest_path, manifest_bytes)?;
432
433        let output = match Command::new("greentic-component")
434            .args(["doctor"])
435            .arg(&wasm_path)
436            .args(["--manifest"])
437            .arg(&manifest_path)
438            .output()
439        {
440            Ok(output) => output,
441            Err(err) if err.kind() == io::ErrorKind::NotFound => {
442                diagnostics.push(Diagnostic {
443                    severity: Severity::Warn,
444                    code: "PACK_COMPONENT_DOCTOR_UNAVAILABLE".to_string(),
445                    message: "greentic-component not available; skipping component doctor checks"
446                        .to_string(),
447                    path: None,
448                    hint: Some(
449                        "install greentic-component or pass --no-component-doctor".to_string(),
450                    ),
451                    data: Value::Null,
452                });
453                return Ok(false);
454            }
455            Err(err) => return Err(err).context("run greentic-component doctor"),
456        };
457
458        if !output.status.success() {
459            has_errors = true;
460            diagnostics.push(Diagnostic {
461                severity: Severity::Error,
462                code: "PACK_COMPONENT_DOCTOR_FAILED".to_string(),
463                message: "component doctor failed".to_string(),
464                path: Some(component.name.clone()),
465                hint: Some("run `greentic-component doctor` for details".to_string()),
466                data: json_diagnostic_data(&output),
467            });
468        }
469    }
470
471    Ok(has_errors)
472}
473
474fn json_diagnostic_data(output: &std::process::Output) -> Value {
475    serde_json::json!({
476        "status": output.status.code(),
477        "stdout": String::from_utf8_lossy(&output.stdout).trim_end(),
478        "stderr": String::from_utf8_lossy(&output.stderr).trim_end(),
479    })
480}
481
482fn component_manifest_missing_diag(manifest_file: &Option<String>) -> Diagnostic {
483    Diagnostic {
484        severity: Severity::Warn,
485        code: "PACK_COMPONENT_DOCTOR_MISSING_MANIFEST".to_string(),
486        message: "component manifest missing or incomplete; skipping component doctor".to_string(),
487        path: manifest_file.clone(),
488        hint: Some("rebuild the pack to include component manifests".to_string()),
489        data: Value::Null,
490    }
491}
492
493fn component_manifest_has_required_fields(manifest: &Value) -> bool {
494    manifest.get("name").is_some()
495        && manifest.get("artifacts").is_some()
496        && manifest.get("hashes").is_some()
497        && manifest.get("describe_export").is_some()
498        && manifest.get("config_schema").is_some()
499}
500
501fn sanitize_component_id(value: &str) -> String {
502    value
503        .chars()
504        .map(|ch| {
505            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
506                ch
507            } else {
508                '_'
509            }
510        })
511        .collect()
512}
513
514fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
515    let load = open_pack(path, SigningPolicy::DevOk)
516        .map_err(|err| anyhow!(err.message))
517        .with_context(|| format!("failed to open pack {}", path.display()))?;
518    Ok(load)
519}
520
521fn detect_pack_build_mode(load: &PackLoad) -> PackBuildMode {
522    if let Some(manifest) = load.gpack_manifest.as_ref()
523        && let Some(mode) = manifest_build_mode(manifest)
524    {
525        return mode;
526    }
527    if load.files.keys().any(|path| path.ends_with(".ygtc")) {
528        return PackBuildMode::Dev;
529    }
530    PackBuildMode::Prod
531}
532
533fn manifest_build_mode(manifest: &PackManifest) -> Option<PackBuildMode> {
534    let extensions = manifest.extensions.as_ref()?;
535    let entry = extensions.get(EXT_BUILD_MODE_ID)?;
536    let inline = entry.inline.as_ref()?;
537    if let PackManifestExtensionInline::Other(value) = inline
538        && let Some(mode) = value.get("mode").and_then(|value| value.as_str())
539    {
540        if mode.eq_ignore_ascii_case("dev") {
541            return Some(PackBuildMode::Dev);
542        }
543        return Some(PackBuildMode::Prod);
544    }
545    None
546}
547
548fn find_forbidden_source_paths(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
549    files
550        .keys()
551        .filter(|path| is_forbidden_source_path(path))
552        .cloned()
553        .collect()
554}
555
556fn is_forbidden_source_path(path: &str) -> bool {
557    if matches!(path, "pack.yaml" | "pack.manifest.json") {
558        return true;
559    }
560    if matches!(
561        path,
562        "secret-requirements.json" | "secrets_requirements.json"
563    ) {
564        return true;
565    }
566    if path.ends_with(".ygtc") {
567        return true;
568    }
569    if path.starts_with("flows/") && path.ends_with(".json") {
570        return true;
571    }
572    if path.ends_with("manifest.json") {
573        return true;
574    }
575    false
576}
577
578enum InspectMode {
579    Archive(PathBuf),
580    Source(PathBuf),
581}
582
583fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
584    if args.archive && args.source {
585        bail!("--archive and --source are mutually exclusive");
586    }
587    if args.pack.is_some() && args.input.is_some() {
588        bail!("exactly one of --pack or --in may be supplied");
589    }
590
591    if let Some(path) = &args.pack {
592        return Ok(InspectMode::Archive(path.clone()));
593    }
594    if let Some(path) = &args.input {
595        return Ok(InspectMode::Source(path.clone()));
596    }
597    if let Some(path) = &args.path {
598        let meta =
599            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
600        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
601            return Ok(InspectMode::Archive(path.clone()));
602        }
603        if args.source || meta.is_dir() {
604            return Ok(InspectMode::Source(path.clone()));
605        }
606        if meta.is_file() {
607            return Ok(InspectMode::Archive(path.clone()));
608        }
609    }
610    Ok(InspectMode::Source(
611        std::env::current_dir().context("determine current directory")?,
612    ))
613}
614
615fn source_mode_pack_dir(mode: &InspectMode) -> Option<&Path> {
616    match mode {
617        InspectMode::Source(path) => Some(path.as_path()),
618        InspectMode::Archive(_) => None,
619    }
620}
621
622async fn inspect_source_dir(
623    dir: &Path,
624    runtime: &RuntimeContext,
625    allow_oci_tags: bool,
626) -> Result<PackLoad> {
627    let pack_dir = dir
628        .canonicalize()
629        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
630
631    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
632    let manifest_out = temp.path().join("manifest.cbor");
633    let gtpack_out = temp.path().join("pack.gtpack");
634
635    let opts = build::BuildOptions {
636        pack_dir,
637        component_out: None,
638        manifest_out,
639        sbom_out: None,
640        gtpack_out: Some(gtpack_out.clone()),
641        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
642        bundle: build::BundleMode::Cache,
643        dry_run: false,
644        secrets_req: None,
645        default_secret_scope: None,
646        allow_oci_tags,
647        require_component_manifests: false,
648        no_extra_dirs: false,
649        dev: true,
650        runtime: runtime.clone(),
651        skip_update: false,
652        allow_pack_schema: false,
653        validate_extension_refs: false,
654    };
655
656    build::run(&opts).await?;
657
658    inspect_pack_file(&gtpack_out)
659}
660
661fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
662    let manifest = &load.manifest;
663    let report = &load.report;
664    println!(
665        "Pack: {} ({})",
666        manifest.meta.pack_id, manifest.meta.version
667    );
668    println!("Name: {}", manifest.meta.name);
669    println!("Flows: {}", manifest.flows.len());
670    if manifest.flows.is_empty() {
671        println!("Flows list: none");
672    } else {
673        println!("Flows list:");
674        for flow in &manifest.flows {
675            println!(
676                "  - {} (entry: {}, kind: {})",
677                flow.id, flow.entry, flow.kind
678            );
679        }
680    }
681    println!("Components: {}", manifest.components.len());
682    if manifest.components.is_empty() {
683        println!("Components list: none");
684    } else {
685        println!("Components list:");
686        for component in &manifest.components {
687            println!("  - {} ({})", component.name, component.version);
688        }
689    }
690    if let Some(gmanifest) = load.gpack_manifest.as_ref()
691        && let Some(value) = gmanifest
692            .extensions
693            .as_ref()
694            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
695            .and_then(|ext| ext.inline.as_ref())
696            .and_then(|inline| match inline {
697                greentic_types::ExtensionInline::Other(v) => Some(v),
698                _ => None,
699            })
700        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
701    {
702        let mut inline = 0usize;
703        let mut remote = 0usize;
704        let mut oci = 0usize;
705        let mut repo = 0usize;
706        let mut store = 0usize;
707        let mut file = 0usize;
708        for entry in &cs.components {
709            match entry.artifact {
710                ArtifactLocationV1::Inline { .. } => inline += 1,
711                ArtifactLocationV1::Remote => remote += 1,
712            }
713            match entry.source {
714                ComponentSourceRef::Oci(_) => oci += 1,
715                ComponentSourceRef::Repo(_) => repo += 1,
716                ComponentSourceRef::Store(_) => store += 1,
717                ComponentSourceRef::File(_) => file += 1,
718            }
719        }
720        println!(
721            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
722            cs.components.len(),
723            oci,
724            repo,
725            store,
726            file,
727            inline,
728            remote
729        );
730        if cs.components.is_empty() {
731            println!("Component source entries: none");
732        } else {
733            println!("Component source entries:");
734            for entry in &cs.components {
735                println!(
736                    "  - {} source={} artifact={}",
737                    entry.name,
738                    format_component_source(&entry.source),
739                    format_component_artifact(&entry.artifact)
740                );
741            }
742        }
743    } else {
744        println!("Component sources: none");
745    }
746
747    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
748        let providers = providers_from_manifest(gmanifest);
749        if providers.is_empty() {
750            println!("Providers: none");
751        } else {
752            println!("Providers:");
753            for provider in providers {
754                println!(
755                    "  - {} ({}) {}",
756                    provider.provider_type,
757                    provider_kind(&provider),
758                    summarize_provider(&provider)
759                );
760            }
761        }
762    } else {
763        println!("Providers: none");
764    }
765
766    let static_routes = load_static_routes(load);
767    if static_routes.is_empty() {
768        println!("Static routes: none");
769    } else {
770        println!("Static routes:");
771        for route in &static_routes {
772            println!(
773                "  - {} -> {} [{}]",
774                route.id, route.public_path, route.source_root
775            );
776            println!(
777                "    scope: tenant={} team={}",
778                route.scope.tenant, route.scope.team
779            );
780            println!(
781                "    index_file: {}",
782                route.index_file.as_deref().unwrap_or("none")
783            );
784            println!(
785                "    spa_fallback: {}",
786                route.spa_fallback.as_deref().unwrap_or("none")
787            );
788            println!(
789                "    cache: {}",
790                route
791                    .cache
792                    .as_ref()
793                    .map(|cache| match cache.max_age_seconds {
794                        Some(max_age) => format!("{} ({max_age}s)", cache.strategy),
795                        None => cache.strategy.clone(),
796                    })
797                    .unwrap_or_else(|| "none".to_string())
798            );
799            if route.exports.is_empty() {
800                println!("    exports: none");
801            } else {
802                let exports = route
803                    .exports
804                    .iter()
805                    .map(|(key, value)| format!("{key}={value}"))
806                    .collect::<Vec<_>>()
807                    .join(", ");
808                println!("    exports: {exports}");
809            }
810        }
811    }
812
813    if !report.warnings.is_empty() {
814        println!("Warnings:");
815        for warning in &report.warnings {
816            println!("  - {}", warning);
817        }
818    }
819
820    if let Some(report) = validation {
821        print_validation(report);
822    }
823}
824
825fn load_static_routes(load: &PackLoad) -> Vec<StaticRouteV1> {
826    load.gpack_manifest
827        .as_ref()
828        .and_then(|manifest| {
829            parse_static_routes_extension(&manifest.extensions)
830                .ok()
831                .flatten()
832        })
833        .map(|payload| payload.routes)
834        .unwrap_or_default()
835}
836
837#[derive(Clone, Debug, Serialize)]
838struct ValidationOutput {
839    #[serde(flatten)]
840    report: ValidationReport,
841    has_errors: bool,
842    sources: Vec<crate::validator::ValidatorSourceReport>,
843}
844
845fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
846    diagnostics
847        .iter()
848        .any(|diag| matches!(diag.severity, Severity::Error))
849}
850
851async fn run_pack_validation(
852    load: &PackLoad,
853    source_pack_dir: Option<&Path>,
854    args: &InspectArgs,
855    runtime: &RuntimeContext,
856) -> Result<ValidationOutput> {
857    let ctx = ValidateCtx::from_pack_load(load);
858    let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
859        Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
860        Box::new(SbomConsistencyValidator::new(ctx.clone())),
861        Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
862        Box::new(SecretRequirementsValidator),
863        Box::new(StaticRoutesValidator::new(ctx.clone())),
864        Box::new(ComponentReferencesExistValidator),
865        Box::new(OauthCapabilityRequirementsValidator),
866    ];
867
868    let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
869        run_validators(manifest, &ctx, &validators)
870    } else {
871        ValidationReport {
872            pack_id: None,
873            pack_version: None,
874            diagnostics: vec![Diagnostic {
875                severity: Severity::Warn,
876                code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
877                message: "Pack manifest is not in the greentic-types format; skipping validation."
878                    .to_string(),
879                path: Some("manifest.cbor".to_string()),
880                hint: Some(
881                    "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
882                ),
883                data: Value::Null,
884            }],
885        }
886    };
887
888    let config = ValidatorConfig {
889        validators_root: args.validators_root.clone(),
890        validator_packs: args.validator_pack.clone(),
891        validator_allow: args.validator_allow.clone(),
892        validator_cache_dir: args.validator_cache_dir.clone(),
893        policy: args.validator_policy,
894        local_validators: parse_validator_wasm_args(&args.validator_wasm)?,
895    };
896
897    let wasm_result = run_wasm_validators(load, &config, runtime).await?;
898    report.diagnostics.extend(wasm_result.diagnostics);
899    if let Some(pack_dir) = source_pack_dir {
900        report
901            .diagnostics
902            .extend(collect_extension_dependency_diagnostics(pack_dir));
903    }
904
905    let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
906
907    Ok(ValidationOutput {
908        report,
909        has_errors,
910        sources: wasm_result.sources,
911    })
912}
913
914fn collect_extension_dependency_diagnostics(pack_dir: &Path) -> Vec<Diagnostic> {
915    let source_path = default_extensions_file_path(pack_dir);
916    let lock_path = default_extensions_lock_file_path(pack_dir);
917    let mut diagnostics = Vec::new();
918
919    let source = if source_path.exists() {
920        match read_extensions_file(&source_path) {
921            Ok(file) => Some(file),
922            Err(err) => {
923                diagnostics.push(Diagnostic {
924                    severity: Severity::Error,
925                    code: "PACK_EXTENSION_DEPENDENCY_SOURCE_INVALID".to_string(),
926                    message: err.to_string(),
927                    path: Some(path_display(pack_dir, &source_path)),
928                    hint: Some("fix pack.extensions.json and rerun doctor".to_string()),
929                    data: Value::Null,
930                });
931                None
932            }
933        }
934    } else {
935        None
936    };
937
938    let lock = if lock_path.exists() {
939        match read_extensions_lock_file(&lock_path) {
940            Ok(file) => Some(file),
941            Err(err) => {
942                diagnostics.push(Diagnostic {
943                    severity: Severity::Error,
944                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_INVALID".to_string(),
945                    message: err.to_string(),
946                    path: Some(path_display(pack_dir, &lock_path)),
947                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>`".to_string()),
948                    data: Value::Null,
949                });
950                None
951            }
952        }
953    } else {
954        None
955    };
956
957    match (source.as_ref(), lock.as_ref()) {
958        (Some(_), None) => diagnostics.push(Diagnostic {
959            severity: Severity::Warn,
960            code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING".to_string(),
961            message: "pack.extensions.json exists but pack.extensions.lock.json is missing"
962                .to_string(),
963            path: Some(path_display(pack_dir, &source_path)),
964            hint: Some("run `greentic-pack extensions-lock --in <DIR>`".to_string()),
965            data: Value::Null,
966        }),
967        (None, Some(_)) => diagnostics.push(Diagnostic {
968            severity: Severity::Warn,
969            code: "PACK_EXTENSION_DEPENDENCY_SOURCE_MISSING".to_string(),
970            message: "pack.extensions.lock.json exists but pack.extensions.json is missing"
971                .to_string(),
972            path: Some(path_display(pack_dir, &lock_path)),
973            hint: Some(
974                "restore pack.extensions.json or regenerate the lock from the intended source file"
975                    .to_string(),
976            ),
977            data: Value::Null,
978        }),
979        (Some(source), Some(lock)) => {
980            if let Err(err) = validate_extensions_lock_alignment(source, lock) {
981                diagnostics.push(Diagnostic {
982                    severity: Severity::Error,
983                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_STALE".to_string(),
984                    message: err.to_string(),
985                    path: Some(path_display(pack_dir, &lock_path)),
986                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` after editing pack.extensions.json".to_string()),
987                    data: Value::Null,
988                });
989            }
990        }
991        (None, None) => {}
992    }
993
994    if let Some(source) = source.as_ref() {
995        for extension in &source.extensions {
996            if extension.id == DEPLOYER_EXTENSION_KEY && extension.role != "deployer" {
997                diagnostics.push(Diagnostic {
998                    severity: Severity::Error,
999                    code: "PACK_DEPLOYER_EXTENSION_ROLE_INVALID".to_string(),
1000                    message: format!(
1001                        "extension `{}` must use role `deployer`, found `{}`",
1002                        extension.id, extension.role
1003                    ),
1004                    path: Some(path_display(pack_dir, &source_path)),
1005                    hint: Some("set the dependency role to `deployer`".to_string()),
1006                    data: Value::Null,
1007                });
1008            }
1009        }
1010    }
1011
1012    if let Some(lock) = lock.as_ref() {
1013        for extension in &lock.extensions {
1014            if extension.media_type.is_none() {
1015                diagnostics.push(Diagnostic {
1016                    severity: Severity::Warn,
1017                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_MEDIA_TYPE".to_string(),
1018                    message: format!(
1019                        "extension `{}` lock entry is missing media_type metadata",
1020                        extension.id
1021                    ),
1022                    path: Some(path_display(pack_dir, &lock_path)),
1023                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content type".to_string()),
1024                    data: Value::Null,
1025                });
1026            }
1027            if extension.size_bytes.is_none() {
1028                diagnostics.push(Diagnostic {
1029                    severity: Severity::Warn,
1030                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_SIZE".to_string(),
1031                    message: format!(
1032                        "extension `{}` lock entry is missing size metadata",
1033                        extension.id
1034                    ),
1035                    path: Some(path_display(pack_dir, &lock_path)),
1036                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content length".to_string()),
1037                    data: Value::Null,
1038                });
1039            }
1040        }
1041    }
1042
1043    diagnostics
1044}
1045
1046fn path_display(root: &Path, path: &Path) -> String {
1047    path.strip_prefix(root)
1048        .unwrap_or(path)
1049        .display()
1050        .to_string()
1051}
1052
1053fn print_validation(report: &ValidationOutput) {
1054    let (info, warn, error) = validation_counts(&report.report);
1055    println!("Validation:");
1056    println!("  Info: {info} Warn: {warn} Error: {error}");
1057    if report.report.diagnostics.is_empty() {
1058        println!("  - none");
1059        return;
1060    }
1061    for diag in &report.report.diagnostics {
1062        let sev = match diag.severity {
1063            Severity::Info => "INFO",
1064            Severity::Warn => "WARN",
1065            Severity::Error => "ERROR",
1066        };
1067        if let Some(path) = diag.path.as_deref() {
1068            println!("  - [{sev}] {} {} - {}", diag.code, path, diag.message);
1069        } else {
1070            println!("  - [{sev}] {} - {}", diag.code, diag.message);
1071        }
1072        if matches!(
1073            diag.code.as_str(),
1074            "PACK_FLOW_DOCTOR_FAILED" | "PACK_COMPONENT_DOCTOR_FAILED"
1075        ) {
1076            print_doctor_failure_details(&diag.data);
1077        }
1078        if let Some(hint) = diag.hint.as_deref() {
1079            println!("    hint: {hint}");
1080        }
1081    }
1082}
1083
1084fn parse_validator_wasm_args(args: &[String]) -> Result<Vec<LocalValidator>> {
1085    let mut local_validators = Vec::new();
1086    for entry in args {
1087        let mut segments = entry.splitn(2, '=');
1088        let component_id = segments.next().unwrap_or_default().trim().to_string();
1089        let path = segments
1090            .next()
1091            .map(|p| p.trim())
1092            .filter(|p| !p.is_empty())
1093            .ok_or_else(|| {
1094                anyhow!(
1095                    "invalid --validator-wasm argument `{}` (expected format COMPONENT_ID=FILE)",
1096                    entry
1097                )
1098            })?;
1099        if component_id.is_empty() {
1100            return Err(anyhow!(
1101                "validator component id must not be empty in `{}`",
1102                entry
1103            ));
1104        }
1105        local_validators.push(LocalValidator {
1106            component_id,
1107            path: PathBuf::from(path),
1108        });
1109    }
1110    Ok(local_validators)
1111}
1112
1113fn print_doctor_failure_details(data: &Value) {
1114    let Some(obj) = data.as_object() else {
1115        return;
1116    };
1117    let stdout = obj.get("stdout").and_then(|value| value.as_str());
1118    let stderr = obj.get("stderr").and_then(|value| value.as_str());
1119    let status = obj.get("status").and_then(|value| value.as_i64());
1120    if let Some(status) = status {
1121        println!("    status: {status}");
1122    }
1123    if let Some(stderr) = stderr {
1124        let trimmed = stderr.trim();
1125        if !trimmed.is_empty() {
1126            println!("    stderr: {trimmed}");
1127        }
1128    }
1129    if let Some(stdout) = stdout {
1130        let trimmed = stdout.trim();
1131        if !trimmed.is_empty() {
1132            println!("    stdout: {trimmed}");
1133        }
1134    }
1135}
1136
1137fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
1138    let mut info = 0;
1139    let mut warn = 0;
1140    let mut error = 0;
1141    for diag in &report.diagnostics {
1142        match diag.severity {
1143            Severity::Info => info += 1,
1144            Severity::Warn => warn += 1,
1145            Severity::Error => error += 1,
1146        }
1147    }
1148    (info, warn, error)
1149}
1150
1151#[derive(Debug, Clone, Copy, clap::ValueEnum)]
1152pub enum InspectFormat {
1153    Human,
1154    Json,
1155}
1156
1157fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
1158    if json {
1159        InspectFormat::Json
1160    } else {
1161        args.format
1162    }
1163}
1164
1165fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
1166    let mut providers = manifest
1167        .provider_extension_inline()
1168        .map(|inline| inline.providers.clone())
1169        .unwrap_or_default();
1170    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
1171    providers
1172}
1173
1174fn provider_kind(provider: &ProviderDecl) -> String {
1175    provider
1176        .runtime
1177        .world
1178        .split('@')
1179        .next()
1180        .unwrap_or_default()
1181        .to_string()
1182}
1183
1184fn summarize_provider(provider: &ProviderDecl) -> String {
1185    let caps = provider.capabilities.len();
1186    let ops = provider.ops.len();
1187    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
1188    parts.push(format!("config:{}", provider.config_schema_ref));
1189    if let Some(docs) = provider.docs_ref.as_deref() {
1190        parts.push(format!("docs:{docs}"));
1191    }
1192    parts.join(" ")
1193}
1194
1195fn format_component_source(source: &ComponentSourceRef) -> String {
1196    match source {
1197        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
1198        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
1199        ComponentSourceRef::Store(value) => format_source_ref("store", value),
1200        ComponentSourceRef::File(value) => format_source_ref("file", value),
1201    }
1202}
1203
1204fn format_source_ref(scheme: &str, value: &str) -> String {
1205    if value.contains("://") {
1206        value.to_string()
1207    } else {
1208        format!("{scheme}://{value}")
1209    }
1210}
1211
1212fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
1213    match artifact {
1214        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
1215        ArtifactLocationV1::Remote => "remote".to_string(),
1216    }
1217}