systemprompt_cli/commands/cloud/doctor/
checks.rs1use std::collections::HashMap;
13use std::hash::BuildHasher;
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use systemprompt_models::Profile;
18
19use super::{CheckResult, CheckStatus};
20
21pub fn check_profile_valid(profile: &Profile) -> CheckResult {
22 match profile.validate() {
23 Ok(()) => CheckResult::pass("profile", "schema and required fields valid"),
24 Err(err) => CheckResult::fail("profile", err.to_string()),
25 }
26}
27
28pub fn check_extension_configs(profile: &Profile) -> CheckResult {
29 let services_path = Path::new(&profile.paths.services);
30 match systemprompt_runtime::validate_extension_configs(services_path) {
31 Err(err) => CheckResult::fail(
32 "extension-config",
33 format!("could not discover extensions: {err}"),
34 ),
35 Ok(outcomes) => {
36 let failures: Vec<String> = outcomes
37 .iter()
38 .filter_map(|o| {
39 o.error
40 .as_ref()
41 .map(|msg| format!("[ext:{}] {msg}", o.extension_id))
42 })
43 .collect();
44 if failures.is_empty() {
45 CheckResult::pass("extension-config", "all extension configs valid")
46 } else {
47 CheckResult::fail("extension-config", failures.join("\n"))
48 }
49 },
50 }
51}
52
53pub(in crate::commands::cloud) fn resolve_signing_key_path(
54 profile: &Profile,
55 profile_dir: &Path,
56) -> PathBuf {
57 let configured = &profile.security.signing_key_path;
58 if configured.is_absolute() {
59 configured.clone()
60 } else {
61 profile_dir.join(configured)
62 }
63}
64
65pub fn check_signing_key<S: BuildHasher>(
66 profile: &Profile,
67 profile_dir: &Path,
68 secrets: &HashMap<String, String, S>,
69) -> CheckResult {
70 if secrets.contains_key("signing_key_pem") {
71 return CheckResult::pass("signing-key", "provided via secrets.json (signing_key_pem)");
72 }
73
74 let path = resolve_signing_key_path(profile, profile_dir);
75 if path.exists() {
76 CheckResult::pass("signing-key", path.display().to_string())
77 } else {
78 CheckResult::fail(
79 "signing-key",
80 format!(
81 "no signing key at {} and no signing_key_pem in secrets.json — the deploy cannot \
82 provision a JWT signing key, so every request would 500. Generate one with \
83 `systemprompt admin keys generate --output {}`.",
84 path.display(),
85 path.display()
86 ),
87 )
88 }
89}
90
91pub fn check_required_secrets<S: BuildHasher>(secrets: &HashMap<String, String, S>) -> CheckResult {
92 let mut missing: Vec<&str> = Vec::new();
93
94 if !secrets.contains_key("oauth_at_rest_pepper") {
95 missing.push("oauth_at_rest_pepper");
96 }
97 let has_db =
98 secrets.contains_key("database_url") || secrets.contains_key("internal_database_url");
99 if !has_db {
100 missing.push("database_url (or internal_database_url)");
101 }
102
103 if missing.is_empty() {
104 CheckResult::pass("secrets", "required keys present")
105 } else {
106 CheckResult::fail(
107 "secrets",
108 format!(
109 "secrets.json is missing required keys: {}",
110 missing.join(", ")
111 ),
112 )
113 }
114}
115
116pub fn check_provider_secrets<S: BuildHasher>(
117 profile: &Profile,
118 secrets: &HashMap<String, String, S>,
119) -> CheckResult {
120 let missing: Vec<String> = profile
121 .providers
122 .providers
123 .iter()
124 .filter(|provider| !secret_present(secrets, provider.api_key_secret.as_str()))
125 .map(|provider| {
126 format!(
127 "{} (needs `{}`)",
128 provider.name.as_str(),
129 provider.api_key_secret.as_str()
130 )
131 })
132 .collect();
133
134 if missing.is_empty() {
135 CheckResult::pass("providers", "all provider credentials present")
136 } else {
137 CheckResult::fail(
138 "providers",
139 format!(
140 "secrets.json is missing credentials for: {}",
141 missing.join(", ")
142 ),
143 )
144 }
145}
146
147fn secret_present<S: BuildHasher>(secrets: &HashMap<String, String, S>, name: &str) -> bool {
148 secrets.contains_key(name)
149 || secrets.contains_key(&name.to_uppercase())
150 || secrets.contains_key(&name.to_lowercase())
151}
152
153pub(super) async fn check_database_reachable(secrets: &HashMap<String, String>) -> CheckResult {
154 let Some(url) = secrets
155 .get("external_database_url")
156 .or_else(|| secrets.get("database_url"))
157 .or_else(|| secrets.get("internal_database_url"))
158 else {
159 return CheckResult::warn("database", "no database URL to probe");
160 };
161
162 let Some((host, port)) = host_port(url) else {
163 return CheckResult::warn("database", "could not parse host:port from database URL");
164 };
165
166 match tokio::time::timeout(
167 Duration::from_secs(5),
168 tokio::net::TcpStream::connect((host.as_str(), port)),
169 )
170 .await
171 {
172 Ok(Ok(_)) => CheckResult::pass("database", format!("reachable at {host}:{port}")),
173 Ok(Err(err)) => CheckResult::warn(
174 "database",
175 format!("{host}:{port} unreachable from here ({err}) — fine if DB is Fly-internal"),
176 ),
177 Err(_) => CheckResult::warn(
178 "database",
179 format!("{host}:{port} did not answer within 5s — fine if DB is Fly-internal"),
180 ),
181 }
182}
183
184pub(super) fn check_governance_hook_url(profile: &Profile) -> CheckResult {
185 let Some(authz) = profile.governance.as_ref().and_then(|g| g.authz.as_ref()) else {
186 return CheckResult::warn("hook-url", "no governance.authz block");
187 };
188 let Some(url) = authz.hook.url.as_deref().filter(|u| !u.is_empty()) else {
189 return CheckResult::pass("hook-url", "no webhook URL to check for this mode");
190 };
191
192 let hook_host = host_port(url).map(|(h, _)| h);
193 let external_host = host_port(&profile.server.api_external_url).map(|(h, _)| h);
194
195 match (hook_host, external_host) {
196 (Some(hook), Some(external)) if hook == external => {
197 CheckResult::pass("hook-url", format!("targets {external}"))
198 },
199 (Some(hook), Some(external)) if is_loopback(&hook) => CheckResult::warn(
200 "hook-url",
201 format!(
202 "points at {hook} but api_external_url is {external} — a loopback hook only works \
203 if the gateway and webhook share the machine"
204 ),
205 ),
206 (Some(hook), Some(external)) => CheckResult::warn(
207 "hook-url",
208 format!("targets {hook}, but api_external_url is {external} — verify this is intended"),
209 ),
210 _ => CheckResult::warn("hook-url", "could not parse hook or api_external_url host"),
211 }
212}
213
214fn host_port(raw: &str) -> Option<(String, u16)> {
215 let parsed = url::Url::parse(raw).ok()?;
216 let host = parsed.host_str()?.to_owned();
217 let port = parsed.port_or_known_default()?;
218 Some((host, port))
219}
220
221fn is_loopback(host: &str) -> bool {
222 host == "localhost" || host == "127.0.0.1" || host == "::1"
223}
224
225impl CheckResult {
226 pub(super) fn pass(name: &'static str, detail: impl Into<String>) -> Self {
227 Self {
228 name,
229 status: CheckStatus::Pass,
230 detail: detail.into(),
231 }
232 }
233
234 pub(super) fn warn(name: &'static str, detail: impl Into<String>) -> Self {
235 Self {
236 name,
237 status: CheckStatus::Warn,
238 detail: detail.into(),
239 }
240 }
241
242 pub(super) fn fail(name: &'static str, detail: impl Into<String>) -> Self {
243 Self {
244 name,
245 status: CheckStatus::Fail,
246 detail: detail.into(),
247 }
248 }
249}