systemprompt_cli/commands/cloud/doctor/
mod.rs1mod 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}