Skip to main content

dora_cli/command/build/
mod.rs

1//! Provides the `dora build` command.
2//!
3//! The `dora build` command works like this:
4//!
5//! - Dataflows can specify a `build` command for each node in their YAML definition
6//! - Dora will run the `build` command when `dora build` is invoked
7//! - If the dataflow is distributed across multiple machines, each `build` command will be run the target machine of the corresponding node.
8//!     - i.e. the machine specified under the `deploy` key
9//!     - this requires a connection to the dora coordinator, so you need to specify the coordinator IP/port for this
10//!     - to run the build commands of all nodes _locally_, you can use `dora build --local`
11//! - If the build command does not specify any `deploy` keys, all build commands will be run locally (i.e. `dora build` behaves like `dora build --local`)
12//!
13//! #### Git Source
14//!
15//! - Nodes can have a git repository as source
16//!     - set the `git` config key to the URL of the repository
17//!     - by default, the default branch is used
18//!     - you can also specify a specific `branch` name
19//!     - alternatively, you can specify a `tag` name or a `rev` key with the commit hash
20//!     - you can only specify one of `branch`, `tag`, and `rev`, otherwise an error will occur
21//! - Dora will automatically clone and checkout the requested branch/tag/commit on `dora build`
22//!     - the `build` command will be run after cloning
23//!     - for distributed dataflows, the clone/checkout will happen on the target machine
24//! - subsequent `dora build` command will automatically fetch the latest changes for nodes
25//!     - not when using `tag` or `rev`, because these are not expected to change
26//! - after fetching changes, the `build` command will be executed again
27//!     - _tip:_ use a build tool that supports incremental builds (e.g. `cargo`) to make this rebuild faster
28//!
29//! The **working directory** will be set to the git repository.
30//! This means that both the `build` and `path` keys will be run from this folder.
31//! This allows you to use relative paths.
32//!
33//! #### Example
34//!
35//! ```yml
36//! nodes:
37//!   - id: rust-node
38//!     # URL of your repository
39//!     git: https://github.com/dora-rs/dora.git
40//!     # the build command that should be invoked after cloning
41//!     build: cargo build -p rust-dataflow-example-node
42//!     # path to the executable that should be run on start
43//!     path: target/debug/rust-dataflow-example-node
44//!     inputs:
45//!       tick: dora/timer/millis/10
46//!     outputs:
47//!       - random
48//! ```
49
50use dora_core::{
51    descriptor::{CoreNodeKind, CustomNode, Descriptor, DescriptorExt},
52    topics::{DORA_COORDINATOR_PORT_WS_DEFAULT, LOCALHOST},
53    types::TypeRegistry,
54};
55use dora_message::{BuildId, common::GitSource, descriptor::NodeSource, id::NodeId};
56use eyre::Context;
57use std::{
58    collections::BTreeMap,
59    net::IpAddr,
60    path::{Path, PathBuf},
61};
62
63use crate::ws_client::WsSession;
64
65use super::{Executable, default_tracing};
66use crate::{
67    common::{
68        canonicalize_working_dir, connect_to_coordinator, local_working_dir, resolve_dataflow,
69        working_dir_or_parent,
70    },
71    session::DataflowSession,
72};
73
74use distributed::{build_distributed_dataflow, wait_until_dataflow_built};
75use local::build_dataflow_locally;
76use lockfile::BuildLockfile;
77
78mod distributed;
79mod git;
80pub mod hub;
81mod local;
82pub(crate) mod lockfile;
83
84#[derive(Debug, clap::Args)]
85/// Run build commands provided in the given dataflow.
86pub struct Build {
87    /// Path to the dataflow descriptor file
88    #[clap(value_name = "PATH")]
89    dataflow: String,
90    /// Address of the dora coordinator
91    #[clap(long, value_name = "IP", env = "DORA_COORDINATOR_ADDR")]
92    coordinator_addr: Option<IpAddr>,
93    /// Port number of the coordinator control server
94    #[clap(long, value_name = "PORT", env = "DORA_COORDINATOR_PORT")]
95    coordinator_port: Option<u16>,
96    // Use UV to build nodes.
97    #[clap(long, action)]
98    uv: bool,
99    // Run build on local machine
100    #[clap(long, action)]
101    local: bool,
102    /// Treat type warnings as errors
103    #[clap(long, action)]
104    strict_types: bool,
105    /// Use pinned git source commits from a lockfile.
106    #[clap(long, action, conflicts_with = "write_lockfile")]
107    locked: bool,
108    /// Write resolved git source commits to a lockfile.
109    #[clap(long, action)]
110    write_lockfile: bool,
111    /// Path to build lockfile (defaults to `<dataflow-stem>.dora-lock.yaml`).
112    #[clap(long, value_name = "PATH")]
113    lockfile: Option<PathBuf>,
114    /// Build nodes concurrently (faster on multi-core machines).
115    #[clap(long, action)]
116    parallel: bool,
117    /// Do not access the network for hub index refreshes; fail loudly on
118    /// cache misses.
119    #[clap(long, action)]
120    offline: bool,
121    /// Substitute a local checkout for a hub package (UC11 inner loop):
122    /// `--hub-override <namespace>/<name>=<path>`. The manifest is read from
123    /// the checkout, contracts are still validated, and the node builds + runs
124    /// from local source — no index resolution for that package. Repeatable;
125    /// local builds only.
126    #[clap(long = "hub-override", value_name = "PKG=PATH")]
127    hub_override: Vec<String>,
128}
129
130impl Executable for Build {
131    fn execute(self) -> eyre::Result<()> {
132        default_tracing()?;
133        build(BuildConfig {
134            dataflow: self.dataflow,
135            coordinator_addr: self.coordinator_addr,
136            coordinator_port: self.coordinator_port,
137            uv: self.uv,
138            force_local: self.local,
139            strict_types: self.strict_types,
140            locked: self.locked,
141            write_lockfile: self.write_lockfile,
142            lockfile_override: self.lockfile,
143            parallel: self.parallel,
144            offline: self.offline,
145            hub_overrides: self.hub_override,
146            ..Default::default()
147        })
148    }
149}
150
151/// Configuration for a [`build`] invocation. Set `dataflow` and
152/// override only the fields you care about using struct-update syntax:
153///
154/// ```ignore
155/// build(BuildConfig {
156///     dataflow: path,
157///     uv: true,
158///     force_local: true,
159///     ..Default::default()
160/// })?;
161/// ```
162#[derive(Debug, Clone, Default)]
163pub struct BuildConfig {
164    pub dataflow: String,
165    pub coordinator_addr: Option<IpAddr>,
166    pub coordinator_port: Option<u16>,
167    pub uv: bool,
168    pub force_local: bool,
169    pub strict_types: bool,
170    pub locked: bool,
171    pub write_lockfile: bool,
172    /// Resolve + write the lockfile, then return WITHOUT building any node.
173    /// Backs `dora hub update`: the full resolve/inject/type-check/lockfile
174    /// pipeline (so the lockfile is identical to a real build's), minus the
175    /// build. Pair with `write_lockfile` to persist, or without it for a
176    /// resolve-only dry run.
177    pub lockfile_only: bool,
178    pub lockfile_override: Option<PathBuf>,
179    pub parallel: bool,
180    /// Skip network access for hub index refreshes (cache only).
181    pub offline: bool,
182    /// `--hub-override <namespace>/<name>=<path>` entries (UC11): substitute a
183    /// local checkout for a hub package. Parsed and validated in [`build`].
184    pub hub_overrides: Vec<String>,
185    /// Overrides the working directory for cargo invocations and
186    /// module expansion. Needed when the dataflow path points at a
187    /// rewritten copy (e.g. a tempfile) whose parent can't resolve the
188    /// original `build:` directives or relative node binaries.
189    pub working_dir_override: Option<PathBuf>,
190}
191
192impl BuildConfig {
193    /// Shared constructor for callers that marshal arguments as
194    /// strings (`coordinator_addr: Option<String>`) and optional
195    /// booleans (`uv: Option<bool>`) rather than as pre-parsed
196    /// `IpAddr` / `bool`. Keeps the `addr.parse()` + `unwrap_or_default`
197    /// glue in one place instead of duplicating it in every binding
198    /// that exposes a simplified build API (PyO3 today, potentially
199    /// a C FFI or WASM shim later).
200    pub fn from_str_args(
201        dataflow: String,
202        uv: Option<bool>,
203        coordinator_addr: Option<String>,
204        coordinator_port: Option<u16>,
205        force_local: bool,
206    ) -> eyre::Result<Self> {
207        Ok(Self {
208            dataflow,
209            coordinator_addr: coordinator_addr
210                .map(|addr| addr.parse())
211                .transpose()
212                .wrap_err("invalid coordinator_addr")?,
213            coordinator_port,
214            uv: uv.unwrap_or_default(),
215            force_local,
216            ..Default::default()
217        })
218    }
219}
220
221pub fn build(cfg: BuildConfig) -> eyre::Result<()> {
222    let BuildConfig {
223        dataflow,
224        coordinator_addr,
225        coordinator_port,
226        uv,
227        force_local,
228        strict_types,
229        locked,
230        write_lockfile,
231        lockfile_only,
232        lockfile_override,
233        parallel,
234        offline,
235        hub_overrides,
236        working_dir_override,
237    } = cfg;
238    // Parse `--hub-override <namespace>/<name>=<path>` into key -> canonical
239    // local dir. The key matches a node's resolved `hub:` reference key.
240    let mut hub_override_dirs: BTreeMap<String, PathBuf> = BTreeMap::new();
241    for spec in &hub_overrides {
242        let (pkg, path) = spec.split_once('=').ok_or_else(|| {
243            eyre::eyre!("invalid --hub-override `{spec}`: expected `<namespace>/<name>=<path>`")
244        })?;
245        let key = dora_hub_client::reference::PackageRef::parse(pkg.trim())
246            .with_context(|| format!("invalid --hub-override package `{pkg}`"))?
247            .key();
248        let dir = std::fs::canonicalize(path.trim()).with_context(|| {
249            format!("invalid --hub-override path `{}` for `{pkg}`", path.trim())
250        })?;
251        hub_override_dirs.insert(key, dir);
252    }
253    // `BuildConfig` derives `Default` so `..Default::default()` works at
254    // call sites, but that gives `dataflow: String::new()` which would
255    // fail late with a confusing "failed to read ``" error. Catch it up
256    // front with a clear message.
257    if dataflow.is_empty() {
258        eyre::bail!(
259            "BuildConfig::dataflow is empty — set it to a YAML path or URL before calling build()"
260        );
261    }
262    let dataflow_path = resolve_dataflow(dataflow).context("could not resolve dataflow")?;
263    if lockfile_override.is_some() && !(locked || write_lockfile) {
264        eyre::bail!("`--lockfile` requires either `--locked` or `--write-lockfile`");
265    }
266    let working_dir = working_dir_or_parent(working_dir_override.as_deref(), &dataflow_path);
267    let mut dataflow_descriptor = Descriptor::blocking_read(&dataflow_path)
268        .wrap_err_with(|| {
269            format!(
270                "failed to read dataflow at `{}`\n\n  \
271                 hint: check the file exists, is valid YAML, and matches the dataflow schema (see details below)",
272                dataflow_path.display()
273            )
274        })?
275        .expand(working_dir)
276        .wrap_err("failed to expand modules in dataflow descriptor")?;
277
278    // `--hub-override` is a local-only inner-loop feature (the checkout exists
279    // only on this machine). If it was *requested* at all, reject combining it
280    // with a distributed build or with lockfile generation here — before any
281    // index resolution or lockfile write. Keyed on the requested overrides, not
282    // the ones that matched a node, so a typo'd package name can't silently
283    // fall through to a distributed build or clobber the lockfile.
284    if !hub_override_dirs.is_empty() {
285        if coordinator_addr.is_some() || coordinator_port.is_some() {
286            eyre::bail!(
287                "`--hub-override` is a local build feature and cannot be combined with a remote \
288                 coordinator (`--coordinator-addr`/`--coordinator-port`)"
289            );
290        }
291        if !force_local && dataflow_descriptor.nodes.iter().any(|n| n.deploy.is_some()) {
292            eyre::bail!(
293                "`--hub-override` is a local build feature and cannot be used with a distributed \
294                 (`deploy:`) dataflow — the local checkout only exists on this machine. Use \
295                 `--local` to force a fully local build if that is what you want."
296            );
297        }
298        if write_lockfile {
299            eyre::bail!(
300                "`--hub-override` cannot be combined with `--write-lockfile`: the override \
301                 substitutes local source for a hub node, so the regenerated lockfile would drop \
302                 that node's hub pin. Drop `--write-lockfile` (or the override) when refreshing \
303                 the lockfile."
304            );
305        }
306    }
307
308    // Digest the expanded descriptor before hub desugaring so `dora start` /
309    // `dora daemon --run-dataflow` can detect on-disk edits to a hub dataflow
310    // (whose unresolved `hub:` references can't be re-fingerprinted directly).
311    let has_hub_nodes = dataflow_descriptor.nodes.iter().any(|n| n.hub.is_some());
312    let source_fingerprint = has_hub_nodes
313        .then(|| DataflowSession::fingerprint_source(&dataflow_descriptor))
314        .flatten();
315
316    // --- Type checking (Phase 1) ---
317    let strict = strict_types || dataflow_descriptor.strict_types.unwrap_or(false);
318    let mut registry = TypeRegistry::new();
319    let types_dir = working_dir.join("types");
320    if types_dir.is_dir() {
321        match registry.load_from_dir(&types_dir) {
322            Ok(count) if count > 0 => {
323                log::info!("Loaded {count} user-defined type(s) from types/");
324            }
325            Err(e) => {
326                eyre::bail!("failed to load user types: {e}");
327            }
328            _ => {}
329        }
330    }
331    // The lockfile is read before hub resolution: under `--locked`, hub
332    // references use the pinned commits verbatim, no index resolution.
333    let lockfile_path = BuildLockfile::path_for_dataflow(&dataflow_path, lockfile_override);
334    let build_lockfile = if locked {
335        Some(BuildLockfile::read_from(&lockfile_path).with_context(|| {
336            format!(
337                "failed to read build lockfile at `{}`",
338                lockfile_path.display()
339            )
340        })?)
341    } else {
342        None
343    };
344    // Desugar hub: nodes into concrete git nodes (spec §10.1) — after module
345    // expansion, before type-checking
346    let hub_pins = build_lockfile.as_ref().map(|l| l.git_sources.clone());
347    let hub_binary_pins = build_lockfile.as_ref().map(|l| l.binary_sources.clone());
348    let hub_resolution = hub::resolve_hub_nodes(
349        &mut dataflow_descriptor,
350        &mut registry,
351        offline,
352        hub_pins.as_ref(),
353        hub_binary_pins.as_ref(),
354        // `dora build --locked` is the strict reproducible path.
355        locked,
356        &hub_override_dirs,
357    )?;
358    let hub_override_node_dirs = hub_resolution.override_dirs.clone();
359    for note in &hub_resolution.notes {
360        println!("  {note}");
361    }
362    for warning in &hub_resolution.warnings {
363        eprintln!("  warning: {warning}");
364    }
365    let resolved_dataflow_for_session =
366        (!hub_resolution.is_empty()).then(|| dataflow_descriptor.clone());
367    // Inject contracts from node manifests adjacent to path: nodes (§6.2)
368    let injection = dora_core::manifest::inject::inject_adjacent_manifests(
369        &mut dataflow_descriptor,
370        working_dir,
371        &mut registry,
372    );
373    for note in &injection.notes {
374        println!("  {note}");
375    }
376    let type_result = dora_core::descriptor::validate::check_type_annotations_full(
377        &dataflow_descriptor,
378        &registry,
379        strict,
380    );
381    for inf in &type_result.inferences {
382        println!("  {inf}");
383    }
384    let warning_count = injection.warnings.len() + type_result.warnings.len();
385    if warning_count > 0 {
386        for w in &injection.warnings {
387            eprintln!("  warning: {w}");
388        }
389        for w in &type_result.warnings {
390            eprintln!("  warning: {w}");
391        }
392        if strict {
393            eyre::bail!("{warning_count} type error(s) found (strict mode)");
394        } else {
395            eprintln!(
396                "{warning_count} type warning(s) found.\n  \
397                 hint: use --strict-types to fail on type warnings"
398            );
399        }
400    }
401
402    let mut git_sources = BTreeMap::new();
403    let mut descriptor_git_sources = BTreeMap::new();
404    let resolved_nodes = dataflow_descriptor
405        .resolve_aliases_and_set_defaults()
406        .context("failed to resolve nodes")?;
407    // Compute the session-level build-inputs fingerprint up front, while we
408    // still hold a borrow of `resolved_nodes` (Pass 2 below consumes it).
409    // This is what `dora start` / `dora run` / `dora daemon --run-dataflow`
410    // compare against to decide whether the cached `build_id` is still
411    // valid for the current descriptor (#1444).
412    let session_build_fingerprint = DataflowSession::fingerprint_build_inputs(&resolved_nodes);
413    // Pass 1 (fail-fast): derive descriptor git-source fingerprint and validate lockfile
414    // provenance before any per-node locked-source lookups.
415    for (node_id, node) in &resolved_nodes {
416        if let CoreNodeKind::Custom(CustomNode {
417            source: NodeSource::GitBranch { repo, rev },
418            ..
419        }) = &node.kind
420        {
421            descriptor_git_sources.insert(
422                node_id.clone(),
423                NodeSource::GitBranch {
424                    repo: repo.clone(),
425                    rev: rev.clone(),
426                },
427            );
428        }
429    }
430    let descriptor_fingerprint =
431        BuildLockfile::fingerprint_descriptor_git_sources(&descriptor_git_sources);
432    if let Some(lockfile) = &build_lockfile {
433        lockfile
434            .ensure_descriptor_fingerprint_matches(&descriptor_fingerprint)
435            .with_context(|| {
436                format!(
437                    "failed to validate lockfile against descriptor at `{}`",
438                    dataflow_path.display()
439                )
440            })?;
441    }
442    // Pass 2: resolve each node's concrete source, now that lockfile provenance
443    // has been validated (when `--locked` is enabled).
444    for (node_id, node) in resolved_nodes {
445        if let CoreNodeKind::Custom(CustomNode {
446            source: NodeSource::GitBranch { repo, rev },
447            ..
448        }) = node.kind
449        {
450            // hub-desugared nodes are already pinned to a commit (and carry
451            // subdir + provenance) — no ref resolution needed
452            let source = match hub_resolution.sources.get(&node_id) {
453                Some(source) => source.clone(),
454                None => match &build_lockfile {
455                    Some(lockfile) => lockfile.get_source(&node_id, &repo).with_context(|| {
456                        format!("failed to resolve locked git source `{node_id}`")
457                    })?,
458                    None => git::fetch_commit_hash(repo, rev)
459                        .with_context(|| format!("failed to find commit hash for `{node_id}`"))?,
460                },
461            };
462            git_sources.insert(node_id, source);
463        }
464    }
465    if write_lockfile {
466        BuildLockfile::write_git_sources(
467            &lockfile_path,
468            &git_sources,
469            &hub_resolution.binary_sources,
470            &descriptor_fingerprint,
471        )
472        .with_context(|| {
473            format!(
474                "failed to write build lockfile to `{}`",
475                lockfile_path.display()
476            )
477        })?;
478        log::info!("wrote build lockfile to {}", lockfile_path.display());
479    }
480
481    // `dora hub update`: the lockfile (and all its validation) is the whole
482    // point — stop before touching the coordinator or building any node.
483    if lockfile_only {
484        return Ok(());
485    }
486
487    // Read (creating if absent) the session file only once we know we'll build —
488    // `read_session` writes `out/<name>.dora-session.yaml` + `.gitignore`, which
489    // an `--dry-run`/`lockfile_only` resolve must not do.
490    let mut dataflow_session =
491        DataflowSession::read_session(&dataflow_path).context("failed to read DataflowSession")?;
492
493    let session = || connect_to_coordinator_with_defaults(coordinator_addr, coordinator_port);
494
495    // `--hub-override` implies a local build (validated above). Force it even
496    // when the override matched no node: the override-vs-distributed conflict
497    // was already rejected, so the remaining cases are safe to build locally.
498    let build_kind = if !hub_override_dirs.is_empty() {
499        log::info!("Building locally because `--hub-override` was given");
500        BuildKind::Local
501    } else if force_local {
502        log::info!("Building locally, as requested through `--force-local`");
503        BuildKind::Local
504    } else if dataflow_descriptor.nodes.iter().all(|n| n.deploy.is_none()) {
505        log::info!("Building locally because dataflow does not contain any `deploy` sections");
506        BuildKind::Local
507    } else if coordinator_addr.is_some() || coordinator_port.is_some() {
508        log::info!("Building through coordinator, using the given coordinator socket information");
509        // explicit coordinator address or port set -> there should be a coordinator running
510        BuildKind::ThroughCoordinator {
511            coordinator_session: session().context("failed to connect to coordinator")?,
512        }
513    } else {
514        match session() {
515            Ok(coordinator_session) => {
516                // we found a local coordinator instance at default port -> use it for building
517                log::info!("Found local dora coordinator instance -> building through coordinator");
518                BuildKind::ThroughCoordinator {
519                    coordinator_session,
520                }
521            }
522            Err(_) => {
523                log::warn!("No dora coordinator instance found -> trying a local build");
524                // no coordinator instance found -> do a local build
525                BuildKind::Local
526            }
527        }
528    };
529
530    match build_kind {
531        BuildKind::Local => {
532            log::info!("running local build");
533            let local_working_dir =
534                canonicalize_working_dir(working_dir_override.as_deref(), &dataflow_path)?;
535            let build_info = build_dataflow_locally(
536                dataflow_descriptor,
537                &git_sources,
538                &dataflow_session,
539                local_working_dir,
540                uv,
541                parallel,
542                &hub_override_node_dirs,
543            )?;
544
545            dataflow_session.git_sources = git_sources;
546            // Reuse existing build_id if git sources are unchanged and
547            // a prior build already exists. This preserves the
548            // association between the build_id and the git clone
549            // directories so `dora run`'s internal rebuild doesn't
550            // orphan them.
551            if dataflow_session.build_id.is_none() {
552                dataflow_session.build_id = Some(BuildId::generate());
553            }
554            dataflow_session.local_build = Some(build_info);
555            // Record the build-inputs fingerprint so subsequent `dora start`
556            // / `dora run` / `dora daemon --run-dataflow` invocations can
557            // detect descriptor drift and invalidate cached build metadata
558            // (#1444).
559            dataflow_session.build_fingerprint = Some(session_build_fingerprint.clone());
560            // hub: nodes were desugared in memory — `dora start`/`dora run`
561            // re-read the YAML from disk and need the resolved form
562            dataflow_session.resolved_dataflow = resolved_dataflow_for_session.clone();
563            dataflow_session.source_fingerprint = source_fingerprint.clone();
564            dataflow_session
565                .write_out_for_dataflow(&dataflow_path)
566                .context("failed to write out dataflow session file")?;
567        }
568        BuildKind::ThroughCoordinator {
569            coordinator_session,
570        } => {
571            let inferred_local_working_dir =
572                local_working_dir(&dataflow_path, &dataflow_descriptor, &coordinator_session)?;
573            let local_working_dir = select_distributed_working_dir(
574                working_dir_override.as_deref(),
575                inferred_local_working_dir,
576                &dataflow_path,
577            )?;
578            let build_id = build_distributed_dataflow(
579                &coordinator_session,
580                dataflow_descriptor,
581                &git_sources,
582                &dataflow_session,
583                local_working_dir,
584                uv,
585            )?;
586
587            // wait until dataflow build is finished
588            let build_result =
589                wait_until_dataflow_built(build_id, &coordinator_session, log::LevelFilter::Info);
590
591            dataflow_session.resolved_dataflow = resolved_dataflow_for_session.clone();
592            dataflow_session.source_fingerprint = source_fingerprint.clone();
593            finalize_distributed_build_session(
594                &mut dataflow_session,
595                &dataflow_path,
596                git_sources,
597                build_result,
598                session_build_fingerprint,
599            )?;
600        }
601    };
602
603    Ok(())
604}
605
606enum BuildKind {
607    Local,
608    ThroughCoordinator { coordinator_session: WsSession },
609}
610
611fn connect_to_coordinator_with_defaults(
612    coordinator_addr: Option<std::net::IpAddr>,
613    coordinator_port: Option<u16>,
614) -> eyre::Result<WsSession> {
615    let coordinator_addr = coordinator_addr.unwrap_or(LOCALHOST);
616    let coordinator_port = coordinator_port.unwrap_or(DORA_COORDINATOR_PORT_WS_DEFAULT);
617    connect_to_coordinator((coordinator_addr, coordinator_port).into())
618}
619
620fn select_distributed_working_dir(
621    working_dir_override: Option<&Path>,
622    inferred_local_working_dir: Option<PathBuf>,
623    dataflow_path: &Path,
624) -> eyre::Result<Option<PathBuf>> {
625    match (working_dir_override, inferred_local_working_dir) {
626        (Some(override_), Some(_)) => {
627            let canonical = canonicalize_working_dir(Some(override_), dataflow_path)?;
628            Ok(Some(canonical))
629        }
630        (Some(_), None) => eyre::bail!(
631            "`working_dir_override` can only be used for single-machine coordinator builds where CLI and daemon run on the same machine"
632        ),
633        (None, inferred) => Ok(inferred),
634    }
635}
636
637fn finalize_distributed_build_session(
638    dataflow_session: &mut DataflowSession,
639    dataflow_path: &Path,
640    git_sources: BTreeMap<NodeId, GitSource>,
641    build_result: eyre::Result<BuildId>,
642    session_build_fingerprint: String,
643) -> eyre::Result<()> {
644    let build_id = build_result?;
645
646    dataflow_session.git_sources = git_sources;
647    dataflow_session.build_id = Some(build_id);
648    dataflow_session.local_build = None;
649    // Same fingerprint-recording rationale as the local-build branch above
650    // (#1444). The session file is the source of truth for "what descriptor
651    // produced this `build_id`."
652    dataflow_session.build_fingerprint = Some(session_build_fingerprint);
653    dataflow_session
654        .write_out_for_dataflow(dataflow_path)
655        .context("failed to write out dataflow session file")?;
656
657    Ok(())
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn from_str_args_parses_valid_coordinator_addr() {
666        let cfg = BuildConfig::from_str_args(
667            "dataflow.yml".into(),
668            None,
669            Some("127.0.0.1".into()),
670            None,
671            false,
672        )
673        .expect("valid IP should parse");
674        assert_eq!(
675            cfg.coordinator_addr,
676            Some("127.0.0.1".parse::<IpAddr>().unwrap())
677        );
678    }
679
680    #[test]
681    fn from_str_args_accepts_none_addr() {
682        let cfg = BuildConfig::from_str_args("dataflow.yml".into(), None, None, None, false)
683            .expect("None addr should be fine");
684        assert!(cfg.coordinator_addr.is_none());
685    }
686
687    #[test]
688    fn from_str_args_errors_on_invalid_addr() {
689        let err = BuildConfig::from_str_args(
690            "dataflow.yml".into(),
691            None,
692            Some("not-an-ip".into()),
693            None,
694            false,
695        )
696        .expect_err("malformed addr should error");
697        assert!(
698            err.to_string().contains("invalid coordinator_addr"),
699            "error should carry context: {err}"
700        );
701    }
702
703    #[test]
704    fn from_str_args_unwraps_uv_default_to_false() {
705        let cfg =
706            BuildConfig::from_str_args("dataflow.yml".into(), None, None, None, false).unwrap();
707        assert!(!cfg.uv, "uv should default to false when None");
708    }
709
710    fn unique_temp_path(name: &str) -> PathBuf {
711        let nanos = std::time::SystemTime::now()
712            .duration_since(std::time::UNIX_EPOCH)
713            .unwrap()
714            .as_nanos();
715        std::env::temp_dir().join(format!(
716            "dora-build-mod-tests-{name}-{}-{nanos}",
717            std::process::id()
718        ))
719    }
720
721    #[test]
722    fn distributed_override_is_used_when_local_working_dir_allowed() {
723        let root = unique_temp_path("override-ok");
724        std::fs::create_dir_all(&root).unwrap();
725
726        let selected = select_distributed_working_dir(
727            Some(root.as_path()),
728            Some(PathBuf::from("/tmp/inferred")),
729            Path::new("/tmp/dataflow.yml"),
730        )
731        .unwrap();
732
733        assert_eq!(selected, Some(dunce::canonicalize(&root).unwrap()));
734        std::fs::remove_dir_all(root).unwrap();
735    }
736
737    #[test]
738    fn distributed_override_is_rejected_for_non_local_builds() {
739        let root = unique_temp_path("override-reject");
740        std::fs::create_dir_all(&root).unwrap();
741
742        let err = select_distributed_working_dir(
743            Some(root.as_path()),
744            None,
745            Path::new("/tmp/dataflow.yml"),
746        )
747        .unwrap_err();
748
749        assert!(
750            err.to_string()
751                .contains("can only be used for single-machine coordinator builds")
752        );
753        std::fs::remove_dir_all(root).unwrap();
754    }
755
756    #[test]
757    fn distributed_without_override_uses_inferred_working_dir() {
758        let inferred = Some(PathBuf::from("/tmp/inferred"));
759        let selected =
760            select_distributed_working_dir(None, inferred.clone(), Path::new("/tmp/dataflow.yml"))
761                .unwrap();
762        assert_eq!(selected, inferred);
763    }
764
765    fn git_source(repo: &str, commit_hash: &str) -> GitSource {
766        GitSource {
767            repo: repo.to_owned(),
768            commit_hash: commit_hash.to_owned(),
769            subdir: None,
770            hub: None,
771        }
772    }
773
774    #[test]
775    fn distributed_build_failure_does_not_persist_new_git_sources() {
776        let tmp = tempfile::tempdir().unwrap();
777        let dataflow_path = tmp.path().join("dataflow.yml");
778        std::fs::write(&dataflow_path, "nodes: []\n").unwrap();
779
780        let old_build_id = BuildId::generate();
781        let old_git_sources = BTreeMap::from([(
782            "old-node".parse().unwrap(),
783            git_source("https://example.com/old.git", "old-commit"),
784        )]);
785        let initial_session = DataflowSession {
786            build_id: Some(old_build_id),
787            git_sources: old_git_sources.clone(),
788            build_fingerprint: Some("old-fingerprint".to_owned()),
789            ..DataflowSession::default()
790        };
791        let mut in_memory_session = initial_session.clone();
792        initial_session
793            .write_out_for_dataflow(&dataflow_path)
794            .unwrap();
795
796        let new_git_sources = BTreeMap::from([(
797            "new-node".parse().unwrap(),
798            git_source("https://example.com/new.git", "new-commit"),
799        )]);
800        let err = finalize_distributed_build_session(
801            &mut in_memory_session,
802            &dataflow_path,
803            new_git_sources,
804            Err(eyre::eyre!("coordinator build failed")),
805            "new-fingerprint".to_owned(),
806        )
807        .unwrap_err();
808
809        assert!(err.to_string().contains("coordinator build failed"));
810        assert_eq!(in_memory_session.build_id, Some(old_build_id));
811        assert_eq!(in_memory_session.git_sources, old_git_sources);
812        assert_eq!(
813            in_memory_session.build_fingerprint.as_deref(),
814            Some("old-fingerprint")
815        );
816
817        let persisted = DataflowSession::read_session(&dataflow_path).unwrap();
818        assert_eq!(persisted.build_id, initial_session.build_id);
819        assert_eq!(persisted.git_sources, initial_session.git_sources);
820        assert_eq!(
821            persisted.build_fingerprint,
822            initial_session.build_fingerprint
823        );
824    }
825
826    #[test]
827    fn distributed_build_success_persists_git_sources_and_build_id() {
828        let tmp = tempfile::tempdir().unwrap();
829        let dataflow_path = tmp.path().join("dataflow.yml");
830        std::fs::write(&dataflow_path, "nodes: []\n").unwrap();
831
832        let mut session = DataflowSession::default();
833        session.write_out_for_dataflow(&dataflow_path).unwrap();
834
835        let build_id = BuildId::generate();
836        let git_sources = BTreeMap::from([(
837            "node".parse().unwrap(),
838            git_source("https://example.com/repo.git", "abc123"),
839        )]);
840        finalize_distributed_build_session(
841            &mut session,
842            &dataflow_path,
843            git_sources.clone(),
844            Ok(build_id),
845            "new-fingerprint".to_owned(),
846        )
847        .unwrap();
848
849        assert_eq!(session.build_id, Some(build_id));
850        assert_eq!(session.git_sources, git_sources);
851        assert!(session.local_build.is_none());
852        assert_eq!(
853            session.build_fingerprint.as_deref(),
854            Some("new-fingerprint")
855        );
856
857        let persisted = DataflowSession::read_session(&dataflow_path).unwrap();
858        assert_eq!(persisted.build_id, Some(build_id));
859        assert_eq!(persisted.git_sources, session.git_sources);
860        assert!(persisted.local_build.is_none());
861        assert_eq!(
862            persisted.build_fingerprint.as_deref(),
863            Some("new-fingerprint")
864        );
865    }
866}