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