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//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::collections::HashMap;
13use std::hash::BuildHasher;
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use systemprompt_models::Profile;
18use systemprompt_models::services::ProviderRegistry;
19
20use super::{CheckResult, CheckStatus};
21
22pub fn check_profile_valid(profile: &Profile) -> CheckResult {
23    match profile.validate() {
24        Ok(()) => CheckResult::pass("profile", "schema and required fields valid"),
25        Err(err) => CheckResult::fail("profile", err.to_string()),
26    }
27}
28
29pub fn check_extension_configs(profile: &Profile) -> CheckResult {
30    let services_path = Path::new(&profile.paths.services);
31    match systemprompt_runtime::validate_extension_configs(services_path) {
32        Err(err) => CheckResult::fail(
33            "extension-config",
34            format!("could not discover extensions: {err}"),
35        ),
36        Ok(outcomes) => {
37            let failures: Vec<String> = outcomes
38                .iter()
39                .filter_map(|o| {
40                    o.error
41                        .as_ref()
42                        .map(|msg| format!("[ext:{}] {msg}", o.extension_id))
43                })
44                .collect();
45            if failures.is_empty() {
46                CheckResult::pass("extension-config", "all extension configs valid")
47            } else {
48                CheckResult::fail("extension-config", failures.join("\n"))
49            }
50        },
51    }
52}
53
54pub(in crate::commands::cloud) fn resolve_signing_key_path(
55    profile: &Profile,
56    profile_dir: &Path,
57) -> PathBuf {
58    let configured = &profile.security.signing_key_path;
59    if configured.is_absolute() {
60        configured.clone()
61    } else {
62        profile_dir.join(configured)
63    }
64}
65
66pub fn check_signing_key<S: BuildHasher>(
67    profile: &Profile,
68    profile_dir: &Path,
69    secrets: &HashMap<String, String, S>,
70) -> CheckResult {
71    if secrets.contains_key("signing_key_pem") {
72        return CheckResult::pass("signing-key", "provided via secrets.json (signing_key_pem)");
73    }
74
75    let path = resolve_signing_key_path(profile, profile_dir);
76    if path.exists() {
77        CheckResult::pass("signing-key", path.display().to_string())
78    } else {
79        CheckResult::fail(
80            "signing-key",
81            format!(
82                "no signing key at {} and no signing_key_pem in secrets.json — the deploy cannot \
83                 provision a JWT signing key, so every request would 500. Generate one with \
84                 `systemprompt admin keys generate --output {}`.",
85                path.display(),
86                path.display()
87            ),
88        )
89    }
90}
91
92pub fn check_required_secrets<S: BuildHasher>(secrets: &HashMap<String, String, S>) -> CheckResult {
93    let mut missing: Vec<&str> = Vec::new();
94
95    if !secrets.contains_key("oauth_at_rest_pepper") {
96        missing.push("oauth_at_rest_pepper");
97    }
98    let has_db =
99        secrets.contains_key("database_url") || secrets.contains_key("internal_database_url");
100    if !has_db {
101        missing.push("database_url (or internal_database_url)");
102    }
103
104    if missing.is_empty() {
105        CheckResult::pass("secrets", "required keys present")
106    } else {
107        CheckResult::fail(
108            "secrets",
109            format!(
110                "secrets.json is missing required keys: {}",
111                missing.join(", ")
112            ),
113        )
114    }
115}
116
117pub fn check_provider_secrets<S: BuildHasher>(
118    registry: &ProviderRegistry,
119    secrets: &HashMap<String, String, S>,
120) -> CheckResult {
121    let missing: Vec<String> = registry
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 fn check_proxy_topology(profile: &Profile) -> CheckResult {
185    if !profile.target.is_cloud() {
186        return CheckResult::pass("proxy-topology", "not a cloud profile");
187    }
188    if systemprompt_cloud::trusted_proxies::covers_fly_peer(&profile.server.trusted_proxies) {
189        if !systemprompt_cloud::trusted_proxies::covers_fly_public_edge(
190            &profile.server.trusted_proxies,
191        ) {
192            return CheckResult::warn(
193                "proxy-topology",
194                "server.trusted_proxies covers the Fly peer range (fc00::/7) but not Fly's \
195                 public edge range 66.241.64.0/18 — requests routed through the public edge \
196                 would be attributed to the Fly proxy instead of the client. Add to the \
197                 profile:\n  server:\n    trusted_proxies:\n      - \"66.241.64.0/18\"",
198            );
199        }
200        return CheckResult::pass(
201            "proxy-topology",
202            "server.trusted_proxies covers the Fly peer (fc00::/7) and public edge \
203             (66.241.64.0/18) ranges",
204        );
205    }
206    CheckResult::fail(
207        "proxy-topology",
208        "server.trusted_proxies does not cover Fly's internal peer range fc00::/7 — every \
209         request would resolve to the Fly proxy's private address and forwarded client-IP \
210         headers (X-Forwarded-For, CF-Connecting-IP) would be ignored, breaking geo \
211         attribution, rate-limit keys, and IP bans. Add to the profile:\n  server:\n    \
212         trusted_proxies:\n      - \"fc00::/7\"\nplus your edge proxy ranges (e.g. Cloudflare).",
213    )
214}
215
216pub(super) fn check_governance_hook_url(profile: &Profile) -> CheckResult {
217    let Some(authz) = profile.governance.as_ref().and_then(|g| g.authz.as_ref()) else {
218        return CheckResult::warn("hook-url", "no governance.authz block");
219    };
220    let Some(url) = authz.hook.url.as_deref().filter(|u| !u.is_empty()) else {
221        return CheckResult::pass("hook-url", "no webhook URL to check for this mode");
222    };
223
224    let hook_host = host_port(url).map(|(h, _)| h);
225    let external_host = host_port(&profile.server.api_external_url).map(|(h, _)| h);
226
227    match (hook_host, external_host) {
228        (Some(hook), Some(external)) if hook == external => {
229            CheckResult::pass("hook-url", format!("targets {external}"))
230        },
231        (Some(hook), Some(external)) if is_loopback(&hook) => CheckResult::warn(
232            "hook-url",
233            format!(
234                "points at {hook} but api_external_url is {external} — a loopback hook only works \
235                 if the gateway and webhook share the machine"
236            ),
237        ),
238        (Some(hook), Some(external)) => CheckResult::warn(
239            "hook-url",
240            format!("targets {hook}, but api_external_url is {external} — verify this is intended"),
241        ),
242        _ => CheckResult::warn("hook-url", "could not parse hook or api_external_url host"),
243    }
244}
245
246fn host_port(raw: &str) -> Option<(String, u16)> {
247    let parsed = url::Url::parse(raw).ok()?;
248    let host = parsed.host_str()?.to_owned();
249    let port = parsed.port_or_known_default()?;
250    Some((host, port))
251}
252
253fn is_loopback(host: &str) -> bool {
254    host == "localhost" || host == "127.0.0.1" || host == "::1"
255}
256
257impl CheckResult {
258    pub(super) fn pass(name: &'static str, detail: impl Into<String>) -> Self {
259        Self {
260            name,
261            status: CheckStatus::Pass,
262            detail: detail.into(),
263        }
264    }
265
266    pub(super) fn warn(name: &'static str, detail: impl Into<String>) -> Self {
267        Self {
268            name,
269            status: CheckStatus::Warn,
270            detail: detail.into(),
271        }
272    }
273
274    pub(super) fn fail(name: &'static str, detail: impl Into<String>) -> Self {
275        Self {
276            name,
277            status: CheckStatus::Fail,
278            detail: detail.into(),
279        }
280    }
281}