systemprompt_cli/commands/cloud/doctor/
mod.rs1mod 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
73fn resolve_services_config(profile: &Profile) -> PathBuf {
74 let declared = PathBuf::from(profile.paths.config());
75 if declared.exists() {
76 return declared;
77 }
78 let local = ProjectContext::discover()
79 .root()
80 .join("services")
81 .join("config")
82 .join("config.yaml");
83 if local.exists() {
84 return local;
85 }
86 declared
87}
88
89pub(in crate::commands::cloud) async fn run(
90 profile: &Profile,
91 profile_dir: &Path,
92 distributed: bool,
93) -> DoctorReport {
94 let mut checks = vec![check_profile_valid(profile)];
95
96 let secrets_path = ProfilePath::Secrets.resolve(profile_dir);
97 let secrets = load_secrets_json(&secrets_path).unwrap_or_else(|_| {
98 checks.push(CheckResult::fail(
99 "secrets-file",
100 format!(
101 "secrets.json not found or unreadable at {}",
102 secrets_path.display()
103 ),
104 ));
105 HashMap::new()
106 });
107
108 checks.push(check_required_secrets(&secrets));
109 checks.push(check_signing_key(profile, profile_dir, &secrets));
110 let services_root = resolve_services_config(profile);
111 match ConfigLoader::load_from_path(&services_root) {
112 Ok(services) => checks.push(check_provider_secrets(&services.providers, &secrets)),
113 Err(err) => checks.push(CheckResult::warn(
114 "providers",
115 format!(
116 "services config at {} could not be loaded, so provider credentials were not \
117 checked: {err}",
118 services_root.display()
119 ),
120 )),
121 }
122 checks.push(check_extension_configs(profile));
123 checks.push(check_proxy_topology(profile));
124 checks.push(checks::check_governance_hook_url(profile));
125 checks.push(checks::check_database_reachable(&secrets).await);
126 if distributed {
127 checks.extend(distributed::run(profile, &secrets).await);
128 }
129
130 DoctorReport { checks }
131}
132
133pub(in crate::commands::cloud) async fn execute(
134 profile_name: Option<String>,
135 distributed: bool,
136 prompter: &dyn Prompter,
137 config: &CliConfig,
138) -> Result<()> {
139 let (profile, profile_path) = resolve_profile(prompter, profile_name.as_deref(), config)?;
140 let profile_dir = profile_path
141 .parent()
142 .ok_or_else(|| anyhow!("Invalid profile path"))?;
143
144 let report = run(&profile, profile_dir, distributed).await;
145 report.render();
146
147 if report.has_blocking() {
148 bail!("Deploy preflight failed — fix the items above before deploying.");
149 }
150 CliService::success("Deploy preflight passed");
151 Ok(())
152}