Skip to main content

dora_core/manifest/
mod.rs

1//! Node manifest (`dora-node.yml`) — the typed, dora-specific description of
2//! a node: contracts, entry point, env/config surface.
3//!
4//! See `docs/plan-node-hub.md` §5 for the format specification. The manifest
5//! lives next to the node's native package manifest (`pyproject.toml` /
6//! `Cargo.toml`) and is copied verbatim into a hub index entry at publish
7//! time, so discovery never needs to fetch source.
8
9pub mod inject;
10pub mod validate;
11
12use std::{collections::BTreeMap, path::Path};
13
14use eyre::Context;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18use crate::types::TypeDef;
19
20/// Conventional file name of a node manifest.
21pub const MANIFEST_FILENAME: &str = "dora-node.yml";
22
23/// The manifest schema version this library reads and writes.
24pub const MANIFEST_API_VERSION: u64 = 1;
25
26/// A node manifest (`dora-node.yml`).
27///
28/// Holds only what native package manifests *cannot*: dora contracts and dora
29/// wiring. Name, version, license, and dependencies are read from
30/// `pyproject.toml`/`Cargo.toml` at publish time where possible.
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(deny_unknown_fields)]
33pub struct NodeManifest {
34    /// Manifest schema version. Currently always `1`.
35    #[serde(rename = "apiVersion")]
36    pub api_version: u64,
37
38    /// Package name. Defaults to `[project].name` / `[package].name` of the
39    /// native manifest at publish time; required for standalone validation.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub name: Option<String>,
42
43    /// Index namespace the package publishes under (a GitHub org or user).
44    pub namespace: String,
45
46    /// One-line description, shown by `dora hub search`/`info`.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub description: Option<String>,
49
50    /// Categories from the fixed list (spec §7.6); drive search facets.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub categories: Vec<Category>,
53
54    /// Free-form search keywords.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub keywords: Vec<String>,
57
58    /// Language runtime of the node.
59    pub runtime: Runtime,
60
61    /// What to run, relative to the node's working dir.
62    ///
63    /// Python: a console script on the managed-env PATH (e.g. `dora-yolo`).
64    /// Rust/C/C++: the build output, e.g. `target/release/dora-yolo`.
65    /// Must be a relative path without `..` components.
66    pub entrypoint: String,
67
68    /// Build command run in the node's working dir when the package is
69    /// installed (e.g. `pip install .`, `cargo build --release`). Arbitrary
70    /// code by design, like any `build:` — `--locked` binds it to the
71    /// reviewed, commit-pinned source (spec §11).
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub build: Option<String>,
74
75    /// Optional platform allowlist, e.g. `[linux-x86_64, macos-aarch64]`.
76    /// Empty means all platforms.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub platforms: Vec<String>,
79
80    /// Supported dora version range (semver requirement, e.g. `>=0.4`).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub dora: Option<String>,
83
84    /// Typed input ports. May be empty (source nodes).
85    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
86    pub inputs: BTreeMap<String, PortDef>,
87
88    /// Typed output ports. May be empty (sink nodes).
89    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90    pub outputs: BTreeMap<String, PortDef>,
91
92    /// Documented + typed configuration surface (environment variables).
93    /// Security-sensitive names (`PATH`, `PYTHONPATH`, `LD_*`, `DYLD_*`) are
94    /// rejected at validation.
95    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96    pub env: BTreeMap<String, EnvVarDef>,
97
98    /// Custom type definitions shipped with the node, keyed by full URN
99    /// (e.g. `acme/lidar/v1/PointCloud`). Must live under the package's
100    /// namespace; `std/` is rejected.
101    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102    pub types: BTreeMap<String, TypeDef>,
103
104    /// Informational hardware/system requirements; surfaced by `dora hub
105    /// info` and at build start, never auto-installed.
106    #[serde(default, skip_serializing_if = "Requirements::is_empty")]
107    pub requirements: Requirements,
108
109    /// Example dataflow snippet shown by `dora hub info` (YAML node list).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub example: Option<String>,
112}
113
114/// Language runtime of a node.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
116#[serde(rename_all = "lowercase")]
117pub enum Runtime {
118    Python,
119    Rust,
120    C,
121    Cpp,
122}
123
124/// Fixed category list (spec §7.6). Extended by index PR + discussion.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[serde(rename_all = "kebab-case")]
127pub enum Category {
128    Sensor,
129    Actuator,
130    Robot,
131    Transform,
132    Filter,
133    MlInference,
134    Llm,
135    Speech,
136    Communication,
137    Recorder,
138    Visualization,
139    Simulator,
140    Debug,
141}
142
143impl Category {
144    /// The kebab-case name as written in manifests and accepted by
145    /// `--category` (matches the serde representation).
146    pub fn as_str(self) -> &'static str {
147        match self {
148            Category::Sensor => "sensor",
149            Category::Actuator => "actuator",
150            Category::Robot => "robot",
151            Category::Transform => "transform",
152            Category::Filter => "filter",
153            Category::MlInference => "ml-inference",
154            Category::Llm => "llm",
155            Category::Speech => "speech",
156            Category::Communication => "communication",
157            Category::Recorder => "recorder",
158            Category::Visualization => "visualization",
159            Category::Simulator => "simulator",
160            Category::Debug => "debug",
161        }
162    }
163}
164
165impl std::fmt::Display for Category {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.write_str(self.as_str())
168    }
169}
170
171/// A typed input or output port.
172#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
173#[serde(deny_unknown_fields)]
174pub struct PortDef {
175    /// Type URN (e.g. `std/media/v1/Image`). Omitted = untyped; validation
176    /// skips the port.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub r#type: Option<String>,
179
180    /// Whether a dataflow must wire this input (defaults to `true`; only
181    /// meaningful on inputs — validation rejects it on outputs).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub required: Option<bool>,
184
185    /// Human-readable description of the port.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub description: Option<String>,
188}
189
190impl PortDef {
191    /// Whether a dataflow must wire this input (defaults to `true`).
192    pub fn is_required(&self) -> bool {
193        self.required.unwrap_or(true)
194    }
195}
196
197/// A documented environment variable.
198#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
199#[serde(deny_unknown_fields)]
200pub struct EnvVarDef {
201    /// Value type: `string` (default), `int`, `float`, or `bool`.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub r#type: Option<EnvVarType>,
204
205    /// Default value used when the variable is not set in the dataflow.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub default: Option<EnvDefault>,
208
209    /// Human-readable description.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub description: Option<String>,
212}
213
214/// A literal env default value.
215///
216/// Deliberately *not* the descriptor's `EnvValue`: that type expands `$VAR`
217/// references against the local environment at deserialization time, which is
218/// right for a dataflow being deployed but wrong for a manifest — manifests
219/// are published verbatim into an index, so defaults must stay literal and
220/// parse identically on every machine.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
222#[serde(untagged)]
223pub enum EnvDefault {
224    Bool(bool),
225    Integer(i64),
226    Float(f64),
227    String(String),
228}
229
230/// Declared value type of an environment variable.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
232#[serde(rename_all = "lowercase")]
233pub enum EnvVarType {
234    String,
235    Int,
236    Float,
237    Bool,
238}
239
240impl EnvVarType {
241    /// The lowercase name as written in manifests.
242    pub fn as_str(self) -> &'static str {
243        match self {
244            EnvVarType::String => "string",
245            EnvVarType::Int => "int",
246            EnvVarType::Float => "float",
247            EnvVarType::Bool => "bool",
248        }
249    }
250}
251
252/// Informational hardware/system requirements (spec §5): documentation
253/// surfaced by `dora hub info`, never auto-installed.
254#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
255#[serde(deny_unknown_fields)]
256pub struct Requirements {
257    /// Hardware requirements, e.g. `[cuda]`. v1 actively probes only `cuda`.
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub hardware: Vec<String>,
260
261    /// System package requirements, keyed by package manager or platform.
262    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
263    pub system: BTreeMap<String, String>,
264}
265
266impl Requirements {
267    fn is_empty(&self) -> bool {
268        self.hardware.is_empty() && self.system.is_empty()
269    }
270}
271
272/// Upper bound on manifest size. Manifests are small by construction; the cap
273/// bounds memory for the YAML parse of untrusted index content.
274pub const MAX_MANIFEST_SIZE: usize = 1024 * 1024;
275
276impl NodeManifest {
277    /// Parse a manifest from YAML.
278    pub fn parse(yaml: &str) -> eyre::Result<Self> {
279        if yaml.len() > MAX_MANIFEST_SIZE {
280            eyre::bail!(
281                "node manifest too large ({} bytes, max {MAX_MANIFEST_SIZE})",
282                yaml.len()
283            );
284        }
285        serde_yaml::from_str(yaml).context("failed to parse node manifest")
286    }
287
288    /// Read and parse a manifest file.
289    pub fn read(path: &Path) -> eyre::Result<Self> {
290        use std::io::Read as _;
291        // bound the read itself (not just the parse) — a size check on
292        // metadata alone would miss FIFOs/devices and growing files
293        let file = std::fs::File::open(path)
294            .with_context(|| format!("failed to read node manifest at `{}`", path.display()))?;
295        let mut content = String::new();
296        file.take(MAX_MANIFEST_SIZE as u64 + 1)
297            .read_to_string(&mut content)
298            .with_context(|| format!("failed to read node manifest at `{}`", path.display()))?;
299        Self::parse(&content)
300            .with_context(|| format!("invalid node manifest at `{}`", path.display()))
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// The full example manifest from spec §5.
309    const SPEC_EXAMPLE: &str = r#"
310apiVersion: 1
311name: dora-yolo
312namespace: dora-rs
313description: YOLO object detection on camera frames
314categories: [ml-inference]
315keywords: [vision, detection, yolo]
316
317runtime: python
318entrypoint: dora-yolo
319platforms: []
320dora: ">=0.4"
321
322inputs:
323  image:
324    type: std/media/v1/Image
325    required: true
326    description: BGR frame to run detection on
327outputs:
328  bbox:
329    type: std/vision/v1/BBox2D
330    description: detected bounding boxes
331
332env:
333  MODEL:
334    default: yolov8n.pt
335    description: model weights file or hub id
336  CONFIDENCE:
337    type: float
338    default: 0.4
339
340requirements:
341  hardware: []
342  system: {}
343
344example: |
345  - id: detector
346    hub: dora-yolo@^0.5
347    inputs:
348      image: camera/image
349    outputs:
350      - bbox
351"#;
352
353    #[test]
354    fn parses_spec_example() {
355        let m = NodeManifest::parse(SPEC_EXAMPLE).unwrap();
356        assert_eq!(m.api_version, 1);
357        assert_eq!(m.name.as_deref(), Some("dora-yolo"));
358        assert_eq!(m.namespace, "dora-rs");
359        assert_eq!(m.runtime, Runtime::Python);
360        assert_eq!(m.entrypoint, "dora-yolo");
361        assert_eq!(m.categories, vec![Category::MlInference]);
362        assert_eq!(
363            m.inputs["image"].r#type.as_deref(),
364            Some("std/media/v1/Image")
365        );
366        assert!(m.inputs["image"].is_required());
367        assert_eq!(
368            m.outputs["bbox"].r#type.as_deref(),
369            Some("std/vision/v1/BBox2D")
370        );
371        assert_eq!(m.env["CONFIDENCE"].r#type, Some(EnvVarType::Float));
372        assert!(m.example.is_some());
373    }
374
375    #[test]
376    fn ports_may_be_empty() {
377        // sinks have no outputs; sources no inputs (spec D9)
378        let m = NodeManifest::parse(
379            r#"
380apiVersion: 1
381name: dora-recorder
382namespace: dora-rs
383runtime: rust
384entrypoint: target/release/dora-recorder
385inputs:
386  data:
387    type: std/core/v1/Bytes
388"#,
389        )
390        .unwrap();
391        assert!(m.outputs.is_empty());
392        assert_eq!(m.inputs.len(), 1);
393    }
394
395    #[test]
396    fn unknown_fields_rejected() {
397        let err = NodeManifest::parse(
398            r#"
399apiVersion: 1
400name: x
401namespace: y
402runtime: python
403entrypoint: x
404no_such_field: true
405"#,
406        )
407        .unwrap_err();
408        assert!(format!("{err:#}").contains("no_such_field"), "{err:#}");
409    }
410
411    #[test]
412    fn env_defaults_stay_literal() {
413        // EnvDefault must NOT expand `$VAR` against the local environment —
414        // manifests are published verbatim and must parse identically on
415        // every machine (the descriptor's EnvValue would expand here)
416        let m = NodeManifest::parse(
417            r#"
418apiVersion: 1
419name: x
420namespace: y
421runtime: python
422entrypoint: x
423env:
424  MODEL_PATH:
425    default: $HOME/weights.pt
426"#,
427        )
428        .unwrap();
429        assert_eq!(
430            m.env["MODEL_PATH"].default,
431            Some(EnvDefault::String("$HOME/weights.pt".into()))
432        );
433    }
434
435    #[test]
436    fn checked_in_schema_is_current() {
437        let schema = schemars::schema_for!(NodeManifest);
438        let expected = serde_json::to_value(&schema).unwrap();
439        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("dora-node-schema.json");
440        let on_disk: serde_json::Value =
441            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
442        assert_eq!(
443            on_disk, expected,
444            "dora-node-schema.json is stale — run `cargo run -p dora-core --bin generate_schema`"
445        );
446    }
447
448    #[test]
449    fn rejects_oversized_manifest() {
450        let huge = format!("apiVersion: 1\n# {}", "x".repeat(MAX_MANIFEST_SIZE));
451        let err = NodeManifest::parse(&huge).unwrap_err();
452        assert!(format!("{err:#}").contains("too large"), "{err:#}");
453    }
454
455    #[test]
456    fn roundtrips_through_serde() {
457        let m = NodeManifest::parse(SPEC_EXAMPLE).unwrap();
458        let yaml = serde_yaml::to_string(&m).unwrap();
459        let again = NodeManifest::parse(&yaml).unwrap();
460        assert_eq!(again.name, m.name);
461        assert_eq!(again.inputs.len(), m.inputs.len());
462        assert_eq!(again.env.len(), m.env.len());
463    }
464}