Skip to main content

systemprompt_loader/bundle/bootstrap/
mod.rs

1//! Boot-time resolution of the services root from configured bundle sources.
2//!
3//! The happy path is fetch, verify, compose, load-validate, swap, record. Every
4//! other path is a named fallback carrying the error that caused it: an
5//! instance serving last-good content reports [`ServicesProvenance::LastGood`]
6//! with the failure text, so "we are running yesterday's bundle" is visible
7//! rather than inferred from a log line that scrolled past. `fail_closed`
8//! refuses to boot instead.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13pub mod baked;
14mod fetch;
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::time::Duration;
19
20use systemprompt_manifest::profile::{FetchFailurePolicy, Profile};
21use systemprompt_manifest::services::bundle::ServicesBundleState;
22
23use super::cache::BundleCache;
24use super::compose::{BundleMember, compose};
25use super::degraded::DegradedRecord;
26use super::error::{BundleError, BundleResult};
27use systemprompt_manifest::services::root::{ActiveServicesRoot, ServicesProvenance};
28
29use baked::{is_base, stage_baked_base};
30use fetch::{ResolvedSource, SourceContext, resolve_source};
31
32const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
33const CONFIG_RELPATH: &str = "config/config.yaml";
34
35#[derive(Debug, Clone, Copy)]
36pub struct ServicesSourceBootstrap;
37
38impl ServicesSourceBootstrap {
39    pub async fn resolve(
40        profile: &Profile,
41        resolve_secret: impl Fn(&str) -> Option<String> + Send + Sync,
42        core_version: &str,
43    ) -> BundleResult<ActiveServicesRoot> {
44        if profile.services.sources.is_empty() {
45            let path = PathBuf::from(&profile.paths.services);
46            return Ok(ActiveServicesRoot {
47                base: path.clone(),
48                path,
49                provenance: ServicesProvenance::Bundled,
50            });
51        }
52
53        let cache = BundleCache::new(cache_root(profile));
54        match Self::compose_sources(profile, &cache, &resolve_secret, core_version).await {
55            Ok(active) => {
56                if let Err(error) = cache.clear_degraded() {
57                    tracing::warn!(error = %error, "Could not clear the degraded services record");
58                }
59                Ok(active)
60            },
61            Err(e) => {
62                tracing::error!(error = %e, "Services bundle refresh failed");
63                let active = Self::fall_back(profile, &cache, e)?;
64                if let Some(record) = DegradedRecord::for_provenance(&active.provenance)
65                    && let Err(error) = cache.write_degraded(&record)
66                {
67                    tracing::warn!(error = %error, "Could not record the degraded services source");
68                }
69                Ok(active)
70            },
71        }
72    }
73
74    async fn compose_sources(
75        profile: &Profile,
76        cache: &BundleCache,
77        resolve_secret: &(impl Fn(&str) -> Option<String> + Send + Sync),
78        core_version: &str,
79    ) -> BundleResult<ActiveServicesRoot> {
80        let client = reqwest::Client::builder()
81            .redirect(reqwest::redirect::Policy::none())
82            .timeout(HTTP_TIMEOUT)
83            .build()
84            .map_err(|e| BundleError::policy_context("http client", e))?;
85
86        let previous = cache.read_state();
87        let ctx = SourceContext {
88            cache,
89            state: &previous,
90            core_version,
91            client: &client,
92        };
93        let mut resolved: Vec<ResolvedSource> = Vec::new();
94        for source in &profile.services.sources {
95            let auth = source.auth_secret().and_then(resolve_secret);
96            resolved.push(resolve_source(source, &ctx, auth).await?);
97        }
98        // Why: a profile that pins only kits names no base; the tree baked
99        // into the image is the base, and it must be member zero so ownership
100        // and the authz reconcile treat every pinned source as a kit.
101        if !resolved
102            .first()
103            .is_some_and(|r| is_base(&r.signed.manifest))
104        {
105            let base = stage_baked_base(cache, Path::new(&profile.paths.services), core_version)?;
106            resolved.insert(0, base);
107        }
108
109        let members: Vec<BundleMember<'_>> = resolved
110            .iter()
111            .map(|r| BundleMember {
112                name: r.name.clone(),
113                content_hash: r.content_hash.clone(),
114                manifest: &r.signed.manifest,
115            })
116            .collect();
117        let (composed_path, composed_hash) = compose(cache, &members)?;
118        let kits: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
119        let retained = previous.composed_hash == composed_hash;
120        refuse_unloadable(&composed_path, profile, &kits, retained)?;
121        cache.swap_current(&composed_path)?;
122
123        let state = ServicesBundleState {
124            composed_hash: composed_hash.clone(),
125            last_reconciled_hash: previous.last_reconciled_hash.clone(),
126            sources: resolved
127                .iter()
128                .map(|r| (r.name.clone(), r.state.clone()))
129                .collect(),
130        };
131        cache.write_state(&state)?;
132        cache.gc(&composed_hash)?;
133
134        let versions: BTreeMap<String, String> = resolved
135            .iter()
136            .map(|r| (r.name.clone(), r.signed.manifest.version.clone()))
137            .collect();
138        tracing::info!(composed_hash = %composed_hash, sources = resolved.len(), "Services bundles composed");
139
140        Ok(ActiveServicesRoot {
141            path: cache.current_link(),
142            base: PathBuf::from(&profile.paths.services),
143            provenance: ServicesProvenance::Fetched {
144                composed_hash,
145                versions,
146            },
147        })
148    }
149
150    fn fall_back(
151        profile: &Profile,
152        cache: &BundleCache,
153        error: BundleError,
154    ) -> BundleResult<ActiveServicesRoot> {
155        match profile.services.on_fetch_failure {
156            FetchFailurePolicy::FailClosed => Err(BundleError::policy_context(
157                "services.on_fetch_failure is fail_closed",
158                error,
159            )),
160            FetchFailurePolicy::UseLastGood => last_good(profile, cache, &error)
161                .map_or_else(|| bundled_fallback(profile, error), Ok),
162            FetchFailurePolicy::UseBundled => bundled_fallback(profile, error),
163        }
164    }
165}
166
167fn refuse_unloadable(
168    composed: &Path,
169    profile: &Profile,
170    kits: &[&str],
171    retained: bool,
172) -> BundleResult<()> {
173    let Err(error) =
174        crate::ConfigLoader::load_from_path(&composed.join(CONFIG_RELPATH), Some(profile))
175    else {
176        return Ok(());
177    };
178    if !retained && let Err(remove) = std::fs::remove_dir_all(composed) {
179        tracing::warn!(error = %remove, path = %composed.display(), "Could not remove the rejected composition");
180    }
181    Err(BundleError::policy_context(
182        format!(
183            "the composed services tree (sources: {}) does not load, so it was not swapped in",
184            kits.join(", ")
185        ),
186        error,
187    ))
188}
189
190fn last_good(
191    profile: &Profile,
192    cache: &BundleCache,
193    error: &BundleError,
194) -> Option<ActiveServicesRoot> {
195    let current = cache.current_root()?;
196    let state = cache.read_state();
197    if state.composed_hash.is_empty() {
198        return None;
199    }
200    tracing::error!(
201        composed_hash = %state.composed_hash,
202        error = %error,
203        "Serving the last-good services composition after a failed refresh"
204    );
205    Some(ActiveServicesRoot {
206        path: current,
207        base: PathBuf::from(&profile.paths.services),
208        provenance: ServicesProvenance::LastGood {
209            composed_hash: state.composed_hash,
210            error: error.to_string(),
211        },
212    })
213}
214
215fn bundled_fallback(profile: &Profile, error: BundleError) -> BundleResult<ActiveServicesRoot> {
216    let root = PathBuf::from(&profile.paths.services);
217    if !root.join("config/config.yaml").is_file() {
218        return Err(BundleError::policy_context(
219            format!(
220                "no cached bundle and no baked services tree at {}",
221                root.display()
222            ),
223            error,
224        ));
225    }
226    tracing::error!(
227        path = %root.display(),
228        error = %error,
229        "Serving the services tree baked into the image after a failed refresh"
230    );
231    Ok(ActiveServicesRoot {
232        base: root.clone(),
233        path: root,
234        provenance: ServicesProvenance::BundledFallback {
235            error: error.to_string(),
236        },
237    })
238}
239
240#[must_use]
241pub fn cache_root(profile: &Profile) -> PathBuf {
242    profile.services.cache_dir.as_ref().map_or_else(
243        || PathBuf::from(&profile.paths.system).join("services-cache"),
244        PathBuf::from,
245    )
246}