Skip to main content

systemprompt_cli/commands/cloud/doctor/
checks.rs

1//! Individual pre-deploy checks.
2//!
3//! Each function returns a [`CheckResult`]. Configuration prerequisites that
4//! would otherwise surface only as a post-deploy 500 (signing key, governance,
5//! secrets, provider credentials) are `Fail`; reachability probes whose outcome
6//! depends on where the operator is running the CLI (database TCP, hook host)
7//! are `Warn` so they inform without blocking a legitimate deploy.
8
9use 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}