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