1use 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)]
85pub struct Build {
87 #[clap(value_name = "PATH")]
89 dataflow: String,
90 #[clap(long, value_name = "IP", env = "DORA_COORDINATOR_ADDR")]
92 coordinator_addr: Option<IpAddr>,
93 #[clap(long, value_name = "PORT", env = "DORA_COORDINATOR_PORT")]
95 coordinator_port: Option<u16>,
96 #[clap(long, action)]
98 uv: bool,
99 #[clap(long, action)]
101 local: bool,
102 #[clap(long, action)]
104 strict_types: bool,
105 #[clap(long, action, conflicts_with = "write_lockfile")]
107 locked: bool,
108 #[clap(long, action)]
110 write_lockfile: bool,
111 #[clap(long, value_name = "PATH")]
113 lockfile: Option<PathBuf>,
114 #[clap(long, action)]
116 parallel: bool,
117 #[clap(long, action)]
120 offline: bool,
121 #[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#[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 pub lockfile_only: bool,
178 pub lockfile_override: Option<PathBuf>,
179 pub parallel: bool,
180 pub offline: bool,
182 pub hub_overrides: Vec<String>,
185 pub working_dir_override: Option<PathBuf>,
190}
191
192impl BuildConfig {
193 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 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 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 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 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 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 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 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 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 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 ®istry,
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 let session_build_fingerprint = DataflowSession::fingerprint_build_inputs(&resolved_nodes);
413 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 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 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 if lockfile_only {
484 return Ok(());
485 }
486
487 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 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 BuildKind::ThroughCoordinator {
511 coordinator_session: session().context("failed to connect to coordinator")?,
512 }
513 } else {
514 match session() {
515 Ok(coordinator_session) => {
516 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 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 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 dataflow_session.build_fingerprint = Some(session_build_fingerprint.clone());
560 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 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 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}