Skip to main content

systemprompt_loader/bundle/bootstrap/
baked.rs

1//! The baked `services/` tree as the base member of a composition.
2//!
3//! A profile that pins only marketplace kits names no base: the base is the
4//! tree shipped in the image at `paths.services`. It enters the composition
5//! exactly like a fetched bundle — staged under the cache as `base` at its
6//! content hash with a synthesised, unsigned `bundle.json` — so composition,
7//! ownership checks and the authz reconcile see one uniform list of members
8//! with the base first, and the same tree hash `/admin/sync` computes.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use std::fs;
14use std::path::Path;
15
16use chrono::Utc;
17use systemprompt_models::services::bundle::{
18    BUNDLE_ALLOWED_DIRS, BUNDLE_MANIFEST_FILE, BundleSourceInfo, BundleSourceState,
19    ServicesBundleManifest, SignedBundleManifest,
20};
21
22use super::fetch::ResolvedSource;
23use crate::bundle::cache::{BundleCache, discard_staging};
24use crate::bundle::compose::copy_tree;
25use crate::bundle::error::{BundleError, BundleResult};
26use crate::bundle::pack::build_manifest;
27
28pub const BASE_SOURCE_NAME: &str = "base";
29const BAKED_VERSION: &str = "baked";
30
31#[must_use]
32pub fn is_base(manifest: &ServicesBundleManifest) -> bool {
33    manifest.owns.dirs.iter().any(|d| d == "config")
34}
35
36pub(super) fn stage_baked_base(
37    cache: &BundleCache,
38    services_root: &Path,
39    core_version: &str,
40) -> BundleResult<ResolvedSource> {
41    if !services_root.join("config/config.yaml").is_file() {
42        return Err(BundleError::policy(format!(
43            "no pinned source is a base and no baked services tree at {}",
44            services_root.display()
45        )));
46    }
47    let manifest = build_manifest(
48        services_root,
49        BAKED_VERSION,
50        &format!(">={core_version}"),
51        BundleSourceInfo::default(),
52    )?;
53    let content_hash = manifest.content_hash.clone();
54    let signed = SignedBundleManifest {
55        manifest,
56        signature: None,
57    };
58
59    let target = cache.bundle_dir(BASE_SOURCE_NAME, &content_hash);
60    if !target.is_dir() {
61        cache.prepare()?;
62        let staging = cache.bundle_dir(
63            BASE_SOURCE_NAME,
64            &format!("{content_hash}.tmp-{}", std::process::id()),
65        );
66        if staging.exists() {
67            fs::remove_dir_all(&staging)?;
68        }
69        fs::create_dir_all(&staging)?;
70        for dir in BUNDLE_ALLOWED_DIRS {
71            let from = services_root.join(dir);
72            if from.is_dir() {
73                let to = staging.join(dir);
74                fs::create_dir_all(&to)?;
75                copy_tree(&from, &to, false)?;
76            }
77        }
78        let raw = serde_json::to_vec_pretty(&signed)
79            .map_err(|e| BundleError::policy(format!("baked base manifest: {e}")))?;
80        fs::write(staging.join(BUNDLE_MANIFEST_FILE), raw)?;
81        if let Err(e) = fs::rename(&staging, &target) {
82            discard_staging(&staging);
83            if !target.is_dir() {
84                return Err(BundleError::extract(&target, e));
85            }
86        }
87    }
88
89    Ok(ResolvedSource {
90        name: BASE_SOURCE_NAME.to_owned(),
91        content_hash: content_hash.clone(),
92        signed,
93        state: BundleSourceState {
94            digest: format!("tree:{content_hash}"),
95            version: BAKED_VERSION.to_owned(),
96            content_hash,
97            fetched_at: Utc::now(),
98        },
99    })
100}