1use dora_message::{
2 config::InputMapping,
3 descriptor::EnvValue,
4 id::{DataId, NodeId, OperatorId},
5};
6use eyre::{Context, OptionExt, Result, bail};
7use std::{
8 collections::{BTreeMap, HashMap},
9 env::consts::EXE_EXTENSION,
10 path::{Component, Path, PathBuf},
11 process::Command,
12};
13
14pub use dora_message::descriptor::{
16 CoreNodeKind, CustomNode, DYNAMIC_SOURCE, Descriptor, Node, OperatorConfig, OperatorDefinition,
17 OperatorSource, PythonSource, RUNTIME_PYTHON, RUNTIME_SHARED_LIBRARY, RUNTIME_WASM,
18 ResolvedNode, RmwZenohCompatibility, Ros2BridgeConfig, Ros2Direction, Ros2QosConfig,
19 Ros2TopicConfig, Ros2TransportConfig, RuntimeNode, SHELL_SOURCE, SingleOperatorDefinition,
20};
21pub use validate::ResolvedNodeExt;
22pub use visualize::collect_dora_timers;
23
24mod classify;
25pub(crate) fn normalize_path(path: &Path) -> PathBuf {
32 let mut out = PathBuf::new();
33 for component in path.components() {
34 match component {
35 Component::CurDir => {}
36 Component::ParentDir => {
37 out.pop();
38 }
39 other => out.push(other),
40 }
41 }
42 out
43}
44
45mod expand;
46pub mod validate;
47mod visualize;
48
49pub use expand::{
50 ExpandedDescriptor, ModuleBoundaries, check_module_file, expand_modules,
51 expand_modules_with_boundaries,
52};
53
54pub trait DescriptorExt {
55 fn resolve_aliases_and_set_defaults(&self) -> eyre::Result<BTreeMap<NodeId, ResolvedNode>>;
56 fn visualize_as_mermaid_with_boundaries(
57 &self,
58 boundaries: &ModuleBoundaries,
59 ) -> eyre::Result<String>;
60 fn apply_exit_when_nodes_finish(&mut self, over: Option<bool>);
73
74 fn blocking_read(path: &Path) -> eyre::Result<Descriptor>;
75 fn parse(buf: Vec<u8>) -> eyre::Result<Descriptor>;
76 fn check(&self, working_dir: &Path) -> eyre::Result<()>;
77 fn expand(&self, working_dir: &Path) -> eyre::Result<Descriptor>;
83 fn expand_with_boundaries(
86 &self,
87 working_dir: &Path,
88 ) -> eyre::Result<(Descriptor, ModuleBoundaries)>;
89}
90
91pub const SINGLE_OPERATOR_DEFAULT_ID: &str = "op";
92
93fn prefix_output_with_operator_id(op_name: &OperatorId, output: &DataId) -> eyre::Result<DataId> {
104 format!("{op_name}/{output}")
105 .parse::<DataId>()
106 .map_err(|e| {
107 eyre::eyre!(
108 "operator id `{op_name}` produces an invalid output id `{op_name}/{output}`: {e}"
109 )
110 })
111}
112
113pub fn resolve_aliases_and_set_defaults_in_topology(
134 desc: &Descriptor,
135 topology_nodes: &[Node],
136) -> eyre::Result<BTreeMap<NodeId, ResolvedNode>> {
137 let default_op_id = OperatorId::from(SINGLE_OPERATOR_DEFAULT_ID.to_string());
138
139 let single_operator_nodes: HashMap<_, _> = topology_nodes
140 .iter()
141 .chain(desc.nodes.iter())
142 .filter_map(|n| {
143 n.operator
144 .as_ref()
145 .map(|op| (&n.id, op.id.as_ref().unwrap_or(&default_op_id)))
146 })
147 .collect();
148
149 let mut resolved = BTreeMap::new();
150 for mut node in desc.nodes.clone() {
151 let node_class = classify::classify(&node)?;
153
154 if node.ros2.is_some() {
156 for input in node.inputs.values_mut() {
157 if let InputMapping::User(m) = &mut input.mapping
158 && let Some(op_name) = single_operator_nodes.get(&m.source).copied()
159 {
160 m.output = prefix_output_with_operator_id(op_name, &m.output)?;
161 }
162 }
163 }
164
165 let input_mappings: Vec<_> = match &node_class {
167 classify::NodeClass::Standard { .. } => node.inputs.values_mut().collect(),
168 classify::NodeClass::Runtime => node
169 .operators
170 .as_mut()
171 .ok_or_eyre("no operators")?
172 .operators
173 .iter_mut()
174 .flat_map(|op| op.config.inputs.values_mut())
175 .collect(),
176 classify::NodeClass::Operator => node
177 .operator
178 .as_mut()
179 .ok_or_eyre("no operator")?
180 .config
181 .inputs
182 .values_mut()
183 .collect(),
184 classify::NodeClass::Ros2Bridge => vec![],
185 };
186 for mapping in input_mappings
187 .into_iter()
188 .filter_map(|i| match &mut i.mapping {
189 InputMapping::Timer { .. } | InputMapping::Logs(_) => None,
190 InputMapping::User(m) => Some(m),
191 })
192 {
193 if let Some(op_name) = single_operator_nodes.get(&mapping.source).copied() {
194 mapping.output = prefix_output_with_operator_id(op_name, &mapping.output)?;
195 }
196 }
197
198 let kind = match node_class {
202 classify::NodeClass::Standard { source } => {
203 let path = node.path.take().ok_or_eyre("missing `path` attribute")?;
204 let mut custom = CustomNode::from_node(&mut node, path);
205 custom.source = source;
206 CoreNodeKind::Custom(custom)
207 }
208 classify::NodeClass::Runtime => {
209 let runtime = node.operators.as_ref().ok_or_eyre("no operators")?;
210 CoreNodeKind::Runtime(runtime.clone())
211 }
212 classify::NodeClass::Operator => {
213 let op = node.operator.as_ref().ok_or_eyre("no operator")?;
214 CoreNodeKind::Runtime(RuntimeNode {
215 operators: vec![OperatorDefinition {
216 id: op.id.clone().unwrap_or_else(|| default_op_id.clone()),
217 config: op.config.clone(),
218 }],
219 })
220 }
221 classify::NodeClass::Ros2Bridge => {
222 let config = node.ros2.as_ref().ok_or_eyre("no ros2")?;
223 let bridge_config_json = serde_json::to_string(&config)
224 .context("failed to serialize ROS2 bridge config")?;
225
226 let mut envs = BTreeMap::new();
227 envs.insert(
228 "DORA_ROS2_BRIDGE_CONFIG".to_string(),
229 EnvValue::String(bridge_config_json),
230 );
231
232 let mut custom =
237 CustomNode::from_node(&mut node, "dora-ros2-bridge-node".to_string());
238 custom.envs = Some(envs);
239 CoreNodeKind::Custom(custom)
240 }
241 };
242
243 if resolved.contains_key(&node.id) {
244 eyre::bail!(
245 "duplicate node ID `{}` — each node must have a unique `id`",
246 node.id
247 );
248 }
249 let mut resolved_node = ResolvedNode::from_node(node, kind);
250 resolved_node.env = merge_env(desc.env.as_ref(), resolved_node.env.take());
254 resolved.insert(resolved_node.id.clone(), resolved_node);
255 }
256
257 Ok(resolved)
258}
259
260impl DescriptorExt for Descriptor {
261 fn resolve_aliases_and_set_defaults(&self) -> eyre::Result<BTreeMap<NodeId, ResolvedNode>> {
262 resolve_aliases_and_set_defaults_in_topology(self, &[])
263 }
264
265 fn visualize_as_mermaid_with_boundaries(
266 &self,
267 boundaries: &ModuleBoundaries,
268 ) -> eyre::Result<String> {
269 let resolved = self.resolve_aliases_and_set_defaults()?;
270 let flowchart = visualize::visualize_nodes_with_boundaries(&resolved, boundaries);
271 Ok(flowchart)
272 }
273
274 fn apply_exit_when_nodes_finish(&mut self, over: Option<bool>) {
275 if let Some(over) = over {
276 self.exit_when_nodes_finish = Some(over);
277 }
278 }
279
280 fn blocking_read(path: &Path) -> eyre::Result<Descriptor> {
281 let buf = std::fs::read(path).context("failed to open given file")?;
282 Descriptor::parse(buf)
283 }
284
285 fn parse(buf: Vec<u8>) -> eyre::Result<Descriptor> {
286 serde_yaml::from_slice(&buf).context("failed to parse given descriptor")
287 }
288
289 fn check(&self, working_dir: &Path) -> eyre::Result<()> {
290 let expanded = self.expand(working_dir)?;
291 validate::check_dataflow(&expanded, working_dir)
292 .wrap_err("Dataflow could not be validated.")
293 }
294
295 fn expand(&self, working_dir: &Path) -> eyre::Result<Descriptor> {
296 expand::expand_modules(self, working_dir)
297 }
298
299 fn expand_with_boundaries(
300 &self,
301 working_dir: &Path,
302 ) -> eyre::Result<(Descriptor, ModuleBoundaries)> {
303 let expanded = expand::expand_modules_with_boundaries(self, working_dir)?;
304 Ok((expanded.descriptor, expanded.boundaries))
305 }
306}
307
308fn merge_env(
315 global: Option<&BTreeMap<String, EnvValue>>,
316 node: Option<BTreeMap<String, EnvValue>>,
317) -> Option<BTreeMap<String, EnvValue>> {
318 let merged = match (global, node) {
319 (None, None) => return None,
320 (None, Some(node)) => node,
321 (Some(global), None) => global.clone(),
322 (Some(global), Some(node)) => {
323 let mut merged = global.clone();
324 merged.extend(node);
326 merged
327 }
328 };
329 (!merged.is_empty()).then_some(merged)
331}
332
333pub async fn read_as_descriptor(path: &Path) -> eyre::Result<Descriptor> {
334 let buf = tokio::fs::read(path)
335 .await
336 .context("failed to open given file")?;
337 Descriptor::parse(buf)
338}
339
340pub fn source_is_url(source: &str) -> bool {
361 source.starts_with("https://") || source.starts_with("http://")
362}
363
364pub fn resolve_path(source: &str, working_dir: &Path) -> Result<PathBuf> {
365 let path = Path::new(&source);
366 let path = if path.extension().is_none() {
367 path.with_extension(EXE_EXTENSION)
368 } else {
369 path.to_owned()
370 };
371
372 let joined = working_dir.join(&path);
374 if joined.exists() {
375 absolutize_preserving_symlinks(&joined)
376 } else if which::which("uv").is_ok() {
379 resolve_path_via_uv(&path)
380 } else if let Ok(abs_path) = which::which(&path) {
381 Ok(abs_path)
382 } else {
383 bail!("Could not find source path {}", path.display())
384 }
385}
386
387pub fn resolve_path_confined(
395 source: &str,
396 working_dir: &Path,
397 python_env_dir: Option<&Path>,
398) -> Result<PathBuf> {
399 let path = Path::new(&source);
400 let path = if path.extension().is_none() {
401 path.with_extension(EXE_EXTENSION)
402 } else {
403 path.to_owned()
404 };
405
406 if let Some(env_dir) = python_env_dir {
408 let bin_dir = env_dir.join(if cfg!(windows) { "Scripts" } else { "bin" });
409 let candidate = bin_dir.join(&path);
410 if candidate.is_file() {
411 return confine(&candidate, &bin_dir);
412 }
413 }
414
415 let candidate = working_dir.join(&path);
417 if candidate.exists() {
418 return confine(&candidate, working_dir);
419 }
420
421 bail!(
422 "could not find `{}` in the node's working directory `{}`{} — \
423 hub nodes resolve only within their own package (no $PATH fallback)",
424 path.display(),
425 working_dir.display(),
426 python_env_dir
427 .map(|env| format!(" or its managed environment `{}`", env.display()))
428 .unwrap_or_default(),
429 )
430}
431
432fn confine(candidate: &Path, root: &Path) -> Result<PathBuf> {
434 let resolved = candidate
435 .canonicalize()
436 .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
437 let root = root
438 .canonicalize()
439 .with_context(|| format!("failed to canonicalize `{}`", root.display()))?;
440 if !resolved.starts_with(&root) {
441 bail!(
442 "entrypoint `{}` resolves outside the node's directory `{}` \
443 (symlink escape?) — refusing to run it",
444 resolved.display(),
445 root.display()
446 );
447 }
448 Ok(resolved)
449}
450
451fn absolutize_preserving_symlinks(path: &Path) -> Result<PathBuf> {
467 if !path.exists() {
468 bail!("path {} does not exist", path.display());
469 }
470 std::path::absolute(path)
471 .with_context(|| format!("failed to make path {} absolute", path.display()))
472}
473
474fn resolve_path_via_uv(path: &Path) -> Result<PathBuf> {
483 let which = if cfg!(windows) { "where" } else { "which" };
484 let output = Command::new("uv")
485 .arg("run")
486 .arg(which)
487 .arg(path)
488 .output()
489 .with_context(|| format!("failed to run `uv run {which}`"))?;
490 if !output.status.success() {
491 bail!("Could not find source path {} within uv", path.display());
492 }
493 let stdout = String::from_utf8_lossy(&output.stdout);
496 let resolved = stdout
497 .lines()
498 .map(str::trim)
499 .find(|line| !line.is_empty())
500 .ok_or_else(|| eyre::eyre!("`uv run {which} {}` produced no output", path.display()))?;
501 absolutize_preserving_symlinks(Path::new(resolved))
502 .with_context(|| format!("uv-resolved path {resolved} is not usable"))
503}
504
505pub trait NodeExt {
506 fn kind(&self) -> eyre::Result<NodeKind<'_>>;
507}
508
509impl NodeExt for Node {
510 fn kind(&self) -> eyre::Result<NodeKind<'_>> {
511 if self.hub.is_some() && self.path.is_none() {
512 eyre::bail!(
517 "node `{}` uses an unresolved `hub:` reference — run `dora build` \
518 first (`dora start` requires a prior build for hub nodes)",
519 self.id
520 );
521 }
522 match (
523 &self.path,
524 &self.operators,
525 &self.operator,
526 &self.ros2,
527 &self.module,
528 ) {
529 (None, None, None, None, None) => {
530 eyre::bail!(
531 "node `{}` requires a `path`, `operators`, `ros2`, or `module` field",
532 self.id
533 )
534 }
535 (None, None, Some(operator), None, None) => Ok(NodeKind::Operator(operator)),
536 (None, Some(runtime), None, None, None) => Ok(NodeKind::Runtime(runtime)),
537 (Some(path), None, None, None, None) => Ok(NodeKind::Standard(path)),
538 (None, None, None, Some(ros2), None) => Ok(NodeKind::Ros2Bridge(ros2)),
539 (None, None, None, None, Some(module)) => Ok(NodeKind::Module(module)),
540 _ => {
541 eyre::bail!(
542 "node `{}` has multiple exclusive fields set, only one of `path`, `operators`, `operator`, `ros2`, and `module` is allowed",
543 self.id
544 )
545 }
546 }
547 }
548}
549
550#[derive(Debug)]
551pub enum NodeKind<'a> {
552 Standard(&'a String),
553 Runtime(&'a RuntimeNode),
555 Operator(&'a SingleOperatorDefinition),
556 Ros2Bridge(&'a Ros2BridgeConfig),
558 Module(&'a String),
560}
561
562#[cfg(test)]
563mod tests {
564 #[test]
573 fn exit_when_nodes_finish_override_semantics() {
574 use super::DescriptorExt;
575
576 let parse = |yaml: &str| -> Descriptor { serde_yaml::from_str(yaml).expect("parse") };
577 let with_setting = "exit_when_nodes_finish: true\nnodes:\n - id: a\n path: ./a\n";
578 let without = "nodes:\n - id: a\n path: ./a\n";
579
580 let mut d = parse(with_setting);
582 d.apply_exit_when_nodes_finish(None);
583 assert_eq!(
584 d.exit_when_nodes_finish,
585 Some(true),
586 "omitting the flag must not silently disable a policy the \
587 dataflow file asked for"
588 );
589
590 let mut d = parse(without);
591 d.apply_exit_when_nodes_finish(None);
592 assert_eq!(d.exit_when_nodes_finish, None, "and must not invent one");
593
594 let mut d = parse(with_setting);
596 d.apply_exit_when_nodes_finish(Some(false));
597 assert_eq!(
598 d.exit_when_nodes_finish,
599 Some(false),
600 "`--exit-when-nodes-finish=false` must be able to turn OFF a \
601 policy the dataflow file turned on"
602 );
603
604 let mut d = parse(without);
605 d.apply_exit_when_nodes_finish(Some(true));
606 assert_eq!(d.exit_when_nodes_finish, Some(true));
607 }
608
609 use super::*;
610 use dora_message::descriptor::{GitRepoRev, NodeSource};
611 use std::collections::BTreeSet;
612
613 fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, EnvValue> {
614 pairs
615 .iter()
616 .map(|(k, v)| (k.to_string(), EnvValue::String(v.to_string())))
617 .collect()
618 }
619
620 #[test]
621 fn merge_env_returns_none_when_both_absent() {
622 assert!(merge_env(None, None).is_none());
623 }
624
625 #[test]
626 fn merge_env_keeps_per_node_when_no_global() {
627 let node_env = env(&[("A", "1")]);
628 let merged = merge_env(None, Some(node_env.clone())).unwrap();
629 assert_eq!(merged, node_env);
630 }
631
632 #[test]
633 fn merge_env_keeps_global_when_no_per_node() {
634 let global = env(&[("A", "1")]);
635 let merged = merge_env(Some(&global), None).unwrap();
636 assert_eq!(merged, global);
637 }
638
639 #[test]
640 fn merge_env_normalizes_empty_node_map_to_none() {
641 assert!(merge_env(None, Some(env(&[]))).is_none());
646 }
647
648 #[test]
649 fn merge_env_normalizes_empty_global_and_node_maps_to_none() {
650 assert!(merge_env(Some(&env(&[])), Some(env(&[]))).is_none());
651 assert!(merge_env(Some(&env(&[])), None).is_none());
652 }
653
654 #[test]
655 fn merge_env_per_node_overrides_global_on_conflict() {
656 let global = env(&[("A", "global"), ("B", "global")]);
657 let node_env = env(&[("A", "node"), ("C", "node")]);
658 let merged = merge_env(Some(&global), Some(node_env)).unwrap();
659 assert_eq!(merged.get("A"), Some(&EnvValue::String("node".into())));
660 assert_eq!(merged.get("B"), Some(&EnvValue::String("global".into())));
661 assert_eq!(merged.get("C"), Some(&EnvValue::String("node".into())));
662 }
663
664 fn resolved_input_mapping<'a>(
665 resolved: &'a BTreeMap<NodeId, ResolvedNode>,
666 node: &str,
667 input: &str,
668 ) -> &'a InputMapping {
669 let node = resolved
670 .get(&NodeId::from(node.to_string()))
671 .expect("node resolved");
672 let inputs = match &node.kind {
673 CoreNodeKind::Custom(n) => &n.run_config.inputs,
674 CoreNodeKind::Runtime(_) => panic!("expected custom node"),
675 };
676 &inputs
677 .get(&DataId::from(input.to_string()))
678 .expect("input present")
679 .mapping
680 }
681
682 #[test]
683 fn add_node_prefixes_single_operator_producer_input_via_topology() {
684 let topology: Descriptor = serde_yaml::from_str(
691 "\
692nodes:
693 - id: producer
694 operator:
695 python: producer.py
696 outputs:
697 - result
698",
699 )
700 .expect("parse topology");
701
702 let added: Descriptor = serde_yaml::from_str(
703 "\
704nodes:
705 - id: consumer
706 path: consumer
707 inputs:
708 reading: producer/result
709",
710 )
711 .expect("parse added node");
712
713 let resolved = resolve_aliases_and_set_defaults_in_topology(&added, &topology.nodes)
716 .expect("resolve in topology");
717 match resolved_input_mapping(&resolved, "consumer", "reading") {
718 InputMapping::User(m) => {
719 assert_eq!(m.source, NodeId::from("producer".to_string()));
720 assert_eq!(m.output, DataId::from("op/result".to_string()));
721 }
722 other => panic!("expected user mapping, got {other:?}"),
723 }
724
725 let resolved_isolated = added
730 .resolve_aliases_and_set_defaults()
731 .expect("resolve isolated");
732 match resolved_input_mapping(&resolved_isolated, "consumer", "reading") {
733 InputMapping::User(m) => {
734 assert_eq!(m.output, DataId::from("result".to_string()));
735 }
736 other => panic!("expected user mapping, got {other:?}"),
737 }
738 }
739
740 #[test]
741 fn topology_lookup_prefers_the_node_being_added_over_a_same_id_topology_entry() {
742 let topology: Descriptor = serde_yaml::from_str(
748 "\
749nodes:
750 - id: producer
751 operator:
752 id: stale
753 python: producer.py
754 outputs:
755 - result
756",
757 )
758 .expect("parse topology");
759
760 let added: Descriptor = serde_yaml::from_str(
761 "\
762nodes:
763 - id: producer
764 operator:
765 id: fresh
766 python: producer.py
767 outputs:
768 - result
769 - id: consumer
770 path: consumer
771 inputs:
772 reading: producer/result
773",
774 )
775 .expect("parse added nodes");
776
777 let resolved = resolve_aliases_and_set_defaults_in_topology(&added, &topology.nodes)
778 .expect("resolve in topology");
779 match resolved_input_mapping(&resolved, "consumer", "reading") {
780 InputMapping::User(m) => {
781 assert_eq!(m.output, DataId::from("fresh/result".to_string()));
782 }
783 other => panic!("expected user mapping, got {other:?}"),
784 }
785 }
786
787 #[test]
788 fn descriptor_global_env_parses_from_yaml() {
789 let yaml = r#"
792env:
793 RUST_LOG: info
794 OTEL_ENDPOINT: http://collector:4317
795nodes:
796 - id: a
797 path: ./a
798 env:
799 RUST_LOG: debug
800 - id: b
801 path: ./b
802"#;
803 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
804 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
805
806 let a = resolved.get(&NodeId::from("a".to_string())).unwrap();
807 let a_env = a.env.as_ref().expect("node a inherits env");
808 assert_eq!(
810 a_env.get("RUST_LOG"),
811 Some(&EnvValue::String("debug".into()))
812 );
813 assert_eq!(
815 a_env.get("OTEL_ENDPOINT"),
816 Some(&EnvValue::String("http://collector:4317".into()))
817 );
818
819 let b = resolved.get(&NodeId::from("b".to_string())).unwrap();
820 let b_env = b.env.as_ref().expect("node b inherits global env");
821 assert_eq!(
822 b_env.get("RUST_LOG"),
823 Some(&EnvValue::String("info".into()))
824 );
825 assert_eq!(
826 b_env.get("OTEL_ENDPOINT"),
827 Some(&EnvValue::String("http://collector:4317".into()))
828 );
829 }
830
831 #[test]
832 fn invalid_operator_id_prefix_errors_instead_of_panicking() {
833 let yaml = r#"
841nodes:
842 - id: producer
843 operator:
844 id: "bad id"
845 python: op.py
846 outputs: [result]
847 - id: consumer
848 path: ./consumer
849 inputs:
850 x: producer/result
851"#;
852 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
853 let result = desc.resolve_aliases_and_set_defaults();
854 assert!(result.is_err(), "expected a clean descriptor error, got Ok");
855 }
856
857 #[test]
858 fn resolve_path_errors_for_nonexistent_binary() {
859 let working_dir = std::env::current_dir().expect("cwd");
865 let result = resolve_path("dora_nonexistent_binary_2016_regression", &working_dir);
866 assert!(
867 result.is_err(),
868 "expected Err for a binary that exists nowhere, got {result:?}"
869 );
870 }
871
872 #[test]
882 #[cfg(unix)]
883 fn resolve_path_preserves_symlinks() {
884 let tmp = tempfile::tempdir().expect("tempdir");
885 let target = tmp.path().join("base-interpreter.bin");
886 std::fs::write(&target, b"x").unwrap();
887 let link = tmp.path().join("venv-python.bin");
888 std::os::unix::fs::symlink(&target, &link).unwrap();
889
890 let resolved = resolve_path("venv-python.bin", tmp.path()).unwrap();
892 assert!(resolved.is_absolute());
893 assert!(
894 resolved.ends_with("venv-python.bin"),
895 "resolve_path followed the symlink: {} — venv discovery \
896 (pyvenv.cfg) is keyed off the symlink location, so execing \
897 the target bypasses the venv",
898 resolved.display()
899 );
900
901 let resolved = resolve_path(link.to_str().unwrap(), Path::new("/")).unwrap();
903 assert!(
904 resolved.ends_with("venv-python.bin"),
905 "absolute symlink path was canonicalized: {}",
906 resolved.display()
907 );
908
909 std::fs::create_dir(tmp.path().join("sub")).unwrap();
914 let resolved = resolve_path("sub/../venv-python.bin", tmp.path()).unwrap();
915 assert!(
916 resolved.ends_with("sub/../venv-python.bin"),
917 "`..` was normalized away: {}",
918 resolved.display()
919 );
920 }
921
922 #[test]
929 #[cfg(unix)]
930 fn resolve_path_treats_dangling_symlink_as_missing() {
931 let tmp = tempfile::tempdir().expect("tempdir");
932 let link = tmp.path().join("dangling-2918-regression.bin");
933 std::os::unix::fs::symlink(tmp.path().join("no-such-target"), &link).unwrap();
934
935 let result = resolve_path("dangling-2918-regression.bin", tmp.path());
936 assert!(
937 result.is_err(),
938 "a dangling symlink must not resolve (nothing on uv/$PATH matches \
939 this name either), got {result:?}"
940 );
941 }
942
943 #[test]
944 fn resolve_path_confined_has_no_path_fallback() {
945 let tmp = tempfile::tempdir().expect("tempdir");
946 let result = resolve_path_confined("sh", tmp.path(), None);
950 assert!(result.is_err(), "expected Err, got {result:?}");
951
952 let exe = if cfg!(windows) {
954 "node.exe"
955 } else {
956 "node.bin"
957 };
958 std::fs::write(tmp.path().join(exe), b"x").unwrap();
959 let resolved = resolve_path_confined(exe, tmp.path(), None).unwrap();
960 assert!(resolved.is_absolute());
961
962 let env_dir = tmp.path().join("env");
964 let bin_dir = env_dir.join(if cfg!(windows) { "Scripts" } else { "bin" });
965 std::fs::create_dir_all(&bin_dir).unwrap();
966 std::fs::write(bin_dir.join(exe), b"x").unwrap();
967 let resolved =
968 resolve_path_confined(exe, tmp.path().join("empty").as_path(), Some(&env_dir));
969 assert!(resolved.is_ok(), "{resolved:?}");
970 }
971
972 #[cfg(unix)]
973 #[test]
974 fn resolve_path_confined_rejects_symlink_escape() {
975 let tmp = tempfile::tempdir().expect("tempdir");
976 let working_dir = tmp.path().join("work");
977 std::fs::create_dir_all(&working_dir).unwrap();
978 let outside = tmp.path().join("outside.bin");
979 std::fs::write(&outside, b"x").unwrap();
980 std::os::unix::fs::symlink(&outside, working_dir.join("escape.bin")).unwrap();
981 let result = resolve_path_confined("escape.bin", &working_dir, None);
982 assert!(
983 result.is_err(),
984 "a symlink pointing outside the working dir must be rejected, got {result:?}"
985 );
986 let msg = format!("{:#}", result.unwrap_err());
987 assert!(msg.contains("outside"), "{msg}");
988 }
989
990 #[test]
991 fn unresolved_hub_node_has_clear_kind_error() {
992 let node: Node = serde_yaml::from_str("id: x\nhub: dora-yolo@^0.5\n").unwrap();
993 let err = node.kind().unwrap_err();
994 assert!(format!("{err}").contains("dora build"), "{err}");
995 }
996
997 #[test]
998 fn resolve_path_via_uv_errors_for_nonexistent_binary() {
999 if which::which("uv").is_err() {
1007 return;
1008 }
1009 let path = Path::new("dora_nonexistent_binary_2016_regression");
1010 let result = resolve_path_via_uv(path);
1011 assert!(
1012 result.is_err(),
1013 "expected Err from `uv run which` for a missing binary, got {result:?}"
1014 );
1015 }
1016
1017 #[test]
1018 fn descriptor_without_global_env_preserves_per_node_env() {
1019 let yaml = r#"
1022nodes:
1023 - id: a
1024 path: ./a
1025 env:
1026 FOO: bar
1027 - id: b
1028 path: ./b
1029"#;
1030 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1031 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1032 let a = resolved.get(&NodeId::from("a".to_string())).unwrap();
1033 assert_eq!(
1034 a.env.as_ref().and_then(|e| e.get("FOO")),
1035 Some(&EnvValue::String("bar".into()))
1036 );
1037 let b = resolved.get(&NodeId::from("b".to_string())).unwrap();
1038 assert!(b.env.is_none(), "node b has no env anywhere");
1039 }
1040
1041 #[test]
1042 fn duplicate_node_id_is_rejected() {
1043 let yaml = r#"
1046nodes:
1047 - id: my-node
1048 path: ./a
1049 - id: my-node
1050 path: ./b
1051"#;
1052 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1053 let err = desc
1054 .resolve_aliases_and_set_defaults()
1055 .expect_err("duplicate node ID must be rejected");
1056 let msg = format!("{err:#}");
1057 assert!(
1058 msg.contains("duplicate node ID") && msg.contains("my-node"),
1059 "unexpected error message: {msg}"
1060 );
1061 }
1062
1063 fn custom_node_field_names() -> BTreeSet<String> {
1070 let schema = schemars::schema_for!(dora_message::descriptor::CustomNode);
1071 let schema = serde_json::to_value(schema).expect("schema should serialize");
1072 schema
1073 .pointer("/$defs/CustomNode/properties")
1074 .or_else(|| schema.pointer("/definitions/CustomNode/properties"))
1075 .or_else(|| schema.pointer("/properties"))
1076 .and_then(serde_json::Value::as_object)
1077 .expect("CustomNode schema should expose properties")
1078 .keys()
1079 .cloned()
1080 .collect()
1081 }
1082
1083 const SHARED_CUSTOM_NODE_KEYS: &str = r#"
1087 args: --verbose
1088 send_stdout_as: stdout-topic
1089 send_logs_as: logs-topic
1090 min_log_level: debug
1091 max_log_size: 4MB
1092 max_rotated_files: 3
1093 restart_policy: always
1094 max_restarts: 7
1095 restart_delay: 1.5
1096 max_restart_delay: 9.5
1097 restart_window: 60.0
1098 health_check_timeout: 2.5
1099 finish_grace_secs: 3.5
1100 shared_memory_pool_size: 8MB
1101 inputs:
1102 tick: dora/timer/millis/100
1103 outputs:
1104 - out
1105 output_types:
1106 out: arrow.int32
1107 output_framing:
1108 out: arrow-ipc
1109 input_types:
1110 tick: arrow.uint64
1111"#;
1112
1113 fn resolve_and_check_carried_through(yaml: &str, kind_specific: &[&str]) -> CustomNode {
1126 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1127 let declared = serde_json::to_value(&desc.nodes[0]).expect("serialize declared");
1128 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1129 let node = resolved.values().next().expect("one node");
1130 let CoreNodeKind::Custom(custom) = &node.kind else {
1131 panic!("expected a custom node, got {:?}", node.kind);
1132 };
1133
1134 let actual = serde_json::to_value(custom).expect("serialize resolved");
1135 let default =
1138 serde_json::to_value(CustomNode::new("<unset>".to_owned())).expect("serialize default");
1139
1140 for field in custom_node_field_names() {
1141 if kind_specific.contains(&field.as_str()) {
1142 continue;
1143 }
1144 assert_ne!(
1145 actual.get(&field),
1146 default.get(&field),
1147 "`{field}` is still at its `CustomNode::new` default after \
1148 resolution — either the YAML does not set it (add it to \
1149 `SHARED_CUSTOM_NODE_KEYS`) or the key is parsed and then \
1150 dropped (carry it in `CustomNode::from_node`)."
1151 );
1152 assert_eq!(
1153 actual.get(&field),
1154 declared.get(&field),
1155 "`{field}` resolved to a different value than the YAML declared \
1156 — `CustomNode::from_node` copies it from the wrong `Node` field."
1157 );
1158 }
1159 custom.clone()
1160 }
1161
1162 #[test]
1167 fn every_custom_node_field_is_carried_through() {
1168 let yaml = format!(
1169 "nodes:\n - id: full\n path: ./full-node\n path_sha256: abc123\n \
1170 build: cargo build{SHARED_CUSTOM_NODE_KEYS}"
1171 );
1172 let custom = resolve_and_check_carried_through(&yaml, &["source", "envs"]);
1173 assert!(
1174 matches!(custom.source, NodeSource::Local),
1175 "{:?}",
1176 custom.source
1177 );
1178 assert!(custom.envs.is_none(), "{:?}", custom.envs);
1179 }
1180
1181 #[test]
1185 fn git_source_is_carried_through() {
1186 let yaml = format!(
1187 "nodes:\n - id: full\n path: node\n path_sha256: abc123\n \
1188 build: cargo build\n git: https://github.com/example/node.git\n \
1189 branch: main{SHARED_CUSTOM_NODE_KEYS}"
1190 );
1191 let custom = resolve_and_check_carried_through(&yaml, &["source", "envs"]);
1192 assert!(
1193 matches!(
1194 &custom.source,
1195 NodeSource::GitBranch { repo, rev: Some(GitRepoRev::Branch(branch)) }
1196 if repo == "https://github.com/example/node.git" && branch == "main"
1197 ),
1198 "{:?}",
1199 custom.source
1200 );
1201 assert!(custom.envs.is_none(), "{:?}", custom.envs);
1202 }
1203
1204 #[test]
1209 fn ros2_bridge_node_is_carried_through() {
1210 let yaml = format!(
1211 "nodes:\n - id: bridge\n ros2:\n topic: /odom\n \
1212 message_type: nav_msgs/msg/Odometry\n direction: subscribe\
1213 {SHARED_CUSTOM_NODE_KEYS}"
1214 );
1215 let custom = resolve_and_check_carried_through(
1216 &yaml,
1217 &["path", "source", "path_sha256", "build", "envs"],
1218 );
1219 assert_eq!(custom.path, "dora-ros2-bridge-node");
1220 assert!(
1221 matches!(custom.source, NodeSource::Local),
1222 "{:?}",
1223 custom.source
1224 );
1225 assert!(custom.path_sha256.is_none(), "{:?}", custom.path_sha256);
1226 assert!(custom.build.is_none(), "{:?}", custom.build);
1227 let envs = custom
1228 .envs
1229 .expect("the bridge is configured through its environment");
1230 let Some(EnvValue::String(config)) = envs.get("DORA_ROS2_BRIDGE_CONFIG") else {
1231 panic!("DORA_ROS2_BRIDGE_CONFIG missing from {envs:?}");
1232 };
1233 assert!(config.contains("/odom"), "{config}");
1234 }
1235
1236 #[test]
1243 fn node_level_keys_are_carried_through() {
1244 let yaml = r#"
1245env:
1246 RUST_LOG: info
1247 SHARED: global
1248nodes:
1249 - id: full
1250 name: Full Node
1251 description: Sets every node-level key
1252 path: ./full-node
1253 env:
1254 SHARED: per-node
1255 cpu_affinity: [0, 1]
1256 deploy:
1257 machine: gpu-box
1258"#;
1259 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1260 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1261 let node = resolved.values().next().expect("one node");
1262
1263 assert_eq!(node.id.to_string(), "full");
1264 assert_eq!(node.name.as_deref(), Some("Full Node"));
1265 assert_eq!(
1266 node.description.as_deref(),
1267 Some("Sets every node-level key")
1268 );
1269 assert_eq!(
1270 node.env,
1271 Some(env(&[("RUST_LOG", "info"), ("SHARED", "per-node")]))
1272 );
1273 assert_eq!(node.cpu_affinity, Some(vec![0, 1]));
1274 assert_eq!(
1275 node.deploy.as_ref().and_then(|d| d.machine.as_deref()),
1276 Some("gpu-box")
1277 );
1278 }
1279}