1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::Context;
7use serde::{Deserialize, Serialize};
8use serde_json::Value as JsonValue;
9use sha2::{Digest, Sha256};
10
11use crate::bundle::{self, BUNDLE_LOCK_FILE, BUNDLE_WORKSPACE_MARKER};
12use crate::{capabilities, config_envelope, discovery, platform_setup, setup_to_formspec};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum DoctorStage {
16 Setup,
17 Cache,
18 Locks,
19 Answers,
20 Runtime,
21 Routes,
22}
23
24#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
25#[serde(rename_all = "lowercase")]
26pub enum DiagnosticSeverity {
27 Info,
28 Warn,
29 Error,
30}
31
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct Diagnostic {
34 pub check_id: String,
35 pub severity: DiagnosticSeverity,
36 pub component: String,
37 pub message: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub evidence: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub expected: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub actual: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub fix_hint: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub related_file: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub related_pack: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub related_component: Option<String>,
52}
53
54#[derive(Clone, Debug, Serialize, Deserialize)]
55pub struct DoctorReport {
56 pub bundle: String,
57 pub status: String,
58 pub error_count: usize,
59 pub warn_count: usize,
60 pub info_count: usize,
61 pub diagnostics: Vec<Diagnostic>,
62}
63
64pub fn run_doctor(bundle: &Path, stage: Option<DoctorStage>) -> DoctorReport {
65 let mut ctx = DoctorContext::new(bundle.to_path_buf(), stage);
66 ctx.run();
67 ctx.into_report()
68}
69
70struct DoctorContext {
71 bundle: PathBuf,
72 stage: Option<DoctorStage>,
73 diagnostics: Vec<Diagnostic>,
74}
75
76impl DoctorContext {
77 fn new(bundle: PathBuf, stage: Option<DoctorStage>) -> Self {
78 Self {
79 bundle,
80 stage,
81 diagnostics: Vec::new(),
82 }
83 }
84
85 fn run(&mut self) {
86 self.check_bundle_root();
87 if !self.bundle.is_dir() || !bundle::is_bundle_root(&self.bundle) {
88 return;
89 }
90
91 if self.includes(DoctorStage::Setup) {
92 self.check_workspace_manifest();
93 self.check_discovery_and_packs();
94 self.check_provider_registry();
95 }
96 if self.includes(DoctorStage::Locks) {
97 self.check_bundle_lock();
98 }
99 if self.includes(DoctorStage::Answers) {
100 self.check_setup_outputs();
101 }
102 if self.includes(DoctorStage::Routes) {
103 self.check_route_artifacts();
104 self.check_resolved_manifests();
105 }
106 if self.includes(DoctorStage::Runtime) {
107 self.check_runtime_artifacts();
108 }
109 if self.includes(DoctorStage::Cache) {
110 self.push(
111 "setup.cache.model",
112 DiagnosticSeverity::Info,
113 "cache",
114 "bundle lock records local bundle references and digests, but not enough OCI cache provenance to validate remote cache freshness",
115 )
116 .fix_hint("extend bundle.lock.json with source_ref, resolved version, OCI digest, and cache path to enable cache doctor checks")
117 .finish();
118 }
119 }
120
121 fn into_report(self) -> DoctorReport {
122 let error_count = self
123 .diagnostics
124 .iter()
125 .filter(|d| d.severity == DiagnosticSeverity::Error)
126 .count();
127 let warn_count = self
128 .diagnostics
129 .iter()
130 .filter(|d| d.severity == DiagnosticSeverity::Warn)
131 .count();
132 let info_count = self
133 .diagnostics
134 .iter()
135 .filter(|d| d.severity == DiagnosticSeverity::Info)
136 .count();
137 let status = if error_count > 0 {
138 "error"
139 } else if warn_count > 0 {
140 "warn"
141 } else {
142 "ok"
143 }
144 .to_string();
145 DoctorReport {
146 bundle: self.bundle.display().to_string(),
147 status,
148 error_count,
149 warn_count,
150 info_count,
151 diagnostics: self.diagnostics,
152 }
153 }
154
155 fn includes(&self, stage: DoctorStage) -> bool {
156 self.stage.is_none_or(|value| value == stage)
157 }
158
159 fn check_bundle_root(&mut self) {
160 if !self.bundle.exists() {
161 let bundle_path = self.bundle.display().to_string();
162 self.push(
163 "setup.bundle.exists",
164 DiagnosticSeverity::Error,
165 "setup",
166 "bundle path does not exist",
167 )
168 .expected("existing bundle directory")
169 .actual(bundle_path.clone())
170 .fix_hint(
171 "check the bundle path or extract the .gtbundle archive before running doctor",
172 )
173 .file(bundle_path)
174 .finish();
175 return;
176 }
177 if !self.bundle.is_dir() {
178 let bundle_path = self.bundle.display().to_string();
179 self.push(
180 "setup.bundle.directory",
181 DiagnosticSeverity::Error,
182 "setup",
183 "doctor currently validates extracted bundle directories",
184 )
185 .expected("directory containing bundle.yaml or greentic.demo.yaml")
186 .actual(bundle_path.clone())
187 .fix_hint("run setup on the .gtbundle first or extract it to a directory")
188 .file(bundle_path)
189 .finish();
190 return;
191 }
192 if !bundle::is_bundle_root(&self.bundle) {
193 let bundle_path = self.bundle.display().to_string();
194 self.push(
195 "setup.bundle.marker",
196 DiagnosticSeverity::Error,
197 "setup",
198 "bundle root marker is missing",
199 )
200 .expected(format!(
201 "{} or {}",
202 BUNDLE_WORKSPACE_MARKER,
203 bundle::LEGACY_BUNDLE_MARKER
204 ))
205 .fix_hint("run greentic-setup bundle init or point doctor at the bundle root")
206 .file(bundle_path)
207 .finish();
208 return;
209 }
210 let bundle_path = self.bundle.display().to_string();
211 self.push(
212 "setup.bundle.marker",
213 DiagnosticSeverity::Info,
214 "setup",
215 "bundle root marker found",
216 )
217 .file(bundle_path)
218 .finish();
219 }
220
221 fn check_workspace_manifest(&mut self) {
222 let path = self.bundle.join(BUNDLE_WORKSPACE_MARKER);
223 if !path.exists() {
224 self.push(
225 "setup.bundle_manifest.present",
226 DiagnosticSeverity::Warn,
227 "setup",
228 "bundle.yaml is missing; legacy bundles are accepted but cannot be fully lock-checked",
229 )
230 .expected(BUNDLE_WORKSPACE_MARKER)
231 .fix_hint("run greentic-setup bundle build or setup to materialize normalized bundle metadata")
232 .file(path.display().to_string())
233 .finish();
234 return;
235 }
236 let Ok(raw) = std::fs::read_to_string(&path) else {
237 self.push(
238 "setup.bundle_manifest.read",
239 DiagnosticSeverity::Error,
240 "setup",
241 "failed to read bundle.yaml",
242 )
243 .file(path.display().to_string())
244 .finish();
245 return;
246 };
247 let parsed = serde_yaml_bw::from_str::<serde_yaml_bw::Value>(&raw);
248 let Ok(doc) = parsed else {
249 self.push(
250 "setup.bundle_manifest.parse",
251 DiagnosticSeverity::Error,
252 "setup",
253 "failed to parse bundle.yaml",
254 )
255 .file(path.display().to_string())
256 .fix_hint("fix YAML syntax before rerunning setup")
257 .finish();
258 return;
259 };
260 let Some(map) = doc.as_mapping() else {
261 self.push(
262 "setup.bundle_manifest.schema",
263 DiagnosticSeverity::Error,
264 "setup",
265 "bundle.yaml must be a YAML object",
266 )
267 .file(path.display().to_string())
268 .finish();
269 return;
270 };
271 if yaml_get(map, "schema_version").is_none() {
272 self.push(
273 "setup.bundle_manifest.schema_version",
274 DiagnosticSeverity::Warn,
275 "setup",
276 "bundle.yaml does not declare schema_version",
277 )
278 .expected("schema_version: 1")
279 .file(path.display().to_string())
280 .finish();
281 }
282 for key in ["app_packs", "extension_providers"] {
283 for reference in yaml_string_list(map, key) {
284 self.check_bundle_reference_path(&reference, key, &path);
285 }
286 }
287 }
288
289 fn check_bundle_reference_path(&mut self, reference: &str, key: &str, manifest_path: &Path) {
290 if reference.contains('\\')
291 || reference.contains("..")
292 || !is_remote_reference(reference) && Path::new(reference).is_absolute()
293 {
294 self.push(
295 "setup.bundle_manifest.reference_path",
296 DiagnosticSeverity::Error,
297 "setup",
298 "bundle reference is not a deterministic relative path",
299 )
300 .expected("relative path without backslashes or parent traversal")
301 .actual(reference.to_string())
302 .evidence(key.to_string())
303 .fix_hint("rewrite bundle.yaml references relative to the bundle root")
304 .file(manifest_path.display().to_string())
305 .finish();
306 }
307 if reference.contains(":latest")
308 || reference.ends_with("@latest")
309 || reference.contains("/latest/")
310 {
311 self.push(
312 "setup.bundle_manifest.latest_ref",
313 DiagnosticSeverity::Warn,
314 "lock",
315 "bundle reference appears to use a moving latest tag",
316 )
317 .expected("exact version or digest-pinned reference")
318 .actual(reference.to_string())
319 .file(manifest_path.display().to_string())
320 .finish();
321 }
322 if is_remote_reference(reference) {
323 if materialized_pack_candidates(&self.bundle, reference)
324 .iter()
325 .any(|path| path.exists())
326 {
327 return;
328 }
329 self.push(
330 "setup.bundle_manifest.remote_materialized",
331 DiagnosticSeverity::Warn,
332 "setup",
333 "bundle manifest uses a remote pack reference but no matching local pack artifact was found",
334 )
335 .expected("resolved .gtpack copied into packs/ or providers/<domain>/")
336 .actual(reference.to_string())
337 .fix_hint("rerun setup or resolve the remote pack before starting the bundle")
338 .file(manifest_path.display().to_string())
339 .finish();
340 return;
341 }
342 let path = self.bundle.join(reference);
343 if !path.exists() {
344 self.push(
345 "setup.bundle_manifest.reference_exists",
346 DiagnosticSeverity::Error,
347 "setup",
348 "bundle manifest references a missing pack",
349 )
350 .expected("referenced .gtpack exists")
351 .actual(reference.to_string())
352 .fix_hint("rerun setup or update bundle.yaml to match the files present in the bundle")
353 .file(path.display().to_string())
354 .finish();
355 }
356 }
357
358 fn check_discovery_and_packs(&mut self) {
359 let discovered = match discovery::discover(&self.bundle) {
360 Ok(value) => value,
361 Err(err) => {
362 self.push(
363 "setup.pack_discovery",
364 DiagnosticSeverity::Error,
365 "setup",
366 "pack discovery failed",
367 )
368 .evidence(err.to_string())
369 .fix_hint("inspect provider and packs directories for unreadable or corrupt .gtpack files")
370 .finish();
371 return;
372 }
373 };
374
375 let targets = discovered.setup_targets();
376 if targets.is_empty() {
377 self.push(
378 "setup.pack_discovery.empty",
379 DiagnosticSeverity::Warn,
380 "setup",
381 "no setup-capable packs were discovered",
382 )
383 .fix_hint("add app packs under packs/ or provider packs under providers/<domain>/")
384 .finish();
385 }
386
387 for provider in targets {
388 if provider.id_source == discovery::ProviderIdSource::Filename {
389 self.push(
390 "setup.pack_manifest.pack_id",
391 DiagnosticSeverity::Warn,
392 "setup",
393 "pack did not expose a readable pack_id; filename fallback was used",
394 )
395 .file(provider.pack_path.display().to_string())
396 .pack(provider.provider_id.clone())
397 .fix_hint("rebuild the pack with pack_id in manifest.cbor or pack.manifest.json")
398 .finish();
399 }
400 if discovery::read_pack_meta(&provider.pack_path).is_err() {
401 self.push(
402 "setup.pack_manifest.read",
403 DiagnosticSeverity::Error,
404 "setup",
405 "pack manifest could not be read",
406 )
407 .file(provider.pack_path.display().to_string())
408 .pack(provider.provider_id.clone())
409 .fix_hint("rebuild or replace the .gtpack")
410 .finish();
411 }
412 if provider.kind == discovery::DetectedPackKind::Provider
413 && !capabilities::has_capabilities_extension(&provider.pack_path)
414 {
415 self.push(
416 "setup.pack_capabilities.extension",
417 DiagnosticSeverity::Warn,
418 "setup",
419 "provider pack is missing greentic.ext.capabilities.v1",
420 )
421 .file(provider.pack_path.display().to_string())
422 .pack(provider.provider_id.clone())
423 .fix_hint("replace this provider pack with a newer build that includes the capabilities extension")
424 .finish();
425 }
426 if setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id)
427 .is_some()
428 {
429 self.push(
430 "setup.schema.available",
431 DiagnosticSeverity::Info,
432 "answers",
433 "setup schema is available for pack",
434 )
435 .file(provider.pack_path.display().to_string())
436 .pack(provider.provider_id.clone())
437 .finish();
438 }
439 }
440 }
441
442 fn check_bundle_lock(&mut self) {
443 let lock_path = self.bundle.join(BUNDLE_LOCK_FILE);
444 if !lock_path.exists() {
445 self.push(
446 "setup.lock.present",
447 DiagnosticSeverity::Warn,
448 "lock",
449 "bundle.lock.json is missing",
450 )
451 .expected(BUNDLE_LOCK_FILE)
452 .fix_hint("rerun greentic-setup so bundle metadata and lock state are synchronized")
453 .file(lock_path.display().to_string())
454 .finish();
455 return;
456 }
457 let lock = match read_json(&lock_path) {
458 Ok(value) => value,
459 Err(err) => {
460 self.push(
461 "setup.lock.parse",
462 DiagnosticSeverity::Error,
463 "lock",
464 "bundle.lock.json is not valid JSON",
465 )
466 .evidence(err.to_string())
467 .file(lock_path.display().to_string())
468 .finish();
469 return;
470 }
471 };
472
473 if lock.get("build_format_version").and_then(JsonValue::as_str) != Some("bundle-lock-v1") {
474 self.push(
475 "setup.lock.format_version",
476 DiagnosticSeverity::Warn,
477 "lock",
478 "bundle lock format is missing or unexpected",
479 )
480 .expected("bundle-lock-v1")
481 .actual(
482 lock.get("build_format_version")
483 .map(JsonValue::to_string)
484 .unwrap_or_else(|| "null".to_string()),
485 )
486 .file(lock_path.display().to_string())
487 .finish();
488 }
489
490 let refs = workspace_refs(&self.bundle).unwrap_or_default();
491 let lock_refs = lock_reference_map(&lock);
492 for reference in &refs {
493 match lock_refs.get(reference) {
494 Some(Some(expected_digest)) => {
495 let path = self.bundle.join(reference);
496 if path.exists() {
497 match sha256_file(&path) {
498 Ok(actual_digest) if &actual_digest == expected_digest => {}
499 Ok(actual_digest) => self
500 .push(
501 "setup.lock.digest_match",
502 DiagnosticSeverity::Error,
503 "lock",
504 "pack digest does not match bundle.lock.json",
505 )
506 .expected(expected_digest.clone())
507 .actual(actual_digest)
508 .file(path.display().to_string())
509 .pack(reference.clone())
510 .fix_hint("replace the pack with the locked artifact or regenerate the lock intentionally")
511 .finish(),
512 Err(err) => self
513 .push(
514 "setup.lock.digest_read",
515 DiagnosticSeverity::Error,
516 "lock",
517 "failed to compute pack digest",
518 )
519 .evidence(err.to_string())
520 .file(path.display().to_string())
521 .pack(reference.clone())
522 .finish(),
523 }
524 }
525 }
526 Some(None) => {
527 if is_stable_reference(reference)
528 && materialized_pack_candidates(&self.bundle, reference)
529 .iter()
530 .any(|path| path.exists())
531 {
532 continue;
533 }
534 self.push(
535 "setup.lock.digest_present",
536 DiagnosticSeverity::Warn,
537 "lock",
538 "lock entry has no digest",
539 )
540 .file(lock_path.display().to_string())
541 .pack(reference.clone())
542 .fix_hint("rerun setup with a resolver that records content digests")
543 .finish();
544 }
545 None => self
546 .push(
547 "setup.lock.reference_present",
548 DiagnosticSeverity::Error,
549 "lock",
550 "bundle.yaml reference is missing from bundle.lock.json",
551 )
552 .expected(reference.clone())
553 .file(lock_path.display().to_string())
554 .fix_hint("rerun setup to synchronize bundle.yaml and bundle.lock.json")
555 .finish(),
556 }
557 }
558 for reference in lock_refs.keys() {
559 if !refs.contains(reference) {
560 self.push(
561 "setup.lock.stale_reference",
562 DiagnosticSeverity::Warn,
563 "lock",
564 "bundle.lock.json contains a reference not present in bundle.yaml",
565 )
566 .actual(reference.clone())
567 .file(lock_path.display().to_string())
568 .fix_hint("rerun setup or remove stale lock entries")
569 .finish();
570 }
571 }
572 }
573
574 fn check_setup_outputs(&mut self) {
575 let discovered = match discovery::discover(&self.bundle) {
576 Ok(value) => value,
577 Err(_) => return,
578 };
579 for provider in discovered.setup_targets() {
580 let config_path = self
581 .bundle
582 .join("state")
583 .join("config")
584 .join(&provider.provider_id)
585 .join("setup-answers.json");
586 let form_spec =
587 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id);
588 if !config_path.exists() {
589 if form_spec
590 .as_ref()
591 .is_some_and(|spec| spec.questions.iter().any(|q| q.required))
592 {
593 self.push(
594 "setup.answers.present",
595 DiagnosticSeverity::Error,
596 "answers",
597 "required setup answers have not been materialized",
598 )
599 .file(config_path.display().to_string())
600 .pack(provider.provider_id.clone())
601 .fix_hint("run greentic-setup with complete answers for this provider")
602 .finish();
603 }
604 continue;
605 }
606 let answers = match read_json(&config_path) {
607 Ok(value) => value,
608 Err(err) => {
609 self.push(
610 "setup.answers.parse",
611 DiagnosticSeverity::Error,
612 "answers",
613 "setup-answers.json is not valid JSON",
614 )
615 .evidence(err.to_string())
616 .file(config_path.display().to_string())
617 .pack(provider.provider_id.clone())
618 .finish();
619 continue;
620 }
621 };
622 if let Some(spec) = form_spec {
623 if let Some(answer_map) = answers.as_object() {
624 let tunnel_supplies_public_base_url =
625 tunnel_supplies_public_base_url(&self.bundle);
626 for question in spec.questions.iter().filter(|q| q.required) {
627 if question.id == "public_base_url" && tunnel_supplies_public_base_url {
628 continue;
629 }
630 let missing = answer_map
631 .get(&question.id)
632 .is_none_or(|value| value.is_null() || value.as_str() == Some(""))
633 && question
634 .default_value
635 .as_ref()
636 .is_none_or(|value| value.trim().is_empty());
637 if missing {
638 self.push(
639 "setup.answers.required",
640 DiagnosticSeverity::Error,
641 "answers",
642 "required setup answer is missing or empty",
643 )
644 .expected(question.id.clone())
645 .file(config_path.display().to_string())
646 .pack(provider.provider_id.clone())
647 .fix_hint("provide the missing answer and rerun greentic-setup")
648 .finish();
649 }
650 }
651 } else {
652 self.push(
653 "setup.answers.object",
654 DiagnosticSeverity::Error,
655 "answers",
656 "setup answers must be a JSON object",
657 )
658 .file(config_path.display().to_string())
659 .pack(provider.provider_id.clone())
660 .finish();
661 }
662 }
663
664 match config_envelope::read_provider_config_envelope(
665 &self.bundle.join(".providers"),
666 &provider.provider_id,
667 ) {
668 Ok(Some(_)) => {
669 let envelope_path = self
670 .bundle
671 .join(".providers")
672 .join(&provider.provider_id)
673 .join("config.envelope.cbor")
674 .display()
675 .to_string();
676 if let Err(err) = config_envelope::ensure_contract_compatible(
677 &self.bundle.join(".providers"),
678 &provider.provider_id,
679 "setup-input",
680 &provider.pack_path,
681 false,
682 ) {
683 self.push(
684 "setup.config_envelope.contract",
685 DiagnosticSeverity::Error,
686 "provider",
687 "provider config envelope no longer matches the current pack contract",
688 )
689 .evidence(err.to_string())
690 .file(envelope_path)
691 .pack(provider.provider_id.clone())
692 .fix_hint(
693 "rerun setup after intentionally accepting the pack version change",
694 )
695 .finish();
696 }
697 }
698 Ok(None) => {
699 let envelope_path = self
700 .bundle
701 .join(".providers")
702 .join(&provider.provider_id)
703 .join("config.envelope.cbor")
704 .display()
705 .to_string();
706 self.push(
707 "setup.config_envelope.present",
708 DiagnosticSeverity::Warn,
709 "provider",
710 "setup answers exist but provider config envelope is missing",
711 )
712 .file(envelope_path)
713 .pack(provider.provider_id.clone())
714 .fix_hint(
715 "rerun greentic-setup so runtime-readable provider config is materialized",
716 )
717 .finish();
718 }
719 Err(err) => self
720 .push(
721 "setup.config_envelope.parse",
722 DiagnosticSeverity::Error,
723 "provider",
724 "provider config envelope could not be read",
725 )
726 .evidence(err.to_string())
727 .pack(provider.provider_id.clone())
728 .finish(),
729 }
730 }
731 }
732
733 fn check_route_artifacts(&mut self) {
734 let path = platform_setup::static_routes_artifact_path(&self.bundle);
735 if path.exists() {
736 match platform_setup::load_static_routes_artifact(&self.bundle) {
737 Ok(_) => {}
738 Err(err) => self
739 .push(
740 "setup.routes.static_routes_parse",
741 DiagnosticSeverity::Error,
742 "routes",
743 "static routes artifact could not be parsed",
744 )
745 .evidence(err.to_string())
746 .file(path.display().to_string())
747 .fix_hint("rerun setup with valid platform_setup.static_routes answers")
748 .finish(),
749 }
750 } else {
751 self.push(
752 "setup.routes.static_routes_present",
753 DiagnosticSeverity::Warn,
754 "routes",
755 "static routes artifact is missing",
756 )
757 .file(path.display().to_string())
758 .fix_hint("rerun setup; setup should persist state/config/platform/static-routes.json")
759 .finish();
760 }
761
762 let tunnel_path = platform_setup::tunnel_artifact_path(&self.bundle);
763 if tunnel_path.exists()
764 && let Err(err) = platform_setup::load_tunnel_artifact(&self.bundle)
765 {
766 self.push(
767 "setup.routes.tunnel_parse",
768 DiagnosticSeverity::Error,
769 "routes",
770 "tunnel artifact could not be parsed",
771 )
772 .evidence(err.to_string())
773 .file(tunnel_path.display().to_string())
774 .finish();
775 }
776 }
777
778 fn check_resolved_manifests(&mut self) {
779 for (tenant, team, gmap) in discover_gmap_targets(&self.bundle) {
780 if is_forbidden_only_gmap(&gmap) {
781 continue;
782 }
783 let filename = bundle::resolved_manifest_filename(&tenant, team.as_deref());
784 let path = self.bundle.join("resolved").join(filename);
785 if !path.exists() {
786 self.push(
787 "setup.routes.resolved_manifest_present",
788 DiagnosticSeverity::Warn,
789 "routes",
790 "tenant/team gmap exists but matching resolved manifest is missing",
791 )
792 .expected(path.display().to_string())
793 .file(gmap.display().to_string())
794 .fix_hint("rerun setup to copy or regenerate resolved manifests")
795 .finish();
796 } else if std::fs::read_to_string(&path)
797 .is_ok_and(|raw| raw.trim() == "# Resolved manifest placeholder")
798 {
799 self.push(
800 "setup.routes.resolved_manifest_placeholder",
801 DiagnosticSeverity::Warn,
802 "routes",
803 "resolved manifest is still the setup placeholder",
804 )
805 .file(path.display().to_string())
806 .fix_hint("run the resolver pipeline before start if this bundle needs concrete resolved manifests")
807 .finish();
808 }
809 }
810 }
811
812 fn check_runtime_artifacts(&mut self) {
813 let runtime = self.bundle.join("state").join("runtime");
814 if !runtime.exists() {
815 self.push(
816 "setup.runtime.state_present",
817 DiagnosticSeverity::Info,
818 "runtime",
819 "runtime state directory has not been created yet",
820 )
821 .file(runtime.display().to_string())
822 .finish();
823 }
824 }
825
826 fn check_provider_registry(&mut self) {
827 let path = self.bundle.join("providers").join("providers.json");
828 if path.exists()
829 && let Err(err) = bundle::load_provider_registry(&self.bundle)
830 {
831 self.push(
832 "setup.provider_registry.parse",
833 DiagnosticSeverity::Error,
834 "provider",
835 "providers/providers.json could not be parsed",
836 )
837 .evidence(err.to_string())
838 .file(path.display().to_string())
839 .finish();
840 }
841 }
842
843 fn push(
844 &mut self,
845 check_id: impl Into<String>,
846 severity: DiagnosticSeverity,
847 component: impl Into<String>,
848 message: impl Into<String>,
849 ) -> DiagnosticBuilder<'_> {
850 DiagnosticBuilder {
851 ctx: self,
852 diagnostic: Diagnostic {
853 check_id: check_id.into(),
854 severity,
855 component: component.into(),
856 message: message.into(),
857 evidence: None,
858 expected: None,
859 actual: None,
860 fix_hint: None,
861 related_file: None,
862 related_pack: None,
863 related_component: None,
864 },
865 }
866 }
867}
868
869struct DiagnosticBuilder<'a> {
870 ctx: &'a mut DoctorContext,
871 diagnostic: Diagnostic,
872}
873
874impl DiagnosticBuilder<'_> {
875 fn evidence(mut self, value: impl Into<String>) -> Self {
876 self.diagnostic.evidence = Some(value.into());
877 self
878 }
879 fn expected(mut self, value: impl Into<String>) -> Self {
880 self.diagnostic.expected = Some(value.into());
881 self
882 }
883 fn actual(mut self, value: impl Into<String>) -> Self {
884 self.diagnostic.actual = Some(value.into());
885 self
886 }
887 fn fix_hint(mut self, value: impl Into<String>) -> Self {
888 self.diagnostic.fix_hint = Some(value.into());
889 self
890 }
891 fn file(mut self, value: impl Into<String>) -> Self {
892 self.diagnostic.related_file = Some(value.into());
893 self
894 }
895 fn pack(mut self, value: impl Into<String>) -> Self {
896 self.diagnostic.related_pack = Some(value.into());
897 self
898 }
899 fn finish(self) {
900 self.ctx.diagnostics.push(self.diagnostic);
901 }
902}
903
904fn read_json(path: &Path) -> anyhow::Result<JsonValue> {
905 let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
906 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
907}
908
909fn sha256_file(path: &Path) -> anyhow::Result<String> {
910 let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
911 let digest = Sha256::digest(bytes);
912 let encoded = digest
913 .iter()
914 .map(|byte| format!("{byte:02x}"))
915 .collect::<String>();
916 Ok(format!("sha256:{encoded}"))
917}
918
919fn workspace_refs(bundle: &Path) -> anyhow::Result<BTreeSet<String>> {
920 let path = bundle.join(BUNDLE_WORKSPACE_MARKER);
921 let raw = std::fs::read_to_string(&path)?;
922 let doc = serde_yaml_bw::from_str::<serde_yaml_bw::Value>(&raw)?;
923 let mut refs = BTreeSet::new();
924 if let Some(map) = doc.as_mapping() {
925 for key in ["app_packs", "extension_providers"] {
926 refs.extend(yaml_string_list(map, key));
927 }
928 }
929 Ok(refs)
930}
931
932fn lock_reference_map(lock: &JsonValue) -> BTreeMap<String, Option<String>> {
933 let mut refs = BTreeMap::new();
934 for key in ["app_packs", "extension_providers"] {
935 if let Some(entries) = lock.get(key).and_then(JsonValue::as_array) {
936 for entry in entries {
937 if let Some(reference) = entry.get("reference").and_then(JsonValue::as_str) {
938 refs.insert(
939 reference.to_string(),
940 entry
941 .get("digest")
942 .and_then(JsonValue::as_str)
943 .map(ToOwned::to_owned),
944 );
945 }
946 }
947 }
948 }
949 refs
950}
951
952fn is_remote_reference(reference: &str) -> bool {
953 reference.starts_with("http://")
954 || reference.starts_with("https://")
955 || reference.starts_with("oci://")
956}
957
958fn is_stable_reference(reference: &str) -> bool {
959 reference.ends_with(":stable")
960}
961
962fn materialized_pack_candidates(bundle: &Path, reference: &str) -> Vec<PathBuf> {
963 if reference.starts_with("oci://") {
964 let Some(pack_id) = reference
965 .rsplit('/')
966 .next()
967 .and_then(|value| value.split(':').next())
968 .filter(|value| !value.is_empty())
969 else {
970 return Vec::new();
971 };
972 let filename = format!("{pack_id}.gtpack");
973 return vec![
974 bundle.join("packs").join(&filename),
975 bundle
976 .join("providers")
977 .join(crate::engine::domain_from_provider_id(pack_id))
978 .join(&filename),
979 ];
980 }
981
982 let Some(filename) = reference
983 .rsplit('/')
984 .next()
985 .filter(|value| value.ends_with(".gtpack"))
986 else {
987 return Vec::new();
988 };
989 vec![
990 bundle.join("packs").join(filename),
991 bundle.join("providers").join("messaging").join(filename),
992 bundle.join("providers").join("events").join(filename),
993 bundle.join("providers").join("oauth").join(filename),
994 bundle.join("providers").join("secrets").join(filename),
995 bundle.join("providers").join("state").join(filename),
996 ]
997}
998
999fn tunnel_supplies_public_base_url(bundle: &Path) -> bool {
1000 platform_setup::load_tunnel_artifact(bundle)
1001 .ok()
1002 .flatten()
1003 .and_then(|answers| answers.mode)
1004 .is_some_and(|mode| matches!(mode.as_str(), "cloudflared" | "ngrok"))
1005}
1006
1007fn is_forbidden_only_gmap(path: &Path) -> bool {
1008 std::fs::read_to_string(path)
1009 .map(|raw| {
1010 raw.lines()
1011 .map(str::trim)
1012 .filter(|line| !line.is_empty() && !line.starts_with('#'))
1013 .all(|line| line == "_ = forbidden")
1014 })
1015 .unwrap_or(false)
1016}
1017
1018fn yaml_get<'a>(map: &'a serde_yaml_bw::Mapping, key: &str) -> Option<&'a serde_yaml_bw::Value> {
1019 map.get(serde_yaml_bw::Value::String(key.to_string(), None))
1020}
1021
1022fn yaml_string_list(map: &serde_yaml_bw::Mapping, key: &str) -> Vec<String> {
1023 yaml_get(map, key)
1024 .and_then(serde_yaml_bw::Value::as_sequence)
1025 .map(|values| {
1026 values
1027 .iter()
1028 .filter_map(serde_yaml_bw::Value::as_str)
1029 .map(ToOwned::to_owned)
1030 .collect()
1031 })
1032 .unwrap_or_default()
1033}
1034
1035fn discover_gmap_targets(bundle: &Path) -> Vec<(String, Option<String>, PathBuf)> {
1036 let tenants_dir = bundle.join("tenants");
1037 let mut targets = Vec::new();
1038 let Ok(tenants) = std::fs::read_dir(&tenants_dir) else {
1039 return targets;
1040 };
1041 for tenant in tenants.flatten() {
1042 if !tenant.path().is_dir() {
1043 continue;
1044 }
1045 let Some(tenant_name) = tenant.file_name().to_str().map(ToOwned::to_owned) else {
1046 continue;
1047 };
1048 let tenant_gmap = tenant.path().join("tenant.gmap");
1049 if tenant_gmap.exists() {
1050 targets.push((tenant_name.clone(), None, tenant_gmap));
1051 }
1052 let teams_dir = tenant.path().join("teams");
1053 let Ok(teams) = std::fs::read_dir(teams_dir) else {
1054 continue;
1055 };
1056 for team in teams.flatten() {
1057 if !team.path().is_dir() {
1058 continue;
1059 }
1060 let Some(team_name) = team.file_name().to_str().map(ToOwned::to_owned) else {
1061 continue;
1062 };
1063 let team_gmap = team.path().join("team.gmap");
1064 if team_gmap.exists() {
1065 targets.push((tenant_name.clone(), Some(team_name), team_gmap));
1066 }
1067 }
1068 }
1069 targets
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use crate::bundle;
1076
1077 fn write(path: &Path, contents: &str) {
1078 if let Some(parent) = path.parent() {
1079 std::fs::create_dir_all(parent).unwrap();
1080 }
1081 std::fs::write(path, contents).unwrap();
1082 }
1083
1084 fn ids(report: &DoctorReport) -> BTreeSet<&str> {
1085 report
1086 .diagnostics
1087 .iter()
1088 .map(|d| d.check_id.as_str())
1089 .collect()
1090 }
1091
1092 #[test]
1093 fn missing_bundle_reports_error() {
1094 let report = run_doctor(Path::new("/definitely/missing/greentic-bundle"), None);
1095 assert_eq!(report.error_count, 1);
1096 assert_eq!(report.status, "error");
1097 }
1098
1099 #[test]
1100 fn new_bundle_reports_lock_and_setup_state() {
1101 let temp = tempfile::tempdir().unwrap();
1102 let root = temp.path().join("demo");
1103 bundle::create_demo_bundle_structure(&root, Some("demo")).unwrap();
1104
1105 let report = run_doctor(&root, None);
1106 assert!(report.error_count == 0, "{:#?}", report.diagnostics);
1107 assert!(
1108 report
1109 .diagnostics
1110 .iter()
1111 .any(|d| d.check_id == "setup.bundle.marker")
1112 );
1113 }
1114
1115 #[test]
1116 fn file_and_unmarked_directory_are_rejected_before_stage_checks() {
1117 let temp = tempfile::tempdir().unwrap();
1118 let file = temp.path().join("bundle.gtbundle");
1119 write(&file, "not a directory");
1120 let report = run_doctor(&file, Some(DoctorStage::Cache));
1121 assert_eq!(report.status, "error");
1122 assert!(ids(&report).contains("setup.bundle.directory"));
1123
1124 let dir = temp.path().join("plain-dir");
1125 std::fs::create_dir_all(&dir).unwrap();
1126 let report = run_doctor(&dir, Some(DoctorStage::Cache));
1127 assert!(ids(&report).contains("setup.bundle.marker"));
1128 assert_eq!(report.error_count, 1);
1129 assert_eq!(report.warn_count, 0);
1130 }
1131
1132 #[test]
1133 fn setup_stage_reports_manifest_parse_schema_and_reference_issues() {
1134 let temp = tempfile::tempdir().unwrap();
1135 let root = temp.path().join("demo");
1136 std::fs::create_dir_all(&root).unwrap();
1137 write(
1138 &root.join(BUNDLE_WORKSPACE_MARKER),
1139 "app_packs:\n - ../bad.gtpack\n",
1140 );
1141 let report = run_doctor(&root, Some(DoctorStage::Setup));
1142 let check_ids = ids(&report);
1143 assert!(check_ids.contains("setup.bundle_manifest.schema_version"));
1144 assert!(check_ids.contains("setup.bundle_manifest.reference_path"));
1145 assert!(check_ids.contains("setup.bundle_manifest.reference_exists"));
1146 assert!(check_ids.contains("setup.pack_discovery.empty"));
1147
1148 write(&root.join(BUNDLE_WORKSPACE_MARKER), ":\n");
1149 let report = run_doctor(&root, Some(DoctorStage::Setup));
1150 assert!(ids(&report).contains("setup.bundle_manifest.parse"));
1151
1152 write(&root.join(BUNDLE_WORKSPACE_MARKER), "- not-an-object\n");
1153 let report = run_doctor(&root, Some(DoctorStage::Setup));
1154 assert!(ids(&report).contains("setup.bundle_manifest.schema"));
1155 }
1156
1157 #[test]
1158 fn setup_stage_reports_remote_latest_registry_and_missing_materialization() {
1159 let temp = tempfile::tempdir().unwrap();
1160 let root = temp.path().join("demo");
1161 std::fs::create_dir_all(&root).unwrap();
1162 write(
1163 &root.join(BUNDLE_WORKSPACE_MARKER),
1164 r#"
1165schema_version: 1
1166app_packs:
1167 - oci://example.com/apps/chat:latest
1168"#,
1169 );
1170 write(&root.join("providers/providers.json"), "{");
1171
1172 let report = run_doctor(&root, Some(DoctorStage::Setup));
1173 let check_ids = ids(&report);
1174 assert!(check_ids.contains("setup.bundle_manifest.latest_ref"));
1175 assert!(check_ids.contains("setup.bundle_manifest.remote_materialized"));
1176 assert!(check_ids.contains("setup.provider_registry.parse"));
1177 }
1178
1179 #[test]
1180 fn lock_stage_reports_parse_format_digest_missing_and_stale_entries() {
1181 let temp = tempfile::tempdir().unwrap();
1182 let root = temp.path().join("demo");
1183 std::fs::create_dir_all(&root).unwrap();
1184 write(
1185 &root.join(BUNDLE_WORKSPACE_MARKER),
1186 "schema_version: 1\napp_packs:\n - packs/app.gtpack\n",
1187 );
1188 let report = run_doctor(&root, Some(DoctorStage::Locks));
1189 assert!(ids(&report).contains("setup.lock.present"));
1190
1191 write(&root.join(BUNDLE_LOCK_FILE), "{");
1192 let report = run_doctor(&root, Some(DoctorStage::Locks));
1193 assert!(ids(&report).contains("setup.lock.parse"));
1194
1195 write(
1196 &root.join(BUNDLE_LOCK_FILE),
1197 r#"{
1198 "build_format_version": "unexpected",
1199 "app_packs": [
1200 {"reference": "packs/app.gtpack"},
1201 {"reference": "packs/stale.gtpack", "digest": "sha256:stale"}
1202 ]
1203}"#,
1204 );
1205 let report = run_doctor(&root, Some(DoctorStage::Locks));
1206 let check_ids = ids(&report);
1207 assert!(check_ids.contains("setup.lock.format_version"));
1208 assert!(check_ids.contains("setup.lock.digest_present"));
1209 assert!(check_ids.contains("setup.lock.stale_reference"));
1210 }
1211
1212 #[test]
1213 fn lock_stage_reports_digest_mismatch_and_reference_absence() {
1214 let temp = tempfile::tempdir().unwrap();
1215 let root = temp.path().join("demo");
1216 std::fs::create_dir_all(&root).unwrap();
1217 write(
1218 &root.join(BUNDLE_WORKSPACE_MARKER),
1219 "schema_version: 1\napp_packs:\n - packs/app.gtpack\n - packs/missing.gtpack\n",
1220 );
1221 write(&root.join("packs/app.gtpack"), "actual bytes");
1222 write(
1223 &root.join(BUNDLE_LOCK_FILE),
1224 r#"{
1225 "build_format_version": "bundle-lock-v1",
1226 "app_packs": [
1227 {"reference": "packs/app.gtpack", "digest": "sha256:not-the-digest"}
1228 ]
1229}"#,
1230 );
1231
1232 let report = run_doctor(&root, Some(DoctorStage::Locks));
1233 let check_ids = ids(&report);
1234 assert!(check_ids.contains("setup.lock.digest_match"));
1235 assert!(check_ids.contains("setup.lock.reference_present"));
1236 }
1237
1238 #[test]
1239 fn route_stage_reports_missing_and_malformed_artifacts() {
1240 let temp = tempfile::tempdir().unwrap();
1241 let root = temp.path().join("demo");
1242 std::fs::create_dir_all(&root).unwrap();
1243 write(&root.join(BUNDLE_WORKSPACE_MARKER), "schema_version: 1\n");
1244 write(&root.join("tenants/demo/tenant.gmap"), "/ = app");
1245 write(
1246 &root.join("tenants/demo/teams/ops/team.gmap"),
1247 "_ = forbidden\n",
1248 );
1249
1250 let report = run_doctor(&root, Some(DoctorStage::Routes));
1251 let check_ids = ids(&report);
1252 assert!(check_ids.contains("setup.routes.static_routes_present"));
1253 assert!(check_ids.contains("setup.routes.resolved_manifest_present"));
1254
1255 write(&root.join("state/config/platform/static-routes.json"), "{");
1256 write(&root.join(".greentic/tunnel.json"), "{");
1257 let resolved = root
1258 .join("resolved")
1259 .join(bundle::resolved_manifest_filename("demo", None));
1260 write(&resolved, "# Resolved manifest placeholder");
1261 let report = run_doctor(&root, Some(DoctorStage::Routes));
1262 let check_ids = ids(&report);
1263 assert!(check_ids.contains("setup.routes.static_routes_parse"));
1264 assert!(check_ids.contains("setup.routes.tunnel_parse"));
1265 assert!(check_ids.contains("setup.routes.resolved_manifest_placeholder"));
1266 }
1267
1268 #[test]
1269 fn cache_and_runtime_stages_report_informational_diagnostics() {
1270 let temp = tempfile::tempdir().unwrap();
1271 let root = temp.path().join("demo");
1272 std::fs::create_dir_all(&root).unwrap();
1273 write(&root.join(BUNDLE_WORKSPACE_MARKER), "schema_version: 1\n");
1274
1275 let cache = run_doctor(&root, Some(DoctorStage::Cache));
1276 assert!(ids(&cache).contains("setup.cache.model"));
1277 assert_eq!(cache.status, "ok");
1278
1279 let runtime = run_doctor(&root, Some(DoctorStage::Runtime));
1280 assert!(ids(&runtime).contains("setup.runtime.state_present"));
1281 assert_eq!(runtime.status, "ok");
1282 }
1283
1284 #[test]
1285 fn helper_functions_parse_references_and_gmaps() {
1286 let temp = tempfile::tempdir().unwrap();
1287 let root = temp.path();
1288 write(
1289 &root.join(BUNDLE_WORKSPACE_MARKER),
1290 "schema_version: 1\napp_packs:\n - packs/app.gtpack\nextension_providers:\n - oci://example.com/providers/messaging-slack:stable\n",
1291 );
1292 let refs = workspace_refs(root).unwrap();
1293 assert!(refs.contains("packs/app.gtpack"));
1294 assert!(refs.contains("oci://example.com/providers/messaging-slack:stable"));
1295
1296 let candidates =
1297 materialized_pack_candidates(root, "oci://example.com/providers/messaging-slack:1.0.0");
1298 assert!(
1299 candidates
1300 .iter()
1301 .any(|p| p.ends_with("providers/messaging/messaging-slack.gtpack"))
1302 );
1303 assert!(materialized_pack_candidates(root, "not-a-pack").is_empty());
1304 assert!(is_remote_reference("https://example.com/app.gtpack"));
1305 assert!(is_stable_reference("oci://example.com/app:stable"));
1306
1307 write(
1308 &root.join("tenants/demo/tenant.gmap"),
1309 "_ = forbidden\n# comment\n",
1310 );
1311 write(&root.join("tenants/demo/teams/ops/team.gmap"), "/ = app\n");
1312 assert!(is_forbidden_only_gmap(
1313 &root.join("tenants/demo/tenant.gmap")
1314 ));
1315 let targets = discover_gmap_targets(root);
1316 assert_eq!(targets.len(), 2);
1317 }
1318}