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;
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}