Skip to main content

kernel/manifests/
manifest.rs

1//! The `RuntimeManifest` value model and its validation.
2
3use std::path::PathBuf;
4
5use serde::Deserialize;
6
7use crate::records::{Capability, ExecutionMode, Modality};
8
9use super::provenance::RuntimeProvenance;
10
11/// A manifest that failed validation. Its message is available via `Display`.
12#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
13#[error("{0}")]
14pub struct ManifestValidationError(String);
15
16fn invalid(message: impl Into<String>) -> ManifestValidationError {
17    ManifestValidationError(message.into())
18}
19
20/// How a runtime is auto-detected for a model: a weight-file extension, or a
21/// marker file (optionally containing a string) beside the model.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct ManifestDetect {
24    pub file: Option<String>,
25    pub contains: Option<String>,
26    pub file_extension: Option<String>,
27}
28
29/// The Python environment a runtime needs.
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub struct ManifestEnv {
32    pub manager: String,
33    pub python: String,
34    pub lockfile: String,
35}
36
37/// A long-running sidecar entrypoint.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct ManifestServe {
40    pub entrypoint: String,
41    pub wire_protocol: String,
42}
43
44/// A one-shot command template.
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub struct ManifestInvoke {
47    pub command: String,
48}
49
50/// What the runtime is allowed to touch.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct ManifestPermissions {
53    pub network: bool,
54    pub paths: Vec<String>,
55}
56
57/// A digest-pinned VM image the runtime runs inside.
58#[derive(Debug, Clone, PartialEq, Eq, Hash)]
59pub struct ManifestVm {
60    pub image: String,
61    pub setup: Vec<String>,
62}
63
64/// A validated community runtime manifest.
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub struct RuntimeManifest {
67    pub id: String,
68    pub modalities: Vec<Modality>,
69    pub capabilities: Vec<Capability>,
70    pub execution: ExecutionMode,
71    pub alternatives: Vec<String>,
72    pub detect: Option<ManifestDetect>,
73    pub env: Option<ManifestEnv>,
74    pub serve: Option<ManifestServe>,
75    pub invoke: Option<ManifestInvoke>,
76    pub permissions: ManifestPermissions,
77    pub vm: Option<ManifestVm>,
78    pub directory: Option<PathBuf>,
79    pub provenance: Option<RuntimeProvenance>,
80    pub content_hash: Option<String>,
81}
82
83impl RuntimeManifest {
84    /// Parse and validate a `manifest.toml`, recording `directory` as where it
85    /// was loaded from.
86    pub fn parse(text: &str, directory: Option<PathBuf>) -> Result<Self, ManifestValidationError> {
87        let raw: RawManifest = toml::from_str(text)
88            .map_err(|err| invalid(format!("manifest is not valid TOML: {err}")))?;
89        raw.validate(directory)
90    }
91}
92
93#[derive(Deserialize)]
94struct RawManifest {
95    id: Option<String>,
96    #[serde(default)]
97    modalities: Vec<String>,
98    #[serde(default)]
99    capabilities: Vec<String>,
100    execution: Option<String>,
101    #[serde(default)]
102    alternatives: Vec<String>,
103    detect: Option<RawDetect>,
104    env: Option<RawEnv>,
105    serve: Option<RawServe>,
106    invoke: Option<RawInvoke>,
107    permissions: Option<RawPermissions>,
108    vm: Option<RawVm>,
109}
110
111#[derive(Deserialize)]
112struct RawDetect {
113    file: Option<String>,
114    contains: Option<String>,
115    extension: Option<String>,
116}
117
118#[derive(Deserialize)]
119struct RawEnv {
120    manager: Option<String>,
121    python: Option<String>,
122    lockfile: Option<String>,
123}
124
125#[derive(Deserialize)]
126struct RawServe {
127    entrypoint: Option<String>,
128    protocol: Option<String>,
129}
130
131#[derive(Deserialize)]
132struct RawInvoke {
133    command: Option<String>,
134}
135
136#[derive(Deserialize, Default)]
137struct RawPermissions {
138    network: Option<bool>,
139    paths: Option<Vec<String>>,
140}
141
142#[derive(Deserialize)]
143struct RawVm {
144    image: Option<String>,
145    #[serde(default)]
146    setup: Vec<String>,
147}
148
149impl RawManifest {
150    fn validate(
151        self,
152        directory: Option<PathBuf>,
153    ) -> Result<RuntimeManifest, ManifestValidationError> {
154        let id = self
155            .id
156            .filter(|id| !id.is_empty())
157            .ok_or_else(|| invalid("manifest is missing an id"))?;
158        validate_id(&id)?;
159
160        let modalities: Vec<Modality> = self
161            .modalities
162            .iter()
163            .map(|m| Modality::from(m.as_str()))
164            .collect();
165        let capabilities: Vec<Capability> = self
166            .capabilities
167            .iter()
168            .map(|c| Capability::from(c.as_str()))
169            .collect();
170        if capabilities.is_empty() {
171            return Err(invalid(format!("manifest {id} declares no capabilities")));
172        }
173
174        let execution_raw = self
175            .execution
176            .ok_or_else(|| invalid(format!("manifest {id} is missing an execution mode")))?;
177        let execution = parse_execution(&execution_raw)
178            .ok_or_else(|| invalid(format!("manifest {id} has an unknown execution mode")))?;
179
180        let detect = match self.detect {
181            Some(raw) => {
182                let detect = ManifestDetect {
183                    file: raw.file,
184                    contains: raw.contains,
185                    file_extension: raw.extension,
186                };
187                if detect.file.is_none() && detect.file_extension.is_none() {
188                    return Err(invalid(format!(
189                        "manifest {id} has a detect rule with no file or extension"
190                    )));
191                }
192                Some(detect)
193            }
194            None => None,
195        };
196
197        let env = match self.env {
198            Some(raw) => {
199                let lockfile = raw.lockfile.ok_or_else(|| {
200                    invalid(format!("manifest {id} declares [env] without a lockfile"))
201                })?;
202                Some(ManifestEnv {
203                    manager: raw.manager.unwrap_or_else(|| "uv".to_owned()),
204                    python: raw.python.unwrap_or_else(|| "3.12".to_owned()),
205                    lockfile,
206                })
207            }
208            None => None,
209        };
210
211        let serve = match self.serve {
212            Some(raw) => {
213                let entrypoint = raw.entrypoint.ok_or_else(|| {
214                    invalid(format!(
215                        "manifest {id} declares [serve] without an entrypoint"
216                    ))
217                })?;
218                Some(ManifestServe {
219                    entrypoint,
220                    wire_protocol: raw.protocol.unwrap_or_else(|| "ndjson+frames".to_owned()),
221                })
222            }
223            None => None,
224        };
225
226        let invoke = match self.invoke {
227            Some(raw) => {
228                let command = raw
229                    .command
230                    .filter(|command| !command.is_empty())
231                    .ok_or_else(|| {
232                        invalid(format!("manifest {id} declares [invoke] without a command"))
233                    })?;
234                Some(ManifestInvoke { command })
235            }
236            None => None,
237        };
238
239        if serve.is_some() && invoke.is_some() {
240            return Err(invalid(format!(
241                "manifest {id} declares both [serve] and [invoke]"
242            )));
243        }
244        if serve.is_none() && invoke.is_none() {
245            return Err(invalid(format!(
246                "manifest {id} declares neither [serve] nor [invoke]"
247            )));
248        }
249        if invoke.is_some() && execution == ExecutionMode::Stream {
250            return Err(invalid(
251                "invoke manifests run to completion — declare sync (or job), or use [serve] to stream",
252            ));
253        }
254
255        let serves_job = capabilities.contains(&Capability::image());
256        if (execution == ExecutionMode::Job) != serves_job {
257            return Err(invalid(format!(
258                "manifest {id} execution \"{execution_raw}\" does not match its capabilities"
259            )));
260        }
261
262        let raw_permissions = self.permissions.unwrap_or_default();
263        let permissions = ManifestPermissions {
264            network: raw_permissions.network.unwrap_or(false),
265            paths: raw_permissions
266                .paths
267                .unwrap_or_else(|| vec!["{model}".to_owned(), "{workdir}".to_owned()]),
268        };
269
270        let vm = match self.vm {
271            Some(raw) => {
272                let image = raw.image.filter(|image| !image.is_empty()).ok_or_else(|| {
273                    invalid(format!("manifest {id} declares [vm] without an image"))
274                })?;
275                if !image.contains("@sha256:") {
276                    return Err(invalid(format!(
277                        "manifest {id} [vm] image must be digest-pinned (…@sha256:…) — tags can move"
278                    )));
279                }
280                if serve.is_some() {
281                    return Err(invalid(format!(
282                        "manifest {id} [vm] runtimes support [invoke] only"
283                    )));
284                }
285                if env.is_some() {
286                    return Err(invalid(format!(
287                        "manifest {id} declares both [vm] and [env] — the image and its setup are the environment"
288                    )));
289                }
290                if permissions.network {
291                    return Err(invalid(
292                        "vm runtimes always run offline — remove permissions.network",
293                    ));
294                }
295                Some(ManifestVm {
296                    image,
297                    setup: raw.setup,
298                })
299            }
300            None => None,
301        };
302
303        Ok(RuntimeManifest {
304            id,
305            modalities,
306            capabilities,
307            execution,
308            alternatives: self.alternatives,
309            detect,
310            env,
311            serve,
312            invoke,
313            permissions,
314            vm,
315            directory,
316            provenance: None,
317            content_hash: None,
318        })
319    }
320}
321
322fn parse_execution(raw: &str) -> Option<ExecutionMode> {
323    match raw {
324        "stream" => Some(ExecutionMode::Stream),
325        "job" => Some(ExecutionMode::Job),
326        "sync" => Some(ExecutionMode::Sync),
327        _ => None,
328    }
329}
330
331fn validate_id(id: &str) -> Result<(), ManifestValidationError> {
332    let allowed = |c: char| c.is_ascii() && (c.is_ascii_alphanumeric() || "._:-".contains(c));
333    let has_alnum = id.chars().any(|c| c.is_ascii_alphanumeric());
334    let not_all_dots = id.chars().any(|c| c != '.');
335    if id.chars().all(allowed) && has_alnum && not_all_dots {
336        Ok(())
337    } else {
338        Err(invalid(
339            "manifest id may only contain letters, digits, dots, underscores, colons, and hyphens",
340        ))
341    }
342}