Skip to main content

cubecl_environment/bundle/
manifest.rs

1use alloc::string::{String, ToString};
2use alloc::vec::Vec;
3
4#[cfg(native_cache)]
5use crate::persistence::Database;
6
7/// The `meta` key the manifest is stored under.
8#[cfg(native_cache)]
9const MANIFEST_KEY: &str = "manifest";
10
11/// The manifest schema version this build reads and writes.
12pub const MANIFEST_SCHEMA: u32 = 1;
13
14/// Error opening or creating a bundle.
15#[derive(Debug)]
16pub enum BundleError {
17    /// The bundle file couldn't be read or written.
18    #[cfg(native_cache)]
19    Io(std::io::Error),
20    /// The database couldn't be opened or queried.
21    #[cfg(native_cache)]
22    Database(rusqlite::Error),
23    /// The file opened but carries no manifest, so it isn't a bundle.
24    NotABundle,
25    /// The manifest is not valid for the expected schema.
26    InvalidManifest(String),
27    /// The manifest declares a schema this build doesn't understand.
28    UnsupportedSchema(u32),
29    /// The bundle exceeds what the format can address.
30    TooLarge,
31    /// The blob isn't a readable flat bundle.
32    Flat(super::EmbeddedBundleError),
33}
34
35impl core::fmt::Display for BundleError {
36    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
37        match self {
38            #[cfg(native_cache)]
39            BundleError::Io(err) => write!(f, "bundle io error: {err}"),
40            #[cfg(native_cache)]
41            BundleError::Database(err) => write!(f, "bundle database error: {err}"),
42            BundleError::NotABundle => write!(f, "the file carries no bundle manifest"),
43            BundleError::InvalidManifest(err) => write!(f, "invalid bundle manifest: {err}"),
44            BundleError::UnsupportedSchema(schema) => {
45                write!(
46                    f,
47                    "unsupported bundle schema {schema} (this build supports {MANIFEST_SCHEMA})"
48                )
49            }
50            BundleError::TooLarge => write!(
51                f,
52                "the flat bundle format addresses at most {} bytes; export fewer namespaces",
53                u32::MAX
54            ),
55            // Already a complete sentence, and prefixing it would read as two
56            // diagnoses of the same failure.
57            BundleError::Flat(err) => write!(f, "{err}"),
58        }
59    }
60}
61
62impl core::error::Error for BundleError {}
63
64impl From<super::EmbeddedBundleError> for BundleError {
65    fn from(err: super::EmbeddedBundleError) -> Self {
66        Self::Flat(err)
67    }
68}
69
70#[cfg(native_cache)]
71impl From<std::io::Error> for BundleError {
72    fn from(err: std::io::Error) -> Self {
73        Self::Io(err)
74    }
75}
76
77#[cfg(native_cache)]
78impl From<rusqlite::Error> for BundleError {
79    fn from(err: rusqlite::Error) -> Self {
80        Self::Database(err)
81    }
82}
83
84/// The manifest of an environment bundle, stored as a row of the bundle
85/// database in [`BundleFormat::Sqlite`](super::BundleFormat::Sqlite) and as the
86/// metadata blob in [`BundleFormat::Flat`](super::BundleFormat::Flat).
87#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
88pub struct BundleManifest {
89    /// Manifest schema version.
90    pub schema: u32,
91    /// Human-chosen bundle name, e.g. "H100 Linux".
92    pub name: String,
93    /// The cubecl version the bundle was exported with. Entries are only
94    /// visible to the same version, exactly like local caches.
95    pub cubecl_version: String,
96    /// Creation time as seconds since the unix epoch, informational.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub created_unix_secs: Option<u64>,
99    /// The environments the bundle was captured on, informational in v1.
100    #[serde(default, rename = "environments")]
101    pub environments: Vec<EnvironmentInfo>,
102}
103
104/// Description of one environment a bundle was captured on. Informational:
105/// correctness never depends on these fields.
106#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
107pub struct EnvironmentInfo {
108    /// Short machine-friendly label, e.g. "h100-linux".
109    #[serde(default)]
110    pub label: String,
111    /// Operating system, e.g. "linux".
112    #[serde(default)]
113    pub os: String,
114    /// CPU architecture, e.g. `x86_64`.
115    #[serde(default)]
116    pub arch: String,
117    /// Free-form device fingerprints, e.g. `cuda-0: NVIDIA H100 PCIe (sm_90)`.
118    #[serde(default)]
119    pub devices: Vec<String>,
120}
121
122impl BundleManifest {
123    /// Parses and validates a serialized manifest, the JSON both formats
124    /// store.
125    ///
126    /// This is the guard [`SqliteBundle`](super::SqliteBundle) applies at open,
127    /// available to the flat format too through
128    /// [`EmbeddedBundle::manifest`](super::EmbeddedBundle::manifest).
129    pub fn parse(content: &[u8]) -> Result<Self, BundleError> {
130        if content.is_empty() {
131            return Err(BundleError::NotABundle);
132        }
133
134        let manifest: Self = serde_json::from_slice(content)
135            .map_err(|err| BundleError::InvalidManifest(err.to_string()))?;
136
137        if manifest.schema != MANIFEST_SCHEMA {
138            return Err(BundleError::UnsupportedSchema(manifest.schema));
139        }
140
141        Ok(manifest)
142    }
143
144    /// Warns when the bundle was built for another cubecl version.
145    ///
146    /// Not an error: the bundle still installs, its entries are simply never
147    /// looked up, because the cubecl version is part of every namespace. A
148    /// clear warning beats silent emptiness.
149    pub fn warn_on_version_mismatch(&self) {
150        if self.cubecl_version != env!("CARGO_PKG_VERSION") {
151            log::warn!(
152                "Bundle '{}' was built for cubecl {}, running {}; its entries will be ignored.",
153                self.name,
154                self.cubecl_version,
155                env!("CARGO_PKG_VERSION"),
156            );
157        }
158    }
159
160    /// Reads and validates the manifest of a bundle database.
161    #[cfg(native_cache)]
162    pub fn read(database: &Database) -> Result<Self, BundleError> {
163        let content = read_meta(database, MANIFEST_KEY)?.ok_or(BundleError::NotABundle)?;
164
165        Self::parse(content.as_bytes())
166    }
167
168    /// Writes the manifest into a bundle database.
169    #[cfg(native_cache)]
170    pub fn write(&self, database: &Database) -> Result<(), BundleError> {
171        let content = serde_json::to_string_pretty(self)
172            .map_err(|err| BundleError::InvalidManifest(err.to_string()))?;
173
174        database.with_connection(|conn| {
175            crate::persistence::sqlite::meta_set(conn, MANIFEST_KEY, &content)
176        })?;
177
178        Ok(())
179    }
180}
181
182#[cfg(native_cache)]
183fn read_meta(database: &Database, key: &str) -> Result<Option<String>, BundleError> {
184    let content = database.with_connection(|conn| crate::persistence::sqlite::meta_get(conn, key));
185
186    match content {
187        Ok(content) => Ok(content),
188        // "Not a bundle" is a narrow condition: the file doesn't parse as a
189        // SQLite database at all, or it does but carries no `meta` table.
190        // Anything else — a locked, corrupt, or unreadable database — is a
191        // real failure and must surface as such rather than as the misleading
192        // "the file carries no bundle manifest".
193        Err(err) if is_missing_meta(&err) => Err(BundleError::NotABundle),
194        Err(err) => Err(BundleError::Database(err)),
195    }
196}
197
198/// Whether `err` means "this file is not a cubecl bundle" rather than a genuine
199/// database failure. A non-database file fails with `NotADatabase`; a database
200/// without the table fails with a "no such table" message, which rusqlite
201/// surfaces as either a `SqliteFailure` or a `SqlInputError` depending on where
202/// the statement is rejected.
203#[cfg(native_cache)]
204fn is_missing_meta(err: &rusqlite::Error) -> bool {
205    match err {
206        rusqlite::Error::SqliteFailure(err, _) if err.code == rusqlite::ErrorCode::NotADatabase => {
207            true
208        }
209        rusqlite::Error::SqliteFailure(_, Some(message))
210        | rusqlite::Error::SqlInputError { msg: message, .. } => message.contains("no such table"),
211        _ => false,
212    }
213}