systemprompt_loader/bundle/bootstrap/
mod.rs1pub 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 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}