Skip to main content

dora_core/descriptor/
mod.rs

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
14// reexport for compatibility
15pub 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;
25/// Lexically normalize a path (collapse `.` and resolve `..`) without touching
26/// the filesystem — the executable may not be built yet when this is called.
27///
28/// Used to sanitize untrusted node paths before a containment check, so the
29/// two callers (module expansion and manifest injection) must collapse `..`
30/// identically; keeping a single implementation prevents them from drifting.
31pub(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    /// Apply a command-line override to the dataflow's completion policy.
61    ///
62    /// `None` leaves the descriptor's own `exit_when_nodes_finish`
63    /// alone, so a setting written in the YAML stands; `Some(v)`
64    /// overrides it in either direction, so `--exit-when-nodes-finish`
65    /// can force the policy on and `=false` can force it off for a
66    /// descriptor that asks for it (dora-rs/dora#2920).
67    ///
68    /// Shared rather than spelled out at each call site: `dora run`,
69    /// `dora start` and the daemon all have to agree, and three copies
70    /// of the same three lines is how one of them ends up not being
71    /// updated.
72    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    /// Expand all module references into flat nodes.
78    ///
79    /// Module nodes are replaced by the inner nodes defined in their module
80    /// file. Internal IDs are prefixed with `{module_id}.` and input/output
81    /// wiring is rewritten accordingly.
82    fn expand(&self, working_dir: &Path) -> eyre::Result<Descriptor>;
83    /// Like [`expand`](Self::expand) but also returns module boundary metadata
84    /// for visualization.
85    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
93/// Prefixes a single-operator node's output id with the operator id
94/// (e.g. `result` -> `op/result`) so downstream references resolve to the
95/// operator-qualified output.
96///
97/// Operator ids are *not* validated against the `DataId` character set when a
98/// descriptor is parsed (`OperatorId` stores its string verbatim), so build the
99/// qualified id fallibly: `DataId::from` panics on characters outside
100/// `[a-zA-Z0-9_./-]`, which would abort `dora check`/`graph`/`build` on an
101/// otherwise-parseable descriptor. Returning an `Err` surfaces it as a clean
102/// descriptor error instead.
103fn 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
113/// Like [`DescriptorExt::resolve_aliases_and_set_defaults`], but resolves
114/// `desc` as a node (or nodes) being added to an already-running dataflow
115/// whose current node set is `topology_nodes`.
116///
117/// Whole-descriptor resolution rewrites an input that references a
118/// single-`operator:` producer from the bare output name to the
119/// operator-qualified one (`result` -> `op/result`). The dynamic-topology
120/// `AddNode` path resolves the new node in isolation, so that producer is not
121/// present in the descriptor being resolved and the rewrite is skipped —
122/// leaving the added node subscribed to an output name nobody publishes
123/// (silent data loss, #2877). Supplying the surrounding `topology_nodes` makes
124/// those producers visible so the prefixing is applied.
125///
126/// `topology_nodes` contribute *only* to the single-operator output-prefixing
127/// lookup — they are never themselves resolved or emitted, and nothing else on
128/// the surrounding descriptor (`env`, `deploy`, …) is consulted. Callers that
129/// want the running dataflow's `env` merged in must set it on `desc` (the
130/// `AddNode` handler does, see #2919). `desc`'s own nodes take precedence in
131/// the lookup, so a node id present in both resolves against the copy being
132/// added.
133pub 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        // classify node: determine kind + validate fields against whitelist
152        let node_class = classify::classify(&node)?;
153
154        // adjust ROS2 bridge input mappings early
155        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        // adjust input mappings
166        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        // resolve nodes. The two custom-node arms drain the custom-node keys
199        // out of `node` with `CustomNode::from_node`; the node-level keys stay
200        // behind for `ResolvedNode::from_node` below.
201        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                // The bridge binary is fixed, so `path` is a constant and
233                // `source` stays at its default. `path_sha256` and `build` are
234                // not accepted on a `ros2:` node (classification rejects them),
235                // so `from_node` finds them unset.
236                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        // Merge the dataflow-level `env` into the per-node `env`. Per-node keys
251        // win on conflict so a node can override a shared default (e.g. global
252        // `RUST_LOG=info` with one verbose node setting `RUST_LOG=debug`).
253        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
308/// Merge dataflow-level `env` into a node's `env`, with per-node keys winning
309/// on conflict. Returns `None` when the merged result is empty so the resolved
310/// node serializes cleanly (no empty `env: {}` map) whenever no env vars are
311/// effectively set — regardless of whether the emptiness comes from the global
312/// map, the node map, or both (e.g. a node that declares `env: {}` with no
313/// dataflow-level env).
314fn 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            // Per-node entries override global ones on key conflict.
325            merged.extend(node);
326            merged
327        }
328    };
329    // Normalize an empty result to `None` regardless of which side was empty.
330    (!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
340/// Returns `true` if `source` is an `http://` or `https://` URL.
341///
342/// This is the trust boundary that decides whether a node `path` is fetched as
343/// a remote download (and, for hub artifacts, checksum-verified) versus
344/// resolved as a local filesystem path. The match is on the literal scheme
345/// prefix and is **case-sensitive**: an upper-cased scheme such as `HTTPS://`
346/// is treated as a path, not a URL. Schemes other than HTTP(S) (e.g. `ftp://`,
347/// `s3://`, `file://`) are likewise not considered URLs here.
348///
349/// ```
350/// use dora_core::descriptor::source_is_url;
351///
352/// assert!(source_is_url("https://example.com/node"));
353/// assert!(source_is_url("http://example.com/node"));
354///
355/// assert!(!source_is_url("./build/my_node"));
356/// assert!(!source_is_url("/usr/bin/my_node"));
357/// assert!(!source_is_url("s3://bucket/key"));
358/// assert!(!source_is_url("HTTPS://example.com/node")); // case-sensitive
359/// ```
360pub 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    // Search path within current working directory.
373    let joined = working_dir.join(&path);
374    if joined.exists() {
375        absolutize_preserving_symlinks(&joined)
376    // Otherwise resolve against the `uv`-managed environment first (when `uv`
377    // is available), then fall back to the system `$PATH`.
378    } 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
387/// Resolve a hub node's entrypoint under **confined** rules (spec §11): the
388/// executable may only come from the node's own working directory
389/// (`<clone>/<subdir>`) or its managed Python environment. There is no
390/// ambient-`$PATH` fallback — a typo or missing console script fails loudly
391/// instead of silently running a host binary — and the resolved path is
392/// canonicalized and checked to stay inside those roots, so a symlink
393/// escaping the working dir is rejected rather than followed.
394pub 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    // a console script installed into the node's managed env
407    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    // a file within the node's working dir (e.g. target/release/<bin>)
416    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
432/// Canonicalize `candidate` and require it to stay under `root`.
433fn 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
451/// Make an existing executable path absolute WITHOUT following symlinks.
452///
453/// Deliberately not `canonicalize()`: that resolves symlinks, and a
454/// virtualenv's `bin/python` is a symlink whose *location* is what
455/// CPython uses to discover `pyvenv.cfg`. Resolving it before exec runs
456/// the base interpreter with no venv, so imports that work in a shell
457/// fail under dora (dora-rs/dora#2918). `path::absolute` only prepends
458/// the cwd and drops `.` components; symlinks and `..` are left for the
459/// kernel to resolve at exec time, which matches shell behavior.
460///
461/// Errors if the path does not exist (`exists()` traverses symlinks, so
462/// a dangling link counts as missing — same outcome canonicalize gave).
463/// Shared by every [`resolve_path`] branch so the no-symlink-resolution
464/// contract cannot regress in one branch while the tests exercise
465/// another.
466fn 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
474/// Resolve `path` against the `uv`-managed environment by running
475/// `uv run which <path>`, returning an absolute path.
476///
477/// Unlike a fire-and-forget spawn, this waits for the child, checks its
478/// exit status (so a missing binary surfaces as an error), and verifies
479/// the captured location exists — without resolving symlinks, which
480/// would reintroduce the venv bypass fixed for the working-dir branch
481/// (dora-rs/dora#2918).
482fn 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    // `which`/`where` may emit multiple matches; the first line is the
494    // resolved binary.
495    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            // `hub:` is desugared into a concrete git node by `dora build` /
513            // `dora run` / `dora validate` before any kind dispatch — an
514            // unresolved reference reaching this point means a flow that
515            // skipped resolution
516            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    /// Dora runtime node
554    Runtime(&'a RuntimeNode),
555    Operator(&'a SingleOperatorDefinition),
556    /// ROS2 bridge node
557    Ros2Bridge(&'a Ros2BridgeConfig),
558    /// Module (sub-dataflow) reference — must be expanded before resolution
559    Module(&'a String),
560}
561
562#[cfg(test)]
563mod tests {
564    /// dora-rs/dora#2920: the command-line flag beats the descriptor in
565    /// BOTH directions, and its absence beats neither.
566    ///
567    /// The third state matters: if "flag omitted" meant `false`, a YAML
568    /// `exit_when_nodes_finish: true` would be overridden on every
569    /// invocation, and since `dora run` and `dora start` are the only
570    /// ways to start a dataflow, the descriptor field could never take
571    /// effect at all.
572    #[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        // Omitted: the descriptor decides, either way.
581        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        // Given: it wins, including against an opposite descriptor value.
595        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        // A node that declares `env: {}` with no dataflow-level env must
642        // resolve to `None`, not `Some({})`, matching the documented contract
643        // (and the `(Some(empty), None)` arm) so the resolved node serializes
644        // without an empty `env:` map.
645        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        // A node added to a running dataflow via `AddNode` is resolved against a
685        // single-node descriptor. If its input references an existing
686        // single-`operator:` producer, the `op/` output prefix that
687        // whole-descriptor resolution applies must still be added — sourced
688        // from the surrounding topology — or the node subscribes to an output
689        // name nobody publishes and silently receives no data (#2877).
690        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        // With the topology the input is prefixed to the operator-qualified
714        // output name the runtime actually publishes under (`op/result`).
715        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        // Contrast: with no topology the producer is not in scope at all, so
726        // there is nothing to key the rewrite off and the name stays bare.
727        // That is the correct answer for the inputs given — which is exactly
728        // why the `AddNode` path had to stop resolving in isolation.
729        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        // The lookup chains topology nodes *before* `desc`'s own, so a node id
743        // present in both resolves against the copy being added rather than a
744        // stale topology entry. Unreachable through `AddNode` today (duplicate
745        // ids are rejected up front and `RemoveNode` prunes the stored
746        // descriptor), but the precedence should not depend on that.
747        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        // Verify the new top-level `env:` field parses and the resolver
790        // hands merged envs to every node with per-node keys winning.
791        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        // Per-node RUST_LOG=debug wins over global RUST_LOG=info.
809        assert_eq!(
810            a_env.get("RUST_LOG"),
811            Some(&EnvValue::String("debug".into()))
812        );
813        // Global key not overridden on node a is still visible.
814        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        // A single-operator node whose `operator.id` contains a character
834        // outside the `DataId` set (here a space) is accepted at parse time
835        // (`OperatorId` is unvalidated). When another node references its
836        // output, the resolver prefixes the output with the operator id. That
837        // used to build the qualified id via `DataId::from`, which panics on
838        // invalid characters and aborted `dora check`/`graph`/`build`. It must
839        // now surface as a clean `Err` instead.
840        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        // Regression for #2016: the `uv` fallback previously spawned
860        // `uv run which <path>` fire-and-forget and returned the original
861        // (relative) path even when the binary did not exist. A missing
862        // binary must surface as an `Err`, and any successful resolution
863        // must be an absolute path.
864        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    /// dora-rs/dora#2918: resolving a node path must NOT follow symlinks.
873    ///
874    /// A virtualenv's `bin/python` is a symlink to the base interpreter,
875    /// and CPython's venv discovery hinges on that: it looks for
876    /// `pyvenv.cfg` relative to the path it was *invoked* as, not the
877    /// symlink's target. Canonicalizing before exec therefore runs the
878    /// base interpreter with no venv — imports that work in a shell
879    /// (`.venv/bin/python -c "import numpy"`) fail under dora with
880    /// `ModuleNotFoundError`.
881    #[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        // relative source resolved against the working dir
891        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        // absolute source (the shape from the issue: `path: /…/.venv/bin/python`)
902        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        // `..` components survive too: they are resolved by the kernel at
910        // exec time, AFTER any symlinked directories — which is the
911        // shell-matching semantic. A lexical "cleanup" that collapses
912        // them would resolve differently through symlinked dirs.
913        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    /// A dangling symlink is "missing": `exists()` traverses the link, so
923    /// resolution falls through to the uv/$PATH branches and ultimately
924    /// errors — the same outcome the old `canonicalize()` failure gave.
925    /// Pins the branch boundary so a switch to `symlink_metadata()`
926    /// (which would treat the dangling link as present and exec a
927    /// guaranteed-ENOENT path) doesn't slip in silently.
928    #[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        // `sh` exists on $PATH everywhere on unix — confined resolution must
947        // NOT find it (spec §11: a typo or missing console script fails, it
948        // never silently runs a host binary)
949        let result = resolve_path_confined("sh", tmp.path(), None);
950        assert!(result.is_err(), "expected Err, got {result:?}");
951
952        // a real file in the working dir resolves
953        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        // a managed-env console script resolves through the env's bin dir
963        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        // Pins the #2016 root cause directly on the `uv` branch. The buggy
1000        // code spawned `uv run which <path>` fire-and-forget and returned
1001        // `Ok(<relative path>)` regardless of the child's exit status, so a
1002        // missing binary was silently accepted. This branch only runs when
1003        // `uv` is installed (the only environment where the bug manifested),
1004        // so guard on its presence to keep the test meaningful where it can
1005        // actually discriminate the fix.
1006        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        // Regression guard: no top-level `env:` must leave per-node env
1020        // semantics unchanged.
1021        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        // Regression for #2393: a plain dataflow with two nodes sharing the
1044        // same `id` must return an error instead of silently discarding one.
1045        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    /// Every `CustomNode` field name, taken from its JSON schema.
1064    ///
1065    /// A field marked `#[schemars(skip)]` would never appear in `properties`
1066    /// and would slip past the carried-through tests below. `Node::deploy` is
1067    /// exactly such a field, and the classify test compensates with a
1068    /// hardcoded insert — do the same here if `CustomNode` ever gains one.
1069    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    /// The per-node keys every custom-node kind resolves identically, each set
1084    /// to a value that differs from `CustomNode::new`'s default — a YAML
1085    /// fragment to append to a `- id:` entry.
1086    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    /// Resolve the single node in `yaml` and assert that every `CustomNode`
1114    /// key outside `kind_specific` arrived carrying the value the YAML
1115    /// declared — and that the YAML did set it to something other than
1116    /// `CustomNode::new`'s default, so the comparison is never a trivial
1117    /// `None == None`. Returns the resolved node for the kind-specific checks.
1118    ///
1119    /// `CustomNode::from_node` is a struct literal inside `dora-message`, so a
1120    /// key added to `CustomNode` is already a compile error there. This is the
1121    /// value-level half: it catches a key wired to the wrong `Node` field, or
1122    /// left at its default. When it fails for a newly added key, carry the key
1123    /// in `from_node` and give it a non-default value in
1124    /// `SHARED_CUSTOM_NODE_KEYS`.
1125    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        // A sentinel `path` that no YAML here uses, so `path` itself is checked
1136        // like every other field rather than comparing equal to it.
1137        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    /// A `path:` node: every shared key and the standard-only `path_sha256` /
1163    /// `build` arrive. `source` resolves to `Local`, which is already the
1164    /// default, and `envs` is set only by the ROS2-bridge arm — the two tests
1165    /// below cover those.
1166    #[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    /// The `source` a `git:` node classifies to must reach the resolved node:
1182    /// it is the one assignment in the standard arm that `from_node` does not
1183    /// cover, and dropping it would turn every git node into a local one.
1184    #[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    /// The ROS2-bridge arm: `path` is the bridge binary, `envs` carries the
1205    /// bridge config the binary reads at startup, and every shared key still
1206    /// arrives. `path_sha256` and `build` are rejected on a `ros2:` node by
1207    /// classification, so they must stay unset.
1208    #[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    /// The node-level keys — the ones `ResolvedNode::from_node` consumes once
1237    /// `CustomNode::from_node` has drained the rest — must reach the resolved
1238    /// node. `from_node` is a struct literal inside `dora-message`, so a new
1239    /// `ResolvedNode` field is a compile error there; this checks the values,
1240    /// including that the dataflow-level `env` is merged in with the per-node
1241    /// key winning.
1242    #[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}