Skip to main content

packc/cli/
inspect.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    fs,
5    path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::Parser;
10use greentic_pack::validate::{
11    ComponentReferencesExistValidator, ProviderReferencesExistValidator,
12    ReferencedFilesExistValidator, SbomConsistencyValidator, ValidateCtx, run_validators,
13};
14use greentic_pack::{PackLoad, SigningPolicy, open_pack};
15use greentic_types::component_source::ComponentSourceRef;
16use greentic_types::pack::extensions::component_sources::{
17    ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
18};
19use greentic_types::pack_manifest::PackManifest;
20use greentic_types::provider::ProviderDecl;
21use greentic_types::validate::{Diagnostic, Severity, ValidationReport};
22use serde::Serialize;
23use serde_json::Value;
24use tempfile::TempDir;
25
26use crate::build;
27use crate::runtime::RuntimeContext;
28use crate::validator::{
29    DEFAULT_VALIDATOR_ALLOW, ValidatorConfig, ValidatorPolicy, run_wasm_validators,
30};
31
32#[derive(Debug, Parser)]
33pub struct InspectArgs {
34    /// Path to a pack (.gtpack or source dir). Defaults to current directory.
35    #[arg(value_name = "PATH")]
36    pub path: Option<PathBuf>,
37
38    /// Path to a compiled .gtpack archive
39    #[arg(long, value_name = "FILE", conflicts_with = "input")]
40    pub pack: Option<PathBuf>,
41
42    /// Path to a pack source directory containing pack.yaml
43    #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
44    pub input: Option<PathBuf>,
45
46    /// Force archive inspection (disables auto-detection)
47    #[arg(long)]
48    pub archive: bool,
49
50    /// Force source inspection (disables auto-detection)
51    #[arg(long)]
52    pub source: bool,
53
54    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
55    #[arg(long = "allow-oci-tags", default_value_t = false)]
56    pub allow_oci_tags: bool,
57
58    /// Output format
59    #[arg(long, value_enum, default_value = "human")]
60    pub format: InspectFormat,
61
62    /// Enable validation (default)
63    #[arg(long, default_value_t = true)]
64    pub validate: bool,
65
66    /// Disable validation
67    #[arg(long = "no-validate", default_value_t = false)]
68    pub no_validate: bool,
69
70    /// Directory containing validator packs (.gtpack)
71    #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
72    pub validators_root: PathBuf,
73
74    /// Validator pack or component reference (path or oci://...)
75    #[arg(long, value_name = "REF")]
76    pub validator_pack: Vec<String>,
77
78    /// Allowed OCI prefixes for validator refs
79    #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
80    pub validator_allow: Vec<String>,
81
82    /// Validator cache directory
83    #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
84    pub validator_cache_dir: PathBuf,
85
86    /// Validator loading policy
87    #[arg(long, value_enum, default_value = "optional")]
88    pub validator_policy: ValidatorPolicy,
89}
90
91pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
92    let mode = resolve_mode(&args)?;
93    let format = resolve_format(&args, json);
94    let validate_enabled = if args.no_validate {
95        false
96    } else {
97        args.validate
98    };
99
100    let load = match mode {
101        InspectMode::Archive(path) => inspect_pack_file(&path)?,
102        InspectMode::Source(path) => {
103            inspect_source_dir(&path, runtime, args.allow_oci_tags).await?
104        }
105    };
106    let validation = if validate_enabled {
107        Some(run_pack_validation(&load, &args, runtime).await?)
108    } else {
109        None
110    };
111
112    match format {
113        InspectFormat::Json => {
114            let mut payload = serde_json::json!({
115                "manifest": load.manifest,
116                "report": {
117                    "signature_ok": load.report.signature_ok,
118                    "sbom_ok": load.report.sbom_ok,
119                    "warnings": load.report.warnings,
120                },
121                "sbom": load.sbom,
122            });
123            if let Some(report) = validation.as_ref() {
124                payload["validation"] = serde_json::to_value(report)?;
125            }
126            println!("{}", serde_json::to_string_pretty(&payload)?);
127        }
128        InspectFormat::Human => {
129            print_human(&load, validation.as_ref());
130        }
131    }
132
133    if validate_enabled
134        && validation
135            .as_ref()
136            .map(|report| report.has_errors)
137            .unwrap_or(false)
138    {
139        bail!("pack validation failed");
140    }
141
142    Ok(())
143}
144
145fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
146    let load = open_pack(path, SigningPolicy::DevOk)
147        .map_err(|err| anyhow!(err.message))
148        .with_context(|| format!("failed to open pack {}", path.display()))?;
149    Ok(load)
150}
151
152enum InspectMode {
153    Archive(PathBuf),
154    Source(PathBuf),
155}
156
157fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
158    if args.archive && args.source {
159        bail!("--archive and --source are mutually exclusive");
160    }
161    if args.pack.is_some() && args.input.is_some() {
162        bail!("exactly one of --pack or --in may be supplied");
163    }
164
165    if let Some(path) = &args.pack {
166        return Ok(InspectMode::Archive(path.clone()));
167    }
168    if let Some(path) = &args.input {
169        return Ok(InspectMode::Source(path.clone()));
170    }
171    if let Some(path) = &args.path {
172        let meta =
173            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
174        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
175            return Ok(InspectMode::Archive(path.clone()));
176        }
177        if args.source || meta.is_dir() {
178            return Ok(InspectMode::Source(path.clone()));
179        }
180        if meta.is_file() {
181            return Ok(InspectMode::Archive(path.clone()));
182        }
183    }
184    Ok(InspectMode::Source(
185        std::env::current_dir().context("determine current directory")?,
186    ))
187}
188
189async fn inspect_source_dir(
190    dir: &Path,
191    runtime: &RuntimeContext,
192    allow_oci_tags: bool,
193) -> Result<PackLoad> {
194    let pack_dir = dir
195        .canonicalize()
196        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
197
198    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
199    let manifest_out = temp.path().join("manifest.cbor");
200    let gtpack_out = temp.path().join("pack.gtpack");
201
202    let opts = build::BuildOptions {
203        pack_dir,
204        component_out: None,
205        manifest_out,
206        sbom_out: None,
207        gtpack_out: Some(gtpack_out.clone()),
208        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
209        bundle: build::BundleMode::Cache,
210        dry_run: false,
211        secrets_req: None,
212        default_secret_scope: None,
213        allow_oci_tags,
214        require_component_manifests: false,
215        no_extra_dirs: false,
216        runtime: runtime.clone(),
217        skip_update: false,
218    };
219
220    build::run(&opts).await?;
221
222    inspect_pack_file(&gtpack_out)
223}
224
225fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
226    let manifest = &load.manifest;
227    let report = &load.report;
228    println!(
229        "Pack: {} ({})",
230        manifest.meta.pack_id, manifest.meta.version
231    );
232    println!("Name: {}", manifest.meta.name);
233    println!("Flows: {}", manifest.flows.len());
234    if manifest.flows.is_empty() {
235        println!("Flows list: none");
236    } else {
237        println!("Flows list:");
238        for flow in &manifest.flows {
239            println!(
240                "  - {} (entry: {}, kind: {})",
241                flow.id, flow.entry, flow.kind
242            );
243        }
244    }
245    println!("Components: {}", manifest.components.len());
246    if manifest.components.is_empty() {
247        println!("Components list: none");
248    } else {
249        println!("Components list:");
250        for component in &manifest.components {
251            println!("  - {} ({})", component.name, component.version);
252        }
253    }
254    if let Some(gmanifest) = load.gpack_manifest.as_ref()
255        && let Some(value) = gmanifest
256            .extensions
257            .as_ref()
258            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
259            .and_then(|ext| ext.inline.as_ref())
260            .and_then(|inline| match inline {
261                greentic_types::ExtensionInline::Other(v) => Some(v),
262                _ => None,
263            })
264        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
265    {
266        let mut inline = 0usize;
267        let mut remote = 0usize;
268        let mut oci = 0usize;
269        let mut repo = 0usize;
270        let mut store = 0usize;
271        let mut file = 0usize;
272        for entry in &cs.components {
273            match entry.artifact {
274                ArtifactLocationV1::Inline { .. } => inline += 1,
275                ArtifactLocationV1::Remote => remote += 1,
276            }
277            match entry.source {
278                ComponentSourceRef::Oci(_) => oci += 1,
279                ComponentSourceRef::Repo(_) => repo += 1,
280                ComponentSourceRef::Store(_) => store += 1,
281                ComponentSourceRef::File(_) => file += 1,
282            }
283        }
284        println!(
285            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
286            cs.components.len(),
287            oci,
288            repo,
289            store,
290            file,
291            inline,
292            remote
293        );
294        if cs.components.is_empty() {
295            println!("Component source entries: none");
296        } else {
297            println!("Component source entries:");
298            for entry in &cs.components {
299                println!(
300                    "  - {} source={} artifact={}",
301                    entry.name,
302                    format_component_source(&entry.source),
303                    format_component_artifact(&entry.artifact)
304                );
305            }
306        }
307    } else {
308        println!("Component sources: none");
309    }
310
311    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
312        let providers = providers_from_manifest(gmanifest);
313        if providers.is_empty() {
314            println!("Providers: none");
315        } else {
316            println!("Providers:");
317            for provider in providers {
318                println!(
319                    "  - {} ({}) {}",
320                    provider.provider_type,
321                    provider_kind(&provider),
322                    summarize_provider(&provider)
323                );
324            }
325        }
326    } else {
327        println!("Providers: none");
328    }
329
330    if !report.warnings.is_empty() {
331        println!("Warnings:");
332        for warning in &report.warnings {
333            println!("  - {}", warning);
334        }
335    }
336
337    if let Some(report) = validation {
338        print_validation(report);
339    }
340}
341
342#[derive(Clone, Debug, Serialize)]
343struct ValidationOutput {
344    #[serde(flatten)]
345    report: ValidationReport,
346    has_errors: bool,
347    sources: Vec<crate::validator::ValidatorSourceReport>,
348}
349
350fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
351    diagnostics
352        .iter()
353        .any(|diag| matches!(diag.severity, Severity::Error))
354}
355
356async fn run_pack_validation(
357    load: &PackLoad,
358    args: &InspectArgs,
359    runtime: &RuntimeContext,
360) -> Result<ValidationOutput> {
361    let ctx = ValidateCtx::from_pack_load(load);
362    let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
363        Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
364        Box::new(SbomConsistencyValidator::new(ctx.clone())),
365        Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
366        Box::new(ComponentReferencesExistValidator),
367    ];
368
369    let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
370        run_validators(manifest, &ctx, &validators)
371    } else {
372        ValidationReport {
373            pack_id: None,
374            pack_version: None,
375            diagnostics: vec![Diagnostic {
376                severity: Severity::Warn,
377                code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
378                message: "Pack manifest is not in the greentic-types format; skipping validation."
379                    .to_string(),
380                path: Some("manifest.cbor".to_string()),
381                hint: Some(
382                    "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
383                ),
384                data: Value::Null,
385            }],
386        }
387    };
388
389    let config = ValidatorConfig {
390        validators_root: args.validators_root.clone(),
391        validator_packs: args.validator_pack.clone(),
392        validator_allow: args.validator_allow.clone(),
393        validator_cache_dir: args.validator_cache_dir.clone(),
394        policy: args.validator_policy,
395    };
396
397    let wasm_result = run_wasm_validators(load, &config, runtime).await?;
398    report.diagnostics.extend(wasm_result.diagnostics);
399
400    let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
401
402    Ok(ValidationOutput {
403        report,
404        has_errors,
405        sources: wasm_result.sources,
406    })
407}
408
409fn print_validation(report: &ValidationOutput) {
410    let (info, warn, error) = validation_counts(&report.report);
411    println!("Validation:");
412    println!("  Info: {info} Warn: {warn} Error: {error}");
413    if report.report.diagnostics.is_empty() {
414        println!("  - none");
415        return;
416    }
417    for diag in &report.report.diagnostics {
418        let sev = match diag.severity {
419            Severity::Info => "INFO",
420            Severity::Warn => "WARN",
421            Severity::Error => "ERROR",
422        };
423        if let Some(path) = diag.path.as_deref() {
424            println!("  - [{sev}] {} {} - {}", diag.code, path, diag.message);
425        } else {
426            println!("  - [{sev}] {} - {}", diag.code, diag.message);
427        }
428        if let Some(hint) = diag.hint.as_deref() {
429            println!("    hint: {hint}");
430        }
431    }
432}
433
434fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
435    let mut info = 0;
436    let mut warn = 0;
437    let mut error = 0;
438    for diag in &report.diagnostics {
439        match diag.severity {
440            Severity::Info => info += 1,
441            Severity::Warn => warn += 1,
442            Severity::Error => error += 1,
443        }
444    }
445    (info, warn, error)
446}
447
448#[derive(Debug, Clone, Copy, clap::ValueEnum)]
449pub enum InspectFormat {
450    Human,
451    Json,
452}
453
454fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
455    if json {
456        InspectFormat::Json
457    } else {
458        args.format
459    }
460}
461
462fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
463    let mut providers = manifest
464        .provider_extension_inline()
465        .map(|inline| inline.providers.clone())
466        .unwrap_or_default();
467    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
468    providers
469}
470
471fn provider_kind(provider: &ProviderDecl) -> String {
472    provider
473        .runtime
474        .world
475        .split('@')
476        .next()
477        .unwrap_or_default()
478        .to_string()
479}
480
481fn summarize_provider(provider: &ProviderDecl) -> String {
482    let caps = provider.capabilities.len();
483    let ops = provider.ops.len();
484    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
485    parts.push(format!("config:{}", provider.config_schema_ref));
486    if let Some(docs) = provider.docs_ref.as_deref() {
487        parts.push(format!("docs:{docs}"));
488    }
489    parts.join(" ")
490}
491
492fn format_component_source(source: &ComponentSourceRef) -> String {
493    match source {
494        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
495        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
496        ComponentSourceRef::Store(value) => format_source_ref("store", value),
497        ComponentSourceRef::File(value) => format_source_ref("file", value),
498    }
499}
500
501fn format_source_ref(scheme: &str, value: &str) -> String {
502    if value.contains("://") {
503        value.to_string()
504    } else {
505        format!("{scheme}://{value}")
506    }
507}
508
509fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
510    match artifact {
511        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
512        ArtifactLocationV1::Remote => "remote".to_string(),
513    }
514}