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 #[arg(value_name = "PATH")]
29 pub path: Option<PathBuf>,
30
31 #[arg(long, value_name = "FILE", conflicts_with = "input")]
33 pub pack: Option<PathBuf>,
34
35 #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
37 pub input: Option<PathBuf>,
38
39 #[arg(long)]
41 pub archive: bool,
42
43 #[arg(long)]
45 pub source: bool,
46
47 #[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"), 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(>pack_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}