Skip to main content

greentic_setup/
doctor.rs

1//! Read-only diagnostics for greentic-setup bundles.
2
3use 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}