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, 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;
16
17pub(in crate::commands::cloud) use checks::resolve_signing_key_path;
18pub use checks::{
19    check_extension_configs, check_profile_valid, check_provider_secrets, check_proxy_topology,
20    check_required_secrets, check_signing_key,
21};
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use anyhow::{Result, anyhow, bail};
27use systemprompt_cloud::{ProfilePath, ProjectContext};
28use systemprompt_loader::ConfigLoader;
29use systemprompt_logging::CliService;
30use systemprompt_models::Profile;
31
32use super::deploy::resolve_profile;
33use crate::cli_settings::CliConfig;
34use crate::interactive::Prompter;
35use systemprompt_cloud::secrets_env::load_secrets_json;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum CheckStatus {
39    Pass,
40    Warn,
41    Fail,
42}
43
44#[derive(Debug)]
45pub struct CheckResult {
46    pub name: &'static str,
47    pub status: CheckStatus,
48    pub detail: String,
49}
50
51pub(in crate::commands::cloud) struct DoctorReport {
52    checks: Vec<CheckResult>,
53}
54
55impl DoctorReport {
56    pub(in crate::commands::cloud) fn has_blocking(&self) -> bool {
57        self.checks.iter().any(|c| c.status == CheckStatus::Fail)
58    }
59
60    pub(in crate::commands::cloud) fn render(&self) {
61        CliService::section("Deploy preflight");
62        for check in &self.checks {
63            let line = format!("{}: {}", check.name, check.detail);
64            match check.status {
65                CheckStatus::Pass => CliService::success(&line),
66                CheckStatus::Warn => CliService::warning(&line),
67                CheckStatus::Fail => CliService::error(&line),
68            }
69        }
70    }
71}
72
73// Why: a cloud profile's `paths.config()` is the container's `/app/services`
74// tree, absent on the machine running `cloud doctor`, so the
75// provider-credential check degraded to a warning and reported green while two
76// undeployable providers shipped. The catalog also ships from the repo's
77// services tree, so fall back to that before giving up; returning the declared
78// path when neither exists keeps the warning naming what the profile asked for.
79fn resolve_services_config(profile: &Profile) -> PathBuf {
80    let declared = PathBuf::from(profile.paths.config());
81    if declared.exists() {
82        return declared;
83    }
84    let local = ProjectContext::discover()
85        .root()
86        .join("services")
87        .join("config")
88        .join("config.yaml");
89    if local.exists() {
90        return local;
91    }
92    declared
93}
94
95pub(in crate::commands::cloud) async fn run(
96    profile: &Profile,
97    profile_dir: &Path,
98    distributed: bool,
99) -> DoctorReport {
100    let mut checks = vec![check_profile_valid(profile)];
101
102    let secrets_path = ProfilePath::Secrets.resolve(profile_dir);
103    let secrets = load_secrets_json(&secrets_path).unwrap_or_else(|_| {
104        checks.push(CheckResult::fail(
105            "secrets-file",
106            format!(
107                "secrets.json not found or unreadable at {}",
108                secrets_path.display()
109            ),
110        ));
111        HashMap::new()
112    });
113
114    checks.push(check_required_secrets(&secrets));
115    checks.push(check_signing_key(profile, profile_dir, &secrets));
116    let services_root = resolve_services_config(profile);
117    match ConfigLoader::load_from_path(&services_root) {
118        Ok(services) => checks.push(check_provider_secrets(&services.providers, &secrets)),
119        Err(err) => checks.push(CheckResult::warn(
120            "providers",
121            format!(
122                "services config at {} could not be loaded, so provider credentials were not \
123                 checked: {err}",
124                services_root.display()
125            ),
126        )),
127    }
128    checks.push(check_extension_configs(profile));
129    checks.push(check_proxy_topology(profile));
130    checks.push(checks::check_governance_hook_url(profile));
131    checks.push(checks::check_database_reachable(&secrets).await);
132    if distributed {
133        checks.extend(distributed::run(profile, &secrets).await);
134    }
135
136    DoctorReport { checks }
137}
138
139pub(in crate::commands::cloud) async fn execute(
140    profile_name: Option<String>,
141    distributed: bool,
142    prompter: &dyn Prompter,
143    config: &CliConfig,
144) -> Result<()> {
145    let (profile, profile_path) = resolve_profile(prompter, profile_name.as_deref(), config)?;
146    let profile_dir = profile_path
147        .parent()
148        .ok_or_else(|| anyhow!("Invalid profile path"))?;
149
150    let report = run(&profile, profile_dir, distributed).await;
151    report.render();
152
153    if report.has_blocking() {
154        bail!("Deploy preflight failed — fix the items above before deploying.");
155    }
156    CliService::success("Deploy preflight passed");
157    Ok(())
158}