packc/cli/
inspect.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    collections::BTreeSet,
5    fs,
6    path::{Path, PathBuf},
7};
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::Parser;
11use greentic_pack::{PackLoad, SigningPolicy, open_pack};
12use greentic_types::ComponentId;
13use greentic_types::component_source::ComponentSourceRef;
14use greentic_types::pack::extensions::component_sources::{
15    ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
16};
17use greentic_types::pack_manifest::PackManifest;
18use greentic_types::provider::ProviderDecl;
19use serde_json::Value;
20use tempfile::TempDir;
21
22use crate::build;
23use crate::runtime::RuntimeContext;
24
25#[derive(Debug, Parser)]
26pub struct InspectArgs {
27    /// Path to a pack (.gtpack or source dir). Defaults to current directory.
28    #[arg(value_name = "PATH")]
29    pub path: Option<PathBuf>,
30
31    /// Path to a compiled .gtpack archive
32    #[arg(long, value_name = "FILE", conflicts_with = "input")]
33    pub pack: Option<PathBuf>,
34
35    /// Path to a pack source directory containing pack.yaml
36    #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
37    pub input: Option<PathBuf>,
38
39    /// Force archive inspection (disables auto-detection)
40    #[arg(long)]
41    pub archive: bool,
42
43    /// Force source inspection (disables auto-detection)
44    #[arg(long)]
45    pub source: bool,
46
47    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
48    #[arg(long = "allow-oci-tags", default_value_t = false)]
49    pub allow_oci_tags: bool,
50}
51
52pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
53    let mode = resolve_mode(&args)?;
54
55    let load = match mode {
56        InspectMode::Archive(path) => inspect_pack_file(&path)?,
57        InspectMode::Source(path) => {
58            inspect_source_dir(&path, runtime, args.allow_oci_tags).await?
59        }
60    };
61    validate_pack_files(&load)?;
62    validate_component_references(&load)?;
63
64    if json {
65        let payload = serde_json::json!({
66            "manifest": load.manifest,
67            "report": {
68                "signature_ok": load.report.signature_ok,
69                "sbom_ok": load.report.sbom_ok,
70                "warnings": load.report.warnings,
71            },
72            "sbom": load.sbom,
73        });
74        println!("{}", serde_json::to_string_pretty(&payload)?);
75        return Ok(());
76    }
77
78    print_human(&load);
79    Ok(())
80}
81
82fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
83    let load = open_pack(path, SigningPolicy::DevOk)
84        .map_err(|err| anyhow!(err.message))
85        .with_context(|| format!("failed to open pack {}", path.display()))?;
86    Ok(load)
87}
88
89enum InspectMode {
90    Archive(PathBuf),
91    Source(PathBuf),
92}
93
94fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
95    if args.archive && args.source {
96        bail!("--archive and --source are mutually exclusive");
97    }
98    if args.pack.is_some() && args.input.is_some() {
99        bail!("exactly one of --pack or --in may be supplied");
100    }
101
102    if let Some(path) = &args.pack {
103        return Ok(InspectMode::Archive(path.clone()));
104    }
105    if let Some(path) = &args.input {
106        return Ok(InspectMode::Source(path.clone()));
107    }
108    if let Some(path) = &args.path {
109        let meta =
110            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
111        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
112            return Ok(InspectMode::Archive(path.clone()));
113        }
114        if args.source || meta.is_dir() {
115            return Ok(InspectMode::Source(path.clone()));
116        }
117        if meta.is_file() {
118            return Ok(InspectMode::Archive(path.clone()));
119        }
120    }
121    Ok(InspectMode::Source(
122        std::env::current_dir().context("determine current directory")?,
123    ))
124}
125
126async fn inspect_source_dir(
127    dir: &Path,
128    runtime: &RuntimeContext,
129    allow_oci_tags: bool,
130) -> Result<PackLoad> {
131    let pack_dir = dir
132        .canonicalize()
133        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
134
135    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
136    let manifest_out = temp.path().join("manifest.cbor");
137    let gtpack_out = temp.path().join("pack.gtpack");
138
139    let opts = build::BuildOptions {
140        pack_dir,
141        component_out: None,
142        manifest_out,
143        sbom_out: None,
144        gtpack_out: Some(gtpack_out.clone()),
145        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
146        bundle: build::BundleMode::Cache,
147        dry_run: false,
148        secrets_req: None,
149        default_secret_scope: None,
150        allow_oci_tags,
151        runtime: runtime.clone(),
152        skip_update: false,
153    };
154
155    build::run(&opts).await?;
156
157    inspect_pack_file(&gtpack_out)
158}
159
160fn print_human(load: &PackLoad) {
161    let manifest = &load.manifest;
162    let report = &load.report;
163    println!(
164        "Pack: {} ({})",
165        manifest.meta.pack_id, manifest.meta.version
166    );
167    println!("Name: {}", manifest.meta.name);
168    println!("Flows: {}", manifest.flows.len());
169    if manifest.flows.is_empty() {
170        println!("Flows list: none");
171    } else {
172        println!("Flows list:");
173        for flow in &manifest.flows {
174            println!(
175                "  - {} (entry: {}, kind: {})",
176                flow.id, flow.entry, flow.kind
177            );
178        }
179    }
180    println!("Components: {}", manifest.components.len());
181    if manifest.components.is_empty() {
182        println!("Components list: none");
183    } else {
184        println!("Components list:");
185        for component in &manifest.components {
186            println!("  - {} ({})", component.name, component.version);
187        }
188    }
189    if let Some(gmanifest) = load.gpack_manifest.as_ref()
190        && let Some(value) = gmanifest
191            .extensions
192            .as_ref()
193            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
194            .and_then(|ext| ext.inline.as_ref())
195            .and_then(|inline| match inline {
196                greentic_types::ExtensionInline::Other(v) => Some(v),
197                _ => None,
198            })
199        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
200    {
201        let mut inline = 0usize;
202        let mut remote = 0usize;
203        let mut oci = 0usize;
204        let mut repo = 0usize;
205        let mut store = 0usize;
206        let mut file = 0usize;
207        for entry in &cs.components {
208            match entry.artifact {
209                ArtifactLocationV1::Inline { .. } => inline += 1,
210                ArtifactLocationV1::Remote => remote += 1,
211            }
212            match entry.source {
213                ComponentSourceRef::Oci(_) => oci += 1,
214                ComponentSourceRef::Repo(_) => repo += 1,
215                ComponentSourceRef::Store(_) => store += 1,
216                ComponentSourceRef::File(_) => file += 1,
217            }
218        }
219        println!(
220            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
221            cs.components.len(),
222            oci,
223            repo,
224            store,
225            file,
226            inline,
227            remote
228        );
229        if cs.components.is_empty() {
230            println!("Component source entries: none");
231        } else {
232            println!("Component source entries:");
233            for entry in &cs.components {
234                println!(
235                    "  - {} source={} artifact={}",
236                    entry.name,
237                    format_component_source(&entry.source),
238                    format_component_artifact(&entry.artifact)
239                );
240            }
241        }
242    } else {
243        println!("Component sources: none");
244    }
245
246    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
247        let providers = providers_from_manifest(gmanifest);
248        if providers.is_empty() {
249            println!("Providers: none");
250        } else {
251            println!("Providers:");
252            for provider in providers {
253                println!(
254                    "  - {} ({}) {}",
255                    provider.provider_type,
256                    provider_kind(&provider),
257                    summarize_provider(&provider)
258                );
259            }
260        }
261    } else {
262        println!("Providers: none");
263    }
264
265    if !report.warnings.is_empty() {
266        println!("Warnings:");
267        for warning in &report.warnings {
268            println!("  - {}", warning);
269        }
270    }
271}
272
273fn validate_pack_files(load: &PackLoad) -> Result<()> {
274    let mut missing = BTreeSet::new();
275
276    for flow in &load.manifest.flows {
277        if !load.files.contains_key(&flow.file_yaml) {
278            missing.insert(flow.file_yaml.clone());
279        }
280        if !load.files.contains_key(&flow.file_json) {
281            missing.insert(flow.file_json.clone());
282        }
283    }
284
285    for component in &load.manifest.components {
286        if !load.files.contains_key(&component.file_wasm) {
287            missing.insert(component.file_wasm.clone());
288        }
289    }
290
291    if missing.is_empty() {
292        Ok(())
293    } else {
294        let items = missing.into_iter().collect::<Vec<_>>().join(", ");
295        bail!("pack is missing required files: {}", items);
296    }
297}
298
299fn validate_component_references(load: &PackLoad) -> Result<()> {
300    let mut known: BTreeSet<ComponentId> = BTreeSet::new();
301    for component in &load.manifest.components {
302        let id = ComponentId::new(component.name.clone()).with_context(|| {
303            format!(
304                "pack manifest contains invalid component id {}",
305                component.name
306            )
307        })?;
308        known.insert(id);
309    }
310
311    let mut source_ids: BTreeSet<ComponentId> = BTreeSet::new();
312    if let Some(gmanifest) = load.gpack_manifest.as_ref()
313        && let Some(value) = gmanifest
314            .extensions
315            .as_ref()
316            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
317            .and_then(|ext| ext.inline.as_ref())
318            .and_then(|inline| match inline {
319                greentic_types::ExtensionInline::Other(v) => Some(v),
320                _ => None,
321            })
322        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
323    {
324        for entry in cs.components {
325            if let Some(id) = entry.component_id {
326                source_ids.insert(id);
327            }
328        }
329    }
330
331    let mut missing = BTreeSet::new();
332    for flow in &load.manifest.flows {
333        let Some(bytes) = load.files.get(&flow.file_json) else {
334            continue;
335        };
336        let flow_json: Value = serde_json::from_slice(bytes)
337            .with_context(|| format!("failed to decode flow json {}", flow.file_json))?;
338        let Some(nodes) = flow_json.get("nodes").and_then(|val| val.as_object()) else {
339            continue;
340        };
341        for node in nodes.values() {
342            let Some(component_id) = node
343                .get("component")
344                .and_then(|val| val.get("id"))
345                .and_then(|val| val.as_str())
346            else {
347                continue;
348            };
349            let parsed = ComponentId::new(component_id).with_context(|| {
350                format!(
351                    "flow {} contains invalid component id {}",
352                    flow.id, component_id
353                )
354            })?;
355            if !known.contains(&parsed) && !source_ids.contains(&parsed) {
356                missing.insert(parsed.to_string());
357            }
358        }
359    }
360
361    if missing.is_empty() {
362        Ok(())
363    } else {
364        let items = missing.into_iter().collect::<Vec<_>>().join(", ");
365        bail!("pack references components missing from manifest/component sources: {items}");
366    }
367}
368
369fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
370    let mut providers = manifest
371        .provider_extension_inline()
372        .map(|inline| inline.providers.clone())
373        .unwrap_or_default();
374    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
375    providers
376}
377
378fn provider_kind(provider: &ProviderDecl) -> String {
379    provider
380        .runtime
381        .world
382        .split('@')
383        .next()
384        .unwrap_or_default()
385        .to_string()
386}
387
388fn summarize_provider(provider: &ProviderDecl) -> String {
389    let caps = provider.capabilities.len();
390    let ops = provider.ops.len();
391    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
392    parts.push(format!("config:{}", provider.config_schema_ref));
393    if let Some(docs) = provider.docs_ref.as_deref() {
394        parts.push(format!("docs:{docs}"));
395    }
396    parts.join(" ")
397}
398
399fn format_component_source(source: &ComponentSourceRef) -> String {
400    match source {
401        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
402        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
403        ComponentSourceRef::Store(value) => format_source_ref("store", value),
404        ComponentSourceRef::File(value) => format_source_ref("file", value),
405    }
406}
407
408fn format_source_ref(scheme: &str, value: &str) -> String {
409    if value.contains("://") {
410        value.to_string()
411    } else {
412        format!("{scheme}://{value}")
413    }
414}
415
416fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
417    match artifact {
418        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
419        ArtifactLocationV1::Remote => "remote".to_string(),
420    }
421}