1pub 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
20pub const MANIFEST_FILENAME: &str = "dora-node.yml";
22
23pub const MANIFEST_API_VERSION: u64 = 1;
25
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(deny_unknown_fields)]
33pub struct NodeManifest {
34 #[serde(rename = "apiVersion")]
36 pub api_version: u64,
37
38 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub name: Option<String>,
42
43 pub namespace: String,
45
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub description: Option<String>,
49
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub categories: Vec<Category>,
53
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub keywords: Vec<String>,
57
58 pub runtime: Runtime,
60
61 pub entrypoint: String,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub build: Option<String>,
74
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub platforms: Vec<String>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub dora: Option<String>,
83
84 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
86 pub inputs: BTreeMap<String, PortDef>,
87
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90 pub outputs: BTreeMap<String, PortDef>,
91
92 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96 pub env: BTreeMap<String, EnvVarDef>,
97
98 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102 pub types: BTreeMap<String, TypeDef>,
103
104 #[serde(default, skip_serializing_if = "Requirements::is_empty")]
107 pub requirements: Requirements,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub example: Option<String>,
112}
113
114#[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#[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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
173#[serde(deny_unknown_fields)]
174pub struct PortDef {
175 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub r#type: Option<String>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub required: Option<bool>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub description: Option<String>,
188}
189
190impl PortDef {
191 pub fn is_required(&self) -> bool {
193 self.required.unwrap_or(true)
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
199#[serde(deny_unknown_fields)]
200pub struct EnvVarDef {
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub r#type: Option<EnvVarType>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub default: Option<EnvDefault>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub description: Option<String>,
212}
213
214#[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#[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 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#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
255#[serde(deny_unknown_fields)]
256pub struct Requirements {
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
259 pub hardware: Vec<String>,
260
261 #[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
272pub const MAX_MANIFEST_SIZE: usize = 1024 * 1024;
275
276impl NodeManifest {
277 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 pub fn read(path: &Path) -> eyre::Result<Self> {
290 use std::io::Read as _;
291 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 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 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 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}