Skip to main content

systemprompt_cli/commands/cloud/doctor/
mod.rs

1//! `cloud doctor`: pre-deploy preflight for runtime prerequisites.
2//!
3//! Validates the things that otherwise only surface as a post-deploy 500 — a
4//! valid profile (incl. `governance.authz`), a provisionable signing key,
5//! `secrets.json` with the required keys and provider credentials — and probes
6//! database/hook reachability. The preflight runs automatically before
7//! `cloud deploy` builds an image, and is exposed standalone (`cloud doctor`)
8//! so an operator can check a profile without deploying.
9
10mod checks;
11
12pub(in crate::commands::cloud) use checks::resolve_signing_key_path;
13pub use checks::{
14    check_extension_configs, check_profile_valid, check_provider_secrets, check_required_secrets,
15    check_signing_key,
16};
17
18use std::collections::HashMap;
19use std::path::Path;
20
21use anyhow::{Result, anyhow, bail};
22use systemprompt_cloud::ProfilePath;
23use systemprompt_logging::CliService;
24use systemprompt_models::Profile;
25
26use super::deploy::resolve_profile;
27use crate::cli_settings::CliConfig;
28use systemprompt_cloud::secrets_env::load_secrets_json;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CheckStatus {
32    Pass,
33    Warn,
34    Fail,
35}
36
37#[derive(Debug)]
38pub struct CheckResult {
39    pub name: &'static str,
40    pub status: CheckStatus,
41    pub detail: String,
42}
43
44pub(in crate::commands::cloud) struct DoctorReport {
45    checks: Vec<CheckResult>,
46}
47
48impl DoctorReport {
49    pub(in crate::commands::cloud) fn has_blocking(&self) -> bool {
50        self.checks.iter().any(|c| c.status == CheckStatus::Fail)
51    }
52
53    pub(in crate::commands::cloud) fn render(&self) {
54        CliService::section("Deploy preflight");
55        for check in &self.checks {
56            let line = format!("{}: {}", check.name, check.detail);
57            match check.status {
58                CheckStatus::Pass => CliService::success(&line),
59                CheckStatus::Warn => CliService::warning(&line),
60                CheckStatus::Fail => CliService::error(&line),
61            }
62        }
63    }
64}
65
66pub(in crate::commands::cloud) async fn run(profile: &Profile, profile_dir: &Path) -> DoctorReport {
67    let mut checks = vec![check_profile_valid(profile)];
68
69    let secrets_path = ProfilePath::Secrets.resolve(profile_dir);
70    let secrets = load_secrets_json(&secrets_path).unwrap_or_else(|_| {
71        checks.push(CheckResult::fail(
72            "secrets-file",
73            format!(
74                "secrets.json not found or unreadable at {}",
75                secrets_path.display()
76            ),
77        ));
78        HashMap::new()
79    });
80
81    checks.push(check_required_secrets(&secrets));
82    checks.push(check_signing_key(profile, profile_dir, &secrets));
83    checks.push(check_provider_secrets(profile, &secrets));
84    checks.push(check_extension_configs(profile));
85    checks.push(checks::check_governance_hook_url(profile));
86    checks.push(checks::check_database_reachable(&secrets).await);
87
88    DoctorReport { checks }
89}
90
91pub(in crate::commands::cloud) async fn execute(
92    profile_name: Option<String>,
93    config: &CliConfig,
94) -> Result<()> {
95    let (profile, profile_path) = resolve_profile(profile_name.as_deref(), config)?;
96    let profile_dir = profile_path
97        .parent()
98        .ok_or_else(|| anyhow!("Invalid profile path"))?;
99
100    let report = run(&profile, profile_dir).await;
101    report.render();
102
103    if report.has_blocking() {
104        bail!("Deploy preflight failed — fix the items above before deploying.");
105    }
106    CliService::success("Deploy preflight passed");
107    Ok(())
108}