systemprompt_loader/bundle/bootstrap/
mod.rs1pub mod baked;
14mod fetch;
15
16use std::collections::BTreeMap;
17use std::path::PathBuf;
18use std::time::Duration;
19
20use systemprompt_models::profile::{FetchFailurePolicy, Profile};
21use systemprompt_models::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 return Ok(ActiveServicesRoot {
57 path: PathBuf::from(&profile.paths.services),
58 provenance: ServicesProvenance::Bundled,
59 });
60 }
61
62 let cache = BundleCache::new(cache_root(profile));
63 match Self::compose_sources(profile, &cache, &resolve_secret, core_version).await {
64 Ok(active) => Ok(active),
65 Err(e) => {
66 tracing::error!(error = %e, "Services bundle refresh failed");
67 Self::fall_back(profile, &cache, &e)
68 },
69 }
70 }
71
72 async fn compose_sources(
73 profile: &Profile,
74 cache: &BundleCache,
75 resolve_secret: &(impl Fn(&str) -> Option<String> + Send + Sync),
76 core_version: &str,
77 ) -> BundleResult<ActiveServicesRoot> {
78 let client = reqwest::Client::builder()
79 .redirect(reqwest::redirect::Policy::none())
80 .timeout(HTTP_TIMEOUT)
81 .build()
82 .map_err(|e| BundleError::policy(format!("http client: {e}")))?;
83
84 let previous = cache.read_state();
85 let ctx = SourceContext {
86 cache,
87 state: &previous,
88 core_version,
89 client: &client,
90 };
91 let mut resolved: Vec<ResolvedSource> = Vec::new();
92 for source in &profile.services.sources {
93 let auth = source.auth_secret().and_then(resolve_secret);
94 resolved.push(resolve_source(source, &ctx, auth).await?);
95 }
96 if !resolved
100 .first()
101 .is_some_and(|r| is_base(&r.signed.manifest))
102 {
103 let base = stage_baked_base(
104 cache,
105 std::path::Path::new(&profile.paths.services),
106 core_version,
107 )?;
108 resolved.insert(0, base);
109 }
110
111 let members: Vec<BundleMember<'_>> = resolved
112 .iter()
113 .map(|r| BundleMember {
114 name: r.name.clone(),
115 content_hash: r.content_hash.clone(),
116 manifest: &r.signed.manifest,
117 })
118 .collect();
119 let (composed_path, composed_hash) = compose(cache, &members)?;
120 cache.swap_current(&composed_path)?;
121
122 let state = ServicesBundleState {
123 composed_hash: composed_hash.clone(),
124 last_reconciled_hash: previous.last_reconciled_hash.clone(),
125 sources: resolved
126 .iter()
127 .map(|r| (r.name.clone(), r.state.clone()))
128 .collect(),
129 };
130 cache.write_state(&state)?;
131 cache.gc(&composed_hash)?;
132
133 let versions: BTreeMap<String, String> = resolved
134 .iter()
135 .map(|r| (r.name.clone(), r.signed.manifest.version.clone()))
136 .collect();
137 tracing::info!(composed_hash = %composed_hash, sources = resolved.len(), "Services bundles composed");
138
139 Ok(ActiveServicesRoot {
140 path: cache.current_link(),
141 provenance: ServicesProvenance::Fetched {
142 composed_hash,
143 versions,
144 },
145 })
146 }
147
148 fn fall_back(
149 profile: &Profile,
150 cache: &BundleCache,
151 error: &BundleError,
152 ) -> BundleResult<ActiveServicesRoot> {
153 match profile.services.on_fetch_failure {
154 FetchFailurePolicy::FailClosed => Err(BundleError::policy(format!(
155 "services.on_fetch_failure is fail_closed: {error}"
156 ))),
157 FetchFailurePolicy::UseLastGood => {
158 last_good(cache, error).map_or_else(|| bundled_fallback(profile, error), Ok)
159 },
160 FetchFailurePolicy::UseBundled => bundled_fallback(profile, error),
161 }
162 }
163}
164
165fn last_good(cache: &BundleCache, error: &BundleError) -> Option<ActiveServicesRoot> {
166 let current = cache.current_root()?;
167 let state = cache.read_state();
168 if state.composed_hash.is_empty() {
169 return None;
170 }
171 tracing::error!(
172 composed_hash = %state.composed_hash,
173 error = %error,
174 "Serving the last-good services composition after a failed refresh"
175 );
176 Some(ActiveServicesRoot {
177 path: current,
178 provenance: ServicesProvenance::LastGood {
179 composed_hash: state.composed_hash,
180 error: error.to_string(),
181 },
182 })
183}
184
185fn bundled_fallback(profile: &Profile, error: &BundleError) -> BundleResult<ActiveServicesRoot> {
186 let root = PathBuf::from(&profile.paths.services);
187 if !root.join("config/config.yaml").is_file() {
188 return Err(BundleError::policy(format!(
189 "no cached bundle and no baked services tree at {}: {error}",
190 root.display()
191 )));
192 }
193 tracing::error!(
194 path = %root.display(),
195 error = %error,
196 "Serving the services tree baked into the image after a failed refresh"
197 );
198 Ok(ActiveServicesRoot {
199 path: root,
200 provenance: ServicesProvenance::BundledFallback {
201 error: error.to_string(),
202 },
203 })
204}
205
206#[must_use]
207pub fn cache_root(profile: &Profile) -> PathBuf {
208 profile.services.cache_dir.as_ref().map_or_else(
209 || PathBuf::from(&profile.paths.system).join("services-cache"),
210 PathBuf::from,
211 )
212}