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 reachable secrets source with
5//! the required keys and provider credentials, a
6//! `trusted_proxies` set that covers the Fly peer range — and probes
7//! database/hook reachability. The preflight runs automatically before
8//! `cloud deploy` builds an image, and is exposed standalone (`cloud doctor`)
9//! so an operator can check a profile without deploying.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14mod checks;
15pub mod distributed;
16pub mod vault_checks;
17
18pub(in crate::commands::cloud) use checks::resolve_signing_key_path;
19pub use checks::{
20    check_extension_configs, check_profile_valid, check_provider_secrets, check_proxy_topology,
21    check_required_secrets, check_signing_key,
22};
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26
27use anyhow::{Result, anyhow, bail};
28use systemprompt_cloud::{ProfilePath, ProjectContext};
29use systemprompt_loader::ConfigLoader;
30use systemprompt_logging::CliService;
31use systemprompt_models::Profile;
32use systemprompt_models::profile::SecretsSource;
33
34use super::deploy::resolve_profile;
35use crate::cli_settings::CliConfig;
36use crate::interactive::Prompter;
37use systemprompt_cloud::secrets_env::load_secrets_json;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum CheckStatus {
41    Pass,
42    Warn,
43    Fail,
44}
45
46#[derive(Debug)]
47pub struct CheckResult {
48    pub name: &'static str,
49    pub status: CheckStatus,
50    pub detail: String,
51}
52
53pub(in crate::commands::cloud) struct DoctorReport {
54    checks: Vec<CheckResult>,
55}
56
57impl DoctorReport {
58    pub(in crate::commands::cloud) fn has_blocking(&self) -> bool {
59        self.checks.iter().any(|c| c.status == CheckStatus::Fail)
60    }
61
62    pub(in crate::commands::cloud) fn render(&self) {
63        CliService::section("Deploy preflight");
64        for check in &self.checks {
65            let line = format!("{}: {}", check.name, check.detail);
66            match check.status {
67                CheckStatus::Pass => CliService::success(&line),
68                CheckStatus::Warn => CliService::warning(&line),
69                CheckStatus::Fail => CliService::error(&line),
70            }
71        }
72    }
73}
74
75fn resolve_services_config(profile: &Profile) -> PathBuf {
76    let declared = PathBuf::from(profile.paths.config());
77    if declared.exists() {
78        return declared;
79    }
80    let local = ProjectContext::discover()
81        .root()
82        .join("services")
83        .join("config")
84        .join("config.yaml");
85    if local.exists() {
86        return local;
87    }
88    declared
89}
90
91async fn resolve_secrets(
92    profile: &Profile,
93    profile_dir: &Path,
94    checks: &mut Vec<CheckResult>,
95) -> HashMap<String, String> {
96    let vault = profile
97        .secrets
98        .as_ref()
99        .filter(|config| matches!(config.source, SecretsSource::Vault))
100        .and_then(|config| config.vault.as_ref());
101
102    let Some(vault) = vault else {
103        let secrets_path = ProfilePath::Secrets.resolve(profile_dir);
104        return load_secrets_json(&secrets_path).unwrap_or_else(|_e| {
105            checks.push(CheckResult::fail(
106                "secrets-file",
107                format!(
108                    "secrets.json not found or unreadable at {}",
109                    secrets_path.display()
110                ),
111            ));
112            HashMap::new()
113        });
114    };
115
116    let address = vault_checks::check_vault_address(vault);
117    let blocked = address.status == CheckStatus::Fail;
118    checks.push(address);
119    if blocked {
120        return HashMap::new();
121    }
122
123    let (document, values) = vault_checks::check_vault_document(vault).await;
124    checks.push(document);
125    values
126}
127
128pub(in crate::commands::cloud) async fn run(
129    profile: &Profile,
130    profile_dir: &Path,
131    distributed: bool,
132) -> DoctorReport {
133    let mut checks = vec![check_profile_valid(profile)];
134    let secrets = resolve_secrets(profile, profile_dir, &mut checks).await;
135
136    checks.push(check_required_secrets(&secrets));
137    checks.push(check_signing_key(profile, profile_dir, &secrets));
138    let services_root = resolve_services_config(profile);
139    match ConfigLoader::load_from_path(&services_root) {
140        Ok(services) => checks.push(check_provider_secrets(&services.providers, &secrets)),
141        Err(err) => checks.push(CheckResult::warn(
142            "providers",
143            format!(
144                "services config at {} could not be loaded, so provider credentials were not \
145                 checked: {err}",
146                services_root.display()
147            ),
148        )),
149    }
150    checks.push(check_extension_configs(profile));
151    checks.push(check_proxy_topology(profile));
152    checks.push(checks::check_governance_hook_url(profile));
153    checks.push(checks::check_database_reachable(&secrets).await);
154    if distributed {
155        checks.extend(distributed::run(profile, &secrets).await);
156    }
157
158    DoctorReport { checks }
159}
160
161pub(in crate::commands::cloud) async fn execute(
162    profile_name: Option<String>,
163    distributed: bool,
164    prompter: &dyn Prompter,
165    config: &CliConfig,
166) -> Result<()> {
167    let (profile, profile_path) = resolve_profile(prompter, profile_name.as_deref(), config)?;
168    let profile_dir = profile_path
169        .parent()
170        .ok_or_else(|| anyhow!("Invalid profile path"))?;
171
172    let report = run(&profile, profile_dir, distributed).await;
173    report.render();
174
175    if report.has_blocking() {
176        bail!("Deploy preflight failed — fix the items above before deploying.");
177    }
178    CliService::success("Deploy preflight passed");
179    Ok(())
180}