1mod build;
2mod clean;
3mod cluster;
4mod completion;
5mod coordinator;
6mod daemon;
7mod doctor;
8mod down;
9mod expand;
10mod graph;
11mod hub;
12pub mod inspect;
13mod list;
14mod logs;
15mod new;
16mod node;
17mod node_binary;
18mod param;
19mod record;
20mod replay;
21mod restart;
22mod run;
23mod runtime;
24mod self_;
25mod start;
26mod stop;
27mod system;
28mod topic;
29mod trace;
30mod up;
31mod validate;
32
33pub use build::{BuildConfig, build};
34pub use run::{Run, run};
35pub(crate) use up::set_python_executable_path;
36
37use build::Build;
38use clean::CleanArgs;
39use cluster::Cluster;
40use completion::Completion;
41use coordinator::Coordinator;
42use daemon::Daemon;
43use doctor::Doctor;
44use down::Down;
45use expand::Expand;
46use eyre::Context;
47use graph::Graph;
48use hub::Hub;
49use inspect::Inspect;
50use list::ListArgs;
51use logs::LogsArgs;
52use new::NewArgs;
53use node::Node;
54use param::Param;
55use record::Record;
56use replay::Replay;
57use restart::Restart;
58use runtime::Runtime;
59use self_::SelfSubCommand;
60use start::Start;
61use stop::Stop;
62use system::System;
63use topic::Topic;
64use trace::Trace;
65use up::Up;
66use validate::Validate;
67
68#[derive(Debug, clap::Subcommand)]
70pub enum Command {
71 #[clap(display_order = 1)]
74 Run(Run),
75 #[clap(display_order = 2)]
77 Up(Up),
78 #[clap(name = "down", alias = "destroy", display_order = 3)]
80 Down(Down),
81 #[clap(subcommand, display_order = 4)]
83 Cluster(Cluster),
84 #[clap(display_order = 5)]
86 Build(Build),
87 #[clap(display_order = 6)]
89 Start(Start),
90 #[clap(display_order = 7)]
92 Stop(Stop),
93 #[clap(display_order = 8)]
95 Restart(Restart),
96
97 #[clap(alias = "ps", display_order = 10)]
100 List(ListArgs),
101 #[clap(display_order = 10)]
103 Clean(CleanArgs),
104 #[clap(display_order = 11)]
106 Logs(LogsArgs),
107 #[clap(subcommand, display_order = 12)]
109 Inspect(Inspect),
110 #[clap(subcommand, display_order = 13)]
112 Topic(Topic),
113 #[clap(subcommand, display_order = 14)]
115 Node(Node),
116 #[clap(subcommand, display_order = 15)]
118 Param(Param),
119 #[clap(display_order = 16)]
121 Record(Record),
122 #[clap(display_order = 17)]
124 Replay(Replay),
125 #[clap(subcommand, display_order = 18)]
127 Trace(Trace),
128
129 #[clap(alias = "check", display_order = 20)]
132 Status(system::status::Status),
133 #[clap(display_order = 19)]
135 Doctor(Doctor),
136 #[clap(display_order = 21)]
138 New(NewArgs),
139 #[clap(display_order = 22)]
141 Graph(Graph),
142 #[clap(display_order = 23)]
144 Expand(Expand),
145 #[clap(display_order = 24)]
147 Validate(Validate),
148 #[clap(subcommand, display_order = 25)]
150 System(System),
151 #[clap(subcommand, display_order = 26)]
153 Hub(Hub),
154
155 #[clap(display_order = 30)]
158 Completion(Completion),
159 #[clap(display_order = 31)]
161 Self_ {
162 #[clap(subcommand)]
163 command: SelfSubCommand,
164 },
165
166 #[clap(hide = true)]
168 Daemon(Daemon),
169 #[clap(hide = true)]
170 Runtime(Runtime),
171 #[clap(hide = true)]
172 Coordinator(Coordinator),
173 #[clap(display_order = 12)]
175 Top(inspect::top::Top),
176}
177
178fn default_tracing() -> eyre::Result<()> {
179 #[cfg(feature = "tracing")]
180 {
181 use dora_tracing::TracingBuilder;
182
183 TracingBuilder::new("dora-cli")
184 .with_stdout("warn", false)
185 .build()
186 .wrap_err("failed to set up tracing subscriber")?;
187 }
188 Ok(())
189}
190
191pub trait Executable {
192 fn execute(self) -> eyre::Result<()>;
193}
194
195impl Executable for Command {
196 fn execute(self) -> eyre::Result<()> {
197 match self {
198 Command::Run(args) => args.execute(),
199 Command::Up(args) => args.execute(),
200 Command::Down(args) => args.execute(),
201 Command::Cluster(args) => args.execute(),
202 Command::Build(args) => args.execute(),
203 Command::Start(args) => args.execute(),
204 Command::Stop(args) => args.execute(),
205 Command::Restart(args) => args.execute(),
206 Command::List(args) => args.execute(),
207 Command::Clean(args) => args.execute(),
208 Command::Logs(args) => args.execute(),
209 Command::Inspect(args) => args.execute(),
210 Command::Topic(args) => args.execute(),
211 Command::Node(args) => args.execute(),
212 Command::Param(args) => args.execute(),
213 Command::Record(args) => args.execute(),
214 Command::Replay(args) => args.execute(),
215 Command::Trace(args) => args.execute(),
216 Command::Status(args) => args.execute(),
217 Command::Doctor(args) => args.execute(),
218 Command::New(args) => args.execute(),
219 Command::Graph(args) => args.execute(),
220 Command::Expand(args) => args.execute(),
221 Command::Validate(args) => args.execute(),
222 Command::System(args) => args.execute(),
223 Command::Hub(args) => args.execute(),
224 Command::Completion(args) => args.execute(),
225 Command::Self_ { command } => command.execute(),
226 Command::Daemon(args) => args.execute(),
227 Command::Runtime(args) => args.execute(),
228 Command::Coordinator(args) => args.execute(),
229 Command::Top(args) => args.execute(),
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use crate::Args;
237 use clap::{CommandFactory, Parser};
238
239 #[test]
240 fn verify_cli() {
241 Args::command().debug_assert();
242 }
243
244 fn parse_ok(args: &[&str]) {
245 Args::try_parse_from(args).unwrap_or_else(|e| panic!("failed to parse {args:?}: {e}"));
246 }
247
248 #[test]
252 fn help_documents_json_output_shape_for_every_format_flag() {
253 let cases: &[(&[&str], &str)] = &[
254 (
255 &["dora", "list", "--help"],
256 "JSON Lines (one object per line)",
257 ),
258 (
259 &["dora", "clean", "--help"],
260 "JSON Lines (one object per line)",
261 ),
262 (
263 &["dora", "topic", "list", "--help"],
264 "JSON Lines (one object per line)",
265 ),
266 (
267 &["dora", "topic", "echo", "--help"],
268 "JSON Lines (one object per decoded message)",
269 ),
270 (
271 &["dora", "logs", "--help"],
272 "JSON Lines (one object per log message)",
273 ),
274 (
275 &["dora", "run", "--help"],
276 "JSON Lines (one object per log message)",
277 ),
278 (
279 &["dora", "status", "--help"],
281 "a single pretty-printed JSON document",
282 ),
283 (
284 &["dora", "ps", "--help"],
286 "JSON Lines (one object per line)",
287 ),
288 (
289 &["dora", "node", "list", "--help"],
290 "JSON Lines (one object per line)",
291 ),
292 (
293 &["dora", "node", "info", "--help"],
294 "a single pretty-printed JSON document",
295 ),
296 (
297 &["dora", "system", "status", "--help"],
298 "a single pretty-printed JSON document",
299 ),
300 (
301 &["dora", "param", "list", "--help"],
302 "a single pretty-printed JSON document",
303 ),
304 ];
305 for (args, expected) in cases {
306 let error = Args::try_parse_from(*args).expect_err("--help should stop parsing");
307 assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
308 let help = error.to_string();
309 assert!(
310 help.contains(expected),
311 "help for {args:?} must contain {expected:?}, got:\n{help}"
312 );
313 }
314 }
315
316 fn parse_err(args: &[&str]) {
317 assert!(
318 Args::try_parse_from(args).is_err(),
319 "expected parse error for {args:?}"
320 );
321 }
322
323 #[test]
324 fn parse_run() {
325 parse_ok(&["dora", "run", "foo.yml"]);
326 }
327
328 #[test]
329 fn parse_run_locked() {
330 parse_ok(&["dora", "run", "foo.yml", "--locked"]);
331 }
332
333 #[test]
334 fn reject_run_locked_and_write_lockfile() {
335 parse_err(&["dora", "run", "foo.yml", "--locked", "--write-lockfile"]);
336 }
337
338 #[test]
339 fn parse_up() {
340 parse_ok(&["dora", "up"]);
341 parse_ok(&["dora", "up", "--recreate-store"]);
342 }
343
344 #[test]
345 fn parse_down() {
346 parse_ok(&["dora", "down"]);
347 }
348
349 #[test]
350 fn parse_start() {
351 parse_ok(&["dora", "start", "foo.yml"]);
352 }
353
354 #[test]
355 fn parse_stop_uuid() {
356 parse_ok(&["dora", "stop", "a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6"]);
357 }
358
359 #[test]
360 fn parse_list() {
361 parse_ok(&["dora", "list"]);
362 }
363
364 #[test]
365 fn parse_clean() {
366 parse_ok(&["dora", "clean"]);
367 parse_ok(&["dora", "clean", "--format", "json"]);
368 parse_ok(&["dora", "clean", "-q"]);
369 parse_err(&["dora", "clean", "-q", "--format", "table"]);
371 }
372
373 #[test]
374 fn parse_logs() {
375 parse_ok(&["dora", "logs"]);
376 }
377
378 #[test]
379 fn parse_logs_dataflow() {
380 parse_ok(&["dora", "logs", "my-dataflow"]);
381 }
382
383 #[test]
384 fn parse_logs_node_flag() {
385 parse_ok(&["dora", "logs", "--node", "sensor"]);
386 parse_ok(&["dora", "logs", "-n", "sensor"]);
387 }
388
389 #[test]
390 fn parse_daemon_worker_threads() {
391 parse_ok(&["dora", "daemon", "--worker-threads", "4"]);
392 parse_ok(&["dora", "daemon", "--worker-threads", "1"]);
393 }
394
395 #[test]
396 fn reject_daemon_worker_threads_zero() {
397 parse_err(&["dora", "daemon", "--worker-threads", "0"]);
401 }
402
403 #[test]
404 fn parse_logs_dataflow_node_flag() {
405 parse_ok(&["dora", "logs", "my-dataflow", "--node", "sensor"]);
406 }
407
408 #[test]
409 fn parse_logs_all_nodes() {
410 parse_ok(&["dora", "logs", "my-dataflow", "--all-nodes"]);
411 }
412
413 #[test]
414 fn parse_logs_legacy_positional_node_for_runtime_hint() {
415 parse_ok(&["dora", "logs", "my-dataflow", "sensor"]);
416 }
417
418 #[test]
419 fn reject_logs_legacy_positional_node_with_node_flag() {
420 parse_err(&["dora", "logs", "my-dataflow", "sensor", "--node", "other"]);
421 }
422
423 #[test]
424 fn reject_logs_legacy_positional_node_with_all_nodes_flag() {
425 parse_err(&["dora", "logs", "my-dataflow", "sensor", "--all-nodes"]);
426 }
427
428 #[test]
429 fn reject_logs_node_and_all_nodes() {
430 parse_err(&[
431 "dora",
432 "logs",
433 "my-dataflow",
434 "--node",
435 "sensor",
436 "--all-nodes",
437 ]);
438 }
439
440 #[test]
441 fn parse_build() {
442 parse_ok(&["dora", "build", "foo.yml"]);
443 }
444
445 #[test]
446 fn parse_build_locked() {
447 parse_ok(&["dora", "build", "foo.yml", "--locked"]);
448 }
449
450 #[test]
451 fn reject_build_locked_and_write_lockfile() {
452 parse_err(&["dora", "build", "foo.yml", "--locked", "--write-lockfile"]);
453 }
454
455 #[test]
456 fn parse_graph() {
457 parse_ok(&["dora", "graph", "foo.yml"]);
458 }
459
460 #[test]
461 fn parse_expand() {
462 parse_ok(&["dora", "expand", "foo.yml"]);
463 }
464
465 #[test]
466 fn parse_expand_module() {
467 parse_ok(&["dora", "expand", "--module", "module.yml"]);
468 }
469
470 #[test]
471 fn parse_validate() {
472 parse_ok(&["dora", "validate", "dataflow.yml"]);
473 }
474
475 #[test]
476 fn parse_validate_strict() {
477 parse_ok(&["dora", "validate", "--strict-types", "dataflow.yml"]);
478 }
479
480 #[test]
481 fn parse_validate_node_manifest() {
482 parse_ok(&["dora", "validate", "--node-manifest", "dora-node.yml"]);
483 parse_err(&[
485 "dora",
486 "validate",
487 "dataflow.yml",
488 "--node-manifest",
489 "dora-node.yml",
490 ]);
491 parse_err(&["dora", "validate"]);
493 parse_err(&[
495 "dora",
496 "validate",
497 "--node-manifest",
498 "dora-node.yml",
499 "--strict-types",
500 ]);
501 }
502
503 #[test]
504 fn parse_new() {
505 parse_ok(&["dora", "new", "test"]);
506 }
507
508 #[test]
509 fn parse_offline() {
510 parse_ok(&["dora", "build", "dataflow.yml", "--offline"]);
511 parse_ok(&["dora", "validate", "dataflow.yml", "--offline"]);
512 parse_err(&["dora", "validate", "--node-manifest", "x.yml", "--offline"]);
514 }
515
516 #[test]
517 fn parse_hub() {
518 parse_ok(&["dora", "hub", "init"]);
519 parse_ok(&["dora", "hub", "init", "path/to/node"]);
520 parse_ok(&["dora", "hub", "install", "dora-yolo"]);
521 parse_ok(&["dora", "hub", "search", "camera", "--category", "sensor"]);
522 parse_ok(&["dora", "hub", "search", "--offline"]);
523 parse_ok(&["dora", "hub", "info", "dora-yolo@^0.5"]);
524 parse_ok(&["dora", "hub", "list", "dataflow.yml"]);
525 parse_ok(&["dora", "hub", "fetch", "dataflow.yml", "--target-dir", "c"]);
526 parse_err(&["dora", "hub"]);
527 parse_err(&["dora", "hub", "info"]);
528 }
529
530 #[test]
531 fn parse_status() {
532 parse_ok(&["dora", "status"]);
533 }
534
535 #[test]
536 fn parse_status_json() {
537 parse_ok(&["dora", "status", "--format", "json"]);
538 }
539
540 #[test]
541 fn parse_status_json_short() {
542 parse_ok(&["dora", "status", "-f", "json"]);
543 }
544
545 #[test]
546 fn parse_inspect_top() {
547 parse_ok(&["dora", "inspect", "top"]);
548 }
549
550 #[test]
551 fn parse_topic_list() {
552 parse_ok(&["dora", "topic", "list"]);
553 }
554
555 #[test]
556 fn parse_topic_hz() {
557 parse_ok(&["dora", "topic", "hz"]);
558 }
559
560 #[test]
561 fn parse_topic_echo() {
562 parse_ok(&["dora", "topic", "echo"]);
563 }
564
565 #[test]
566 fn parse_node_list() {
567 parse_ok(&["dora", "node", "list"]);
568 parse_ok(&["dora", "node", "list", "--format", "json"]);
569 parse_err(&["dora", "node", "list", "-q", "--format", "json"]);
571 }
572
573 #[test]
574 fn parse_node_info() {
575 parse_ok(&["dora", "node", "info", "camera_node"]);
576 }
577
578 #[test]
579 fn parse_node_info_with_dataflow() {
580 parse_ok(&["dora", "node", "info", "sensor", "-d", "my-dataflow"]);
581 }
582
583 #[test]
584 fn reject_node_info_no_node() {
585 parse_err(&["dora", "node", "info"]);
586 }
587
588 #[test]
589 fn parse_topic_pub() {
590 parse_ok(&[
591 "dora",
592 "topic",
593 "pub",
594 "-d",
595 "my-dataflow",
596 "sensor/reading",
597 r#"{"value": 42}"#,
598 ]);
599 }
600
601 #[test]
602 fn parse_topic_pub_with_file() {
603 parse_ok(&[
604 "dora",
605 "topic",
606 "pub",
607 "-d",
608 "my-dataflow",
609 "sensor/reading",
610 "--file",
611 "data.json",
612 ]);
613 }
614
615 #[test]
616 fn reject_topic_pub_no_data_or_file() {
617 parse_err(&[
618 "dora",
619 "topic",
620 "pub",
621 "-d",
622 "my-dataflow",
623 "sensor/reading",
624 ]);
625 }
626
627 #[test]
628 fn parse_node_restart() {
629 parse_ok(&["dora", "node", "restart", "camera_node"]);
630 }
631
632 #[test]
633 fn parse_node_restart_with_grace() {
634 parse_ok(&["dora", "node", "restart", "sensor", "--grace", "30s"]);
635 }
636
637 #[test]
638 fn reject_node_restart_no_node() {
639 parse_err(&["dora", "node", "restart"]);
640 }
641
642 #[test]
643 fn parse_node_replace() {
644 parse_ok(&[
645 "dora",
646 "node",
647 "replace",
648 "filter",
649 "--from-yaml",
650 "filter-node.yml",
651 ]);
652 parse_ok(&[
653 "dora",
654 "node",
655 "replace",
656 "-d",
657 "my-flow",
658 "filter",
659 "--from-yaml",
660 "filter-node.yml",
661 "--grace",
662 "5s",
663 ]);
664 }
665
666 #[test]
667 fn reject_node_replace_without_yaml() {
668 parse_err(&["dora", "node", "replace", "filter"]);
669 }
670
671 #[test]
672 fn parse_node_stop() {
673 parse_ok(&["dora", "node", "stop", "camera_node"]);
674 }
675
676 #[test]
677 fn parse_node_stop_with_grace() {
678 parse_ok(&[
679 "dora", "node", "stop", "sensor", "--grace", "10s", "-d", "my-flow",
680 ]);
681 }
682
683 #[test]
684 fn reject_node_stop_no_node() {
685 parse_err(&["dora", "node", "stop"]);
686 }
687
688 #[test]
689 fn parse_param_list() {
690 parse_ok(&["dora", "param", "list", "camera_node"]);
691 }
692
693 #[test]
694 fn parse_param_list_with_dataflow() {
695 parse_ok(&["dora", "param", "list", "sensor", "-d", "my-dataflow"]);
696 }
697
698 #[test]
699 fn parse_param_get() {
700 parse_ok(&["dora", "param", "get", "camera_node", "fps"]);
701 }
702
703 #[test]
704 fn parse_param_set() {
705 parse_ok(&["dora", "param", "set", "camera_node", "fps", "60"]);
706 }
707
708 #[test]
709 fn parse_param_delete() {
710 parse_ok(&["dora", "param", "delete", "camera_node", "fps"]);
711 }
712
713 #[test]
714 fn reject_param_set_no_value() {
715 parse_err(&["dora", "param", "set", "camera_node", "fps"]);
716 }
717
718 #[test]
719 fn reject_param_get_no_key() {
720 parse_err(&["dora", "param", "get", "camera_node"]);
721 }
722
723 #[test]
724 fn parse_doctor() {
725 parse_ok(&["dora", "doctor"]);
726 }
727
728 #[test]
729 fn parse_doctor_with_dataflow() {
730 parse_ok(&["dora", "doctor", "--dataflow", "dataflow.yml"]);
731 }
732
733 #[test]
734 fn parse_record() {
735 parse_ok(&["dora", "record", "dataflow.yml"]);
736 }
737
738 #[test]
739 fn parse_record_with_output() {
740 parse_ok(&["dora", "record", "dataflow.yml", "-o", "capture.drec"]);
741 }
742
743 #[test]
744 fn parse_record_with_topics() {
745 parse_ok(&[
746 "dora",
747 "record",
748 "dataflow.yml",
749 "--topics",
750 "sensor/image,lidar/points",
751 ]);
752 }
753
754 #[test]
755 fn parse_record_output_yaml() {
756 parse_ok(&[
757 "dora",
758 "record",
759 "dataflow.yml",
760 "--output-yaml",
761 "modified.yml",
762 ]);
763 }
764
765 #[test]
766 fn reject_record_no_file() {
767 parse_err(&["dora", "record"]);
768 }
769
770 #[test]
771 fn parse_replay() {
772 parse_ok(&["dora", "replay", "recording.drec"]);
773 }
774
775 #[test]
776 fn parse_replay_with_options() {
777 parse_ok(&[
778 "dora",
779 "replay",
780 "recording.drec",
781 "--speed",
782 "2.0",
783 "--loop",
784 "--replace",
785 "sensor,camera",
786 ]);
787 }
788
789 #[test]
790 fn parse_replay_output_yaml() {
791 parse_ok(&[
792 "dora",
793 "replay",
794 "recording.drec",
795 "--output-yaml",
796 "modified.yml",
797 ]);
798 }
799
800 #[test]
801 fn reject_replay_no_file() {
802 parse_err(&["dora", "replay"]);
803 }
804
805 #[test]
806 fn parse_trace_list() {
807 parse_ok(&["dora", "trace", "list"]);
808 }
809
810 #[test]
811 fn parse_trace_view() {
812 parse_ok(&["dora", "trace", "view", "abc123"]);
813 }
814
815 #[test]
816 fn reject_trace_view_no_id() {
817 parse_err(&["dora", "trace", "view"]);
818 }
819
820 #[test]
821 fn parse_cluster_up() {
822 parse_ok(&["dora", "cluster", "up", "cluster.yml"]);
823 }
824
825 #[test]
826 fn parse_cluster_status() {
827 parse_ok(&["dora", "cluster", "status"]);
828 }
829
830 #[test]
831 fn parse_cluster_down() {
832 parse_ok(&["dora", "cluster", "down"]);
833 }
834
835 #[test]
836 fn reject_cluster_up_no_file() {
837 parse_err(&["dora", "cluster", "up"]);
838 }
839
840 #[test]
841 fn reject_unknown_subcommand() {
842 parse_err(&["dora", "foo"]);
843 }
844
845 #[test]
846 fn help_exits_cleanly() {
847 let err = Args::try_parse_from(["dora", "--help"]).unwrap_err();
848 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
849 }
850
851 #[test]
852 fn version_exits_cleanly() {
853 let err = Args::try_parse_from(["dora", "--version"]).unwrap_err();
854 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
855 }
856}