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