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_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 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}