Skip to main content

systemprompt_runtime/startup_validation/
extension_validator.rs

1//! Per-extension config and asset validation at startup.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::path::Path;
7use systemprompt_config::ProfileBootstrap;
8use systemprompt_extension::ExtensionRegistry;
9use systemprompt_logging::CliService;
10use systemprompt_logging::services::cli::{BrandColors, render_phase_success};
11use systemprompt_models::{AppPaths, Config};
12use systemprompt_traits::validation_report::ValidationError;
13use systemprompt_traits::{StartupValidationReport, ValidationReport};
14
15use super::config_loaders::load_extension_config;
16
17pub(super) fn validate_extensions(
18    config: &Config,
19    report: &mut StartupValidationReport,
20    verbose: bool,
21) {
22    let extensions = match ExtensionRegistry::discover() {
23        Ok(extensions) => extensions,
24        Err(e) => {
25            let mut ext_report = ValidationReport::new("ext:registry".to_owned());
26            ext_report.add_error(ValidationError::new(
27                "extension_discovery",
28                format!("Failed to discover extensions: {}", e),
29            ));
30            report.add_extension(ext_report);
31            return;
32        },
33    };
34    let config_extensions = extensions.config_extensions();
35    let asset_extensions = extensions.asset_extensions();
36
37    let has_extensions = !config_extensions.is_empty() || !asset_extensions.is_empty();
38
39    if !has_extensions {
40        return;
41    }
42
43    if verbose {
44        CliService::output("");
45        CliService::output(&format!(
46            "{} {}",
47            BrandColors::primary("▸"),
48            BrandColors::white_bold("Validating extensions")
49        ));
50    }
51
52    for ext in config_extensions {
53        validate_single_extension(config, ext.as_ref(), report, verbose);
54    }
55
56    let paths_result = ProfileBootstrap::get()
57        .map_err(|e| e.to_string())
58        .and_then(|p| AppPaths::from_profile(&p.paths).map_err(|e| e.to_string()));
59
60    match paths_result {
61        Ok(paths) => validate_extension_assets(&extensions, &paths, report, verbose),
62        Err(_) if verbose => {
63            CliService::output(&format!(
64                "  {} Asset validation skipped (profile not loaded)",
65                BrandColors::dim("○")
66            ));
67        },
68        Err(_) => {},
69    }
70}
71
72fn validate_extension_assets(
73    registry: &ExtensionRegistry,
74    paths: &AppPaths,
75    report: &mut StartupValidationReport,
76    verbose: bool,
77) {
78    for ext in registry.asset_extensions() {
79        let ext_id = ext.id();
80        let mut has_errors = false;
81
82        for asset in ext.required_assets(paths) {
83            if asset.is_required() && !asset.source().exists() {
84                has_errors = true;
85                let mut ext_report = ValidationReport::new(format!("ext:{}", ext_id));
86                ext_report.add_error(
87                    ValidationError::new(
88                        "required_asset",
89                        format!("Missing required asset: {}", asset.source().display()),
90                    )
91                    .with_suggestion("Ensure the asset file exists at the specified path"),
92                );
93                report.add_extension(ext_report);
94
95                CliService::output(&format!(
96                    "  {} [ext:{}] Missing asset: {}",
97                    BrandColors::stopped("✗"),
98                    ext_id,
99                    asset.source().display()
100                ));
101            }
102        }
103
104        if !has_errors && verbose {
105            render_phase_success(&format!("[ext:{}]", ext_id), Some("assets valid"));
106        }
107    }
108}
109
110/// A single extension's resolved config-validation result.
111///
112/// Produced by [`validate_extension_configs`] so callers outside the serve
113/// boot path (e.g. the `cloud doctor` preflight) can run the exact same
114/// `validate_config` pass without a `Config` or a [`StartupValidationReport`].
115#[derive(Debug)]
116pub struct ExtensionConfigOutcome {
117    pub extension_id: String,
118    pub config_key: String,
119    pub error: Option<String>,
120}
121
122enum ExtConfigError {
123    Load(String),
124    Validate(String),
125}
126
127impl ExtConfigError {
128    fn message(&self) -> &str {
129        match self {
130            Self::Load(m) | Self::Validate(m) => m,
131        }
132    }
133}
134
135fn evaluate_extension_config(
136    ext: &dyn systemprompt_extension::Extension,
137    services_path: &Path,
138) -> Result<(), ExtConfigError> {
139    let Some(prefix) = ext.config_prefix() else {
140        return Ok(());
141    };
142
143    let config_path = services_path
144        .join("config")
145        .join(format!("{}.yaml", prefix));
146
147    let config_json = if config_path.exists() {
148        load_extension_config(&config_path).map_err(ExtConfigError::Load)?
149    } else {
150        serde_json::json!({})
151    };
152
153    ext.validate_config(&config_json)
154        .map_err(|e| ExtConfigError::Validate(e.to_string()))
155}
156
157/// Run every config-bearing extension's `validate_config` against the resolved
158/// service config under `services_path` and collect the results.
159///
160/// This is the same per-extension load-and-validate the serve boot path runs;
161/// both paths funnel through `evaluate_extension_config` so they cannot drift.
162/// `Err` indicates the extension registry could not be discovered at all.
163pub fn validate_extension_configs(
164    services_path: &Path,
165) -> Result<Vec<ExtensionConfigOutcome>, String> {
166    let extensions = ExtensionRegistry::discover().map_err(|e| e.to_string())?;
167
168    Ok(extensions
169        .config_extensions()
170        .iter()
171        .filter_map(|ext| {
172            let prefix = ext.config_prefix()?;
173            let error = evaluate_extension_config(ext.as_ref(), services_path)
174                .err()
175                .map(|e| e.message().to_owned());
176            Some(ExtensionConfigOutcome {
177                extension_id: ext.id().to_owned(),
178                config_key: format!("{}.config", prefix),
179                error,
180            })
181        })
182        .collect())
183}
184
185fn validate_single_extension(
186    config: &Config,
187    ext: &dyn systemprompt_extension::Extension,
188    report: &mut StartupValidationReport,
189    verbose: bool,
190) {
191    let ext_id = ext.id();
192    let Some(prefix) = ext.config_prefix() else {
193        return;
194    };
195
196    match evaluate_extension_config(ext, Path::new(&config.services_path)) {
197        Ok(()) => {
198            if verbose {
199                render_phase_success(&format!("[ext:{}]", ext_id), Some("valid"));
200            }
201        },
202        Err(e) => {
203            let report_message = match &e {
204                ExtConfigError::Load(m) => format!("Failed to load config: {}", m),
205                ExtConfigError::Validate(m) => m.clone(),
206            };
207            let mut ext_report = ValidationReport::new(format!("ext:{}", ext_id));
208            ext_report.add_error(ValidationError::new(
209                format!("{}.config", prefix),
210                report_message,
211            ));
212            report.add_extension(ext_report);
213            CliService::output(&format!(
214                "  {} [ext:{}] {}",
215                BrandColors::stopped("✗"),
216                ext_id,
217                e.message()
218            ));
219        },
220    }
221}