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