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