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