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, swap, record. Every other path
4//! is a named fallback carrying the error that caused it: an instance serving
5//! last-good content reports [`ServicesProvenance::LastGood`] with the failure
6//! text, so "we are running yesterday's bundle" is visible rather than
7//! inferred from a log line that scrolled past. `fail_closed` refuses to boot
8//! 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::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::error::{BundleError, BundleResult};
26use crate::services_root::{ActiveServicesRoot, ServicesProvenance, ServicesRootBootstrap};
27
28use baked::{is_base, stage_baked_base};
29use fetch::{ResolvedSource, SourceContext, resolve_source};
30
31const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
32
33#[derive(Debug, Clone, Copy)]
34pub struct ServicesSourceBootstrap;
35
36impl ServicesSourceBootstrap {
37    pub async fn try_run(
38        profile: &Profile,
39        resolve_secret: impl Fn(&str) -> Option<String> + Send + Sync,
40        core_version: &str,
41    ) -> BundleResult<&'static ActiveServicesRoot> {
42        if let Some(active) = ServicesRootBootstrap::get() {
43            return Ok(active);
44        }
45        Self::resolve(profile, resolve_secret, core_version)
46            .await
47            .map(ServicesRootBootstrap::install)
48    }
49
50    pub async fn resolve(
51        profile: &Profile,
52        resolve_secret: impl Fn(&str) -> Option<String> + Send + Sync,
53        core_version: &str,
54    ) -> BundleResult<ActiveServicesRoot> {
55        if profile.services.sources.is_empty() {
56            let path = PathBuf::from(&profile.paths.services);
57            return Ok(ActiveServicesRoot {
58                base: path.clone(),
59                path,
60                provenance: ServicesProvenance::Bundled,
61            });
62        }
63
64        let cache = BundleCache::new(cache_root(profile));
65        match Self::compose_sources(profile, &cache, &resolve_secret, core_version).await {
66            Ok(active) => Ok(active),
67            Err(e) => {
68                tracing::error!(error = %e, "Services bundle refresh failed");
69                Self::fall_back(profile, &cache, e)
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(
106                cache,
107                std::path::Path::new(&profile.paths.services),
108                core_version,
109            )?;
110            resolved.insert(0, base);
111        }
112
113        let members: Vec<BundleMember<'_>> = resolved
114            .iter()
115            .map(|r| BundleMember {
116                name: r.name.clone(),
117                content_hash: r.content_hash.clone(),
118                manifest: &r.signed.manifest,
119            })
120            .collect();
121        let (composed_path, composed_hash) = compose(cache, &members)?;
122        cache.swap_current(&composed_path)?;
123
124        let state = ServicesBundleState {
125            composed_hash: composed_hash.clone(),
126            last_reconciled_hash: previous.last_reconciled_hash.clone(),
127            sources: resolved
128                .iter()
129                .map(|r| (r.name.clone(), r.state.clone()))
130                .collect(),
131        };
132        cache.write_state(&state)?;
133        cache.gc(&composed_hash)?;
134
135        let versions: BTreeMap<String, String> = resolved
136            .iter()
137            .map(|r| (r.name.clone(), r.signed.manifest.version.clone()))
138            .collect();
139        tracing::info!(composed_hash = %composed_hash, sources = resolved.len(), "Services bundles composed");
140
141        Ok(ActiveServicesRoot {
142            path: cache.current_link(),
143            base: PathBuf::from(&profile.paths.services),
144            provenance: ServicesProvenance::Fetched {
145                composed_hash,
146                versions,
147            },
148        })
149    }
150
151    fn fall_back(
152        profile: &Profile,
153        cache: &BundleCache,
154        error: BundleError,
155    ) -> BundleResult<ActiveServicesRoot> {
156        match profile.services.on_fetch_failure {
157            FetchFailurePolicy::FailClosed => Err(BundleError::policy_context(
158                "services.on_fetch_failure is fail_closed",
159                error,
160            )),
161            FetchFailurePolicy::UseLastGood => last_good(profile, cache, &error)
162                .map_or_else(|| bundled_fallback(profile, error), Ok),
163            FetchFailurePolicy::UseBundled => bundled_fallback(profile, error),
164        }
165    }
166}
167
168fn last_good(
169    profile: &Profile,
170    cache: &BundleCache,
171    error: &BundleError,
172) -> Option<ActiveServicesRoot> {
173    let current = cache.current_root()?;
174    let state = cache.read_state();
175    if state.composed_hash.is_empty() {
176        return None;
177    }
178    tracing::error!(
179        composed_hash = %state.composed_hash,
180        error = %error,
181        "Serving the last-good services composition after a failed refresh"
182    );
183    Some(ActiveServicesRoot {
184        path: current,
185        base: PathBuf::from(&profile.paths.services),
186        provenance: ServicesProvenance::LastGood {
187            composed_hash: state.composed_hash,
188            error: error.to_string(),
189        },
190    })
191}
192
193fn bundled_fallback(profile: &Profile, error: BundleError) -> BundleResult<ActiveServicesRoot> {
194    let root = PathBuf::from(&profile.paths.services);
195    if !root.join("config/config.yaml").is_file() {
196        return Err(BundleError::policy_context(
197            format!(
198                "no cached bundle and no baked services tree at {}",
199                root.display()
200            ),
201            error,
202        ));
203    }
204    tracing::error!(
205        path = %root.display(),
206        error = %error,
207        "Serving the services tree baked into the image after a failed refresh"
208    );
209    Ok(ActiveServicesRoot {
210        base: root.clone(),
211        path: root,
212        provenance: ServicesProvenance::BundledFallback {
213            error: error.to_string(),
214        },
215    })
216}
217
218#[must_use]
219pub fn cache_root(profile: &Profile) -> PathBuf {
220    profile.services.cache_dir.as_ref().map_or_else(
221        || PathBuf::from(&profile.paths.system).join("services-cache"),
222        PathBuf::from,
223    )
224}