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 #[arg(value_name = "PATH")]
59 pub path: Option<PathBuf>,
60
61 #[arg(long, value_name = "FILE", conflicts_with = "input")]
63 pub pack: Option<PathBuf>,
64
65 #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
67 pub input: Option<PathBuf>,
68
69 #[arg(long)]
71 pub archive: bool,
72
73 #[arg(long)]
75 pub source: bool,
76
77 #[arg(long = "allow-oci-tags", default_value_t = false)]
79 pub allow_oci_tags: bool,
80
81 #[arg(long = "no-flow-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
83 pub flow_doctor: bool,
84
85 #[arg(long = "no-component-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
87 pub component_doctor: bool,
88
89 #[arg(long, value_enum, default_value = "human")]
91 pub format: InspectFormat,
92
93 #[arg(long, default_value_t = true)]
95 pub validate: bool,
96
97 #[arg(long = "no-validate", default_value_t = false)]
99 pub no_validate: bool,
100
101 #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
103 pub validators_root: PathBuf,
104
105 #[arg(long, value_name = "REF")]
107 pub validator_pack: Vec<String>,
108
109 #[arg(long, value_name = "COMPONENT=FILE")]
111 pub validator_wasm: Vec<String>,
112
113 #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
115 pub validator_allow: Vec<String>,
116
117 #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
119 pub validator_cache_dir: PathBuf,
120
121 #[arg(long, value_enum, default_value = "optional")]
123 pub validator_policy: ValidatorPolicy,
124
125 #[arg(long, default_value_t = false)]
127 pub online: bool,
128
129 #[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"), 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(>pack_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}