1#![forbid(unsafe_code)]
2
3use std::{
4 collections::{HashMap, HashSet},
5 fs,
6 io::{self, Cursor, Write},
7 net::SocketAddr,
8 path::{Path, PathBuf},
9 sync::Arc,
10 time::Duration,
11};
12
13use clap::{Parser, Subcommand, error::ErrorKind};
14use datum_agent::dcp::{
15 ClientKind, DcpClient, DcpError, EventSubscription, Hello, MetricSubscription, ResponseStatus,
16 proto::{
17 ClusterJobList, ClusterJobNode, ClusterNodeError, ClusterNodeList, ClusterNodeStatus,
18 Event, JobStatus as WireJobStatus, MetricSample, PlacementSpec, PlacementStrategy,
19 StreamMetric,
20 },
21};
22use datum_net::quic::{
23 crypto::rustls::QuicClientConfig,
24 quinn,
25 rustls::{
26 ClientConfig as RustlsClientConfig, RootCertStore,
27 pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
28 },
29};
30use serde::Serialize;
31
32const DEFAULT_ADDR: &str = "127.0.0.1:9555";
33const EXIT_OK: i32 = 0;
34const EXIT_REMOTE: i32 = 1;
35const EXIT_USAGE_OR_CONNECT: i32 = 2;
36const EVENT_INITIAL_IDLE: Duration = Duration::from_millis(500);
37const EVENT_IDLE: Duration = Duration::from_millis(250);
38const METRIC_SNAPSHOT_WAIT: Duration = Duration::from_secs(2);
39const DRAIN_POLL: Duration = Duration::from_millis(20);
40
41#[derive(Debug, Parser)]
42#[command(
43 name = "datum",
44 version,
45 about = "Manage Datum jobs through the Datum Control Protocol"
46)]
47struct Cli {
48 #[arg(long, global = true, default_value = DEFAULT_ADDR)]
50 addr: SocketAddr,
51
52 #[arg(long, global = true, value_name = "PATH")]
54 tls_ca: Option<PathBuf>,
55
56 #[arg(long, global = true, value_name = "PATH")]
58 tls_cert: Option<PathBuf>,
59
60 #[arg(long, global = true, value_name = "PATH")]
62 tls_key: Option<PathBuf>,
63
64 #[arg(long, global = true)]
66 json: bool,
67
68 #[command(subcommand)]
69 command: Command,
70}
71
72#[derive(Debug, Subcommand)]
73enum Command {
74 Ps {
76 #[arg(long)]
78 cluster: bool,
79 },
80 Nodes,
82 Status {
84 #[arg(long)]
85 cluster: bool,
86 job: String,
87 },
88 Start {
90 factory: String,
91 #[arg(long)]
92 name: Option<String>,
93 #[arg(long = "param", value_name = "k=v")]
94 params: Vec<String>,
95 },
96 Submit {
98 #[arg(long)]
99 cluster: bool,
100 #[arg(long)]
101 factory: String,
102 #[arg(long)]
103 name: Option<String>,
104 #[arg(long = "param", value_name = "k=v")]
105 params: Vec<String>,
106 #[arg(long)]
107 role: Option<String>,
108 #[arg(long)]
109 node: Option<String>,
110 },
111 Drain {
113 #[arg(long)]
114 cluster: bool,
115 job: String,
116 },
117 Stop {
119 #[arg(long)]
120 cluster: bool,
121 job: String,
122 },
123 Restart { job: String },
125 Events {
127 #[arg(long)]
128 follow: bool,
129 },
130 Metrics {
132 job: String,
133 #[arg(long)]
134 follow: bool,
135 },
136}
137
138#[derive(Debug)]
139enum CliError {
140 Usage(String),
141 Connect { addr: SocketAddr, message: String },
142 Remote { message: String, fix: &'static str },
143}
144
145impl CliError {
146 fn exit_code(&self) -> i32 {
147 match self {
148 Self::Remote { .. } => EXIT_REMOTE,
149 Self::Usage(_) | Self::Connect { .. } => EXIT_USAGE_OR_CONNECT,
150 }
151 }
152
153 fn print(&self) {
154 match self {
155 Self::Usage(message) => {
156 eprintln!("{message}");
157 }
158 Self::Connect { addr, message } => {
159 eprintln!("could not connect to datum-agent at {addr}: {message}");
160 eprintln!(
161 "start datum-agent or pass --addr host:port; loopback TCP defaults to {DEFAULT_ADDR}"
162 );
163 }
164 Self::Remote { message, fix } => {
165 eprintln!("datum-agent rejected the request: {message}");
166 eprintln!("{fix}");
167 }
168 }
169 }
170}
171
172pub async fn main_entry() -> i32 {
174 match Cli::try_parse() {
175 Ok(cli) => match run(cli).await {
176 Ok(()) => EXIT_OK,
177 Err(error) => {
178 error.print();
179 error.exit_code()
180 }
181 },
182 Err(error) => {
183 let _ = error.print();
184 clap_error_exit_code(&error)
185 }
186 }
187}
188
189fn clap_error_exit_code(error: &clap::Error) -> i32 {
192 match error.kind() {
193 ErrorKind::DisplayHelp
194 | ErrorKind::DisplayVersion
195 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => EXIT_OK,
196 _ => EXIT_USAGE_OR_CONNECT,
197 }
198}
199
200async fn run(cli: Cli) -> Result<(), CliError> {
201 validate_before_connect(&cli.command)?;
202 let client = connect(&cli).await?;
203 match &cli.command {
204 Command::Ps { cluster } => {
205 if *cluster {
206 cluster_ps(&client, cli.addr, cli.json).await
207 } else {
208 ps(&client, cli.addr, cli.json).await
209 }
210 }
211 Command::Nodes => nodes(&client, cli.addr, cli.json).await,
212 Command::Status { cluster, job } => {
213 status(&client, cli.addr, job, *cluster, cli.json).await
214 }
215 Command::Start {
216 factory,
217 name,
218 params,
219 } => {
220 start(
221 &client,
222 cli.addr,
223 factory,
224 name.as_deref(),
225 params,
226 cli.json,
227 )
228 .await
229 }
230 Command::Submit {
231 cluster,
232 factory,
233 name,
234 params,
235 role,
236 node,
237 } => {
238 submit(
239 &client,
240 cli.addr,
241 SubmitArgs {
242 cluster: *cluster,
243 factory,
244 name: name.as_deref(),
245 raw_params: params,
246 role: role.as_deref(),
247 node: node.as_deref(),
248 },
249 cli.json,
250 )
251 .await
252 }
253 Command::Drain { cluster, job } => drain(&client, cli.addr, job, *cluster, cli.json).await,
254 Command::Stop { cluster, job } => stop(&client, cli.addr, job, *cluster, cli.json).await,
255 Command::Restart { job } => restart(&client, cli.addr, job, cli.json).await,
256 Command::Events { follow } => events(&client, cli.addr, *follow, cli.json).await,
257 Command::Metrics { job, follow } => {
258 metrics(&client, cli.addr, job, *follow, cli.json).await
259 }
260 }
261}
262
263fn validate_before_connect(command: &Command) -> Result<(), CliError> {
264 match command {
265 Command::Start { params, .. } => {
266 parse_params(params)?;
267 }
268 Command::Submit {
269 cluster,
270 params,
271 role,
272 node,
273 ..
274 } => {
275 if !cluster {
276 return Err(CliError::Usage(
277 "`datum submit` currently requires --cluster; use `datum start` for local jobs."
278 .to_owned(),
279 ));
280 }
281 if role.is_some() && node.is_some() {
282 return Err(CliError::Usage(
283 "pass at most one of --role or --node for cluster placement.".to_owned(),
284 ));
285 }
286 parse_params(params)?;
287 }
288 _ => {}
289 }
290 Ok(())
291}
292
293async fn connect(cli: &Cli) -> Result<DcpClient, CliError> {
294 let hello = Hello::new(format!("datum-cli-{}", std::process::id()), ClientKind::Cli);
295 match tls_paths(cli)? {
296 Some(paths) => {
297 let config = load_quic_client_config(paths.ca, paths.cert, paths.key)?;
298 DcpClient::connect_quic(cli.addr, "localhost", config, hello)
299 .await
300 .map_err(|error| connect_error(cli.addr, error))
301 }
302 None => DcpClient::connect_tcp(cli.addr, hello)
303 .await
304 .map_err(|error| connect_error(cli.addr, error)),
305 }
306}
307
308struct TlsPaths<'a> {
309 ca: &'a Path,
310 cert: &'a Path,
311 key: &'a Path,
312}
313
314fn tls_paths(cli: &Cli) -> Result<Option<TlsPaths<'_>>, CliError> {
315 match (&cli.tls_ca, &cli.tls_cert, &cli.tls_key) {
316 (None, None, None) => Ok(None),
317 (Some(ca), Some(cert), Some(key)) => Ok(Some(TlsPaths {
318 ca: ca.as_path(),
319 cert: cert.as_path(),
320 key: key.as_path(),
321 })),
322 _ => Err(CliError::Usage(
323 "QUIC+mTLS mode needs --tls-ca, --tls-cert, and --tls-key; pass all three or omit all three for loopback TCP.".to_owned(),
324 )),
325 }
326}
327
328fn load_quic_client_config(
329 ca_path: &Path,
330 cert_path: &Path,
331 key_path: &Path,
332) -> Result<quinn::ClientConfig, CliError> {
333 let mut roots = RootCertStore::empty();
334 for cert in read_certificates(ca_path)? {
335 roots.add(cert).map_err(|error| {
336 CliError::Usage(format!(
337 "could not load --tls-ca {}: {error}; pass a PEM or DER CA certificate.",
338 ca_path.display()
339 ))
340 })?;
341 }
342 let certs = read_certificates(cert_path)?;
343 let key = read_private_key(key_path)?;
344 let rustls = RustlsClientConfig::builder()
345 .with_root_certificates(roots)
346 .with_client_auth_cert(certs, key)
347 .map_err(|error| {
348 CliError::Usage(format!(
349 "could not build QUIC client identity: {error}; check --tls-cert and --tls-key match."
350 ))
351 })?;
352 let quic = QuicClientConfig::try_from(rustls).map_err(|error| {
353 CliError::Usage(format!(
354 "could not build QUIC client config: {error}; check the TLS files."
355 ))
356 })?;
357 Ok(quinn::ClientConfig::new(Arc::new(quic)))
358}
359
360fn read_certificates(path: &Path) -> Result<Vec<CertificateDer<'static>>, CliError> {
361 let bytes = fs::read(path).map_err(|error| {
362 CliError::Usage(format!(
363 "could not read {}: {error}; pass a readable PEM or DER certificate file.",
364 path.display()
365 ))
366 })?;
367 let mut cursor = Cursor::new(bytes.as_slice());
368 let certs = rustls_pemfile::certs(&mut cursor)
369 .collect::<Result<Vec<_>, _>>()
370 .map_err(|error| {
371 CliError::Usage(format!(
372 "could not parse {}: {error}; pass a PEM or DER certificate file.",
373 path.display()
374 ))
375 })?;
376 if certs.is_empty() {
377 Ok(vec![CertificateDer::from(bytes)])
378 } else {
379 Ok(certs)
380 }
381}
382
383fn read_private_key(path: &Path) -> Result<PrivateKeyDer<'static>, CliError> {
384 let bytes = fs::read(path).map_err(|error| {
385 CliError::Usage(format!(
386 "could not read {}: {error}; pass a readable PEM or DER PKCS#8 private key.",
387 path.display()
388 ))
389 })?;
390 let mut cursor = Cursor::new(bytes.as_slice());
391 match rustls_pemfile::private_key(&mut cursor).map_err(|error| {
392 CliError::Usage(format!(
393 "could not parse {}: {error}; pass a PEM or DER PKCS#8 private key.",
394 path.display()
395 ))
396 })? {
397 Some(key) => Ok(key),
398 None => Ok(PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes))),
399 }
400}
401
402fn connect_error(addr: SocketAddr, error: DcpError) -> CliError {
403 match error {
404 DcpError::Response { status, message } => remote_error(status, message),
405 other => CliError::Connect {
406 addr,
407 message: other.to_string(),
408 },
409 }
410}
411
412fn command_error(addr: SocketAddr, error: DcpError) -> CliError {
413 match error {
414 DcpError::Response { status, message } => remote_error(status, message),
415 other => CliError::Connect {
416 addr,
417 message: other.to_string(),
418 },
419 }
420}
421
422fn remote_error(status: ResponseStatus, message: String) -> CliError {
423 let fix = match status {
424 ResponseStatus::BadRequest => "Fix the command arguments and try again.",
425 ResponseStatus::Unauthorized => {
426 "Check the DCP token or mTLS client certificate configured for datum-agent."
427 }
428 ResponseStatus::NotFound => "Check the job or factory name with `datum ps`.",
429 ResponseStatus::Conflict => {
430 "Pick a different job name, or stop the existing job before starting it again."
431 }
432 ResponseStatus::ProtocolMismatch => {
433 "Upgrade the CLI and datum-agent to compatible versions."
434 }
435 ResponseStatus::DeadlineExceeded => {
436 "Retry the command or check whether datum-agent is overloaded."
437 }
438 ResponseStatus::Ok | ResponseStatus::Failed => {
439 "Check `datum status <job>` and the datum-agent logs, then retry."
440 }
441 };
442 CliError::Remote { message, fix }
443}
444
445async fn ps(client: &DcpClient, addr: SocketAddr, json: bool) -> Result<(), CliError> {
446 let mut jobs = client
447 .list_jobs()
448 .await
449 .map_err(|error| command_error(addr, error))?;
450 jobs.sort_by(|left, right| left.name.cmp(&right.name));
451 if json {
452 print_json(&JobsOutput {
453 jobs: jobs.iter().map(JobView::from).collect(),
454 })?;
455 } else if jobs.is_empty() {
456 println!("no jobs");
457 } else {
458 print!("{}", render_jobs_table(&jobs));
459 }
460 Ok(())
461}
462
463async fn cluster_ps(client: &DcpClient, addr: SocketAddr, json: bool) -> Result<(), CliError> {
464 let mut list = client
465 .list_cluster_jobs(1_000)
466 .await
467 .map_err(|error| command_error(addr, error))?;
468 sort_cluster_jobs(&mut list);
469 if json {
470 print_json(&ClusterJobsOutput::from(&list))?;
471 } else if list.nodes.iter().all(|node| node.jobs.is_empty()) && list.errors.is_empty() {
472 println!("no cluster jobs");
473 } else {
474 print!("{}", render_cluster_jobs_table(&list));
475 if !list.errors.is_empty() {
476 print!("{}", render_cluster_errors_table(&list.errors));
477 }
478 }
479 Ok(())
480}
481
482async fn nodes(client: &DcpClient, addr: SocketAddr, json: bool) -> Result<(), CliError> {
483 let mut list = client
484 .cluster_node_info(1_000)
485 .await
486 .map_err(|error| command_error(addr, error))?;
487 list.nodes
488 .sort_by(|left, right| left.node_id.cmp(&right.node_id));
489 list.errors
490 .sort_by(|left, right| left.node_id.cmp(&right.node_id));
491 let coordinator_node_id = cluster_coordinator_node_id(client).await;
492 if json {
493 print_json(&ClusterNodesOutput::new(&list, coordinator_node_id.clone()))?;
494 } else if list.nodes.is_empty() {
495 println!("no cluster nodes");
496 } else {
497 print!(
498 "{}",
499 render_nodes_table(&list.nodes, coordinator_node_id.as_deref())
500 );
501 if !list.errors.is_empty() {
502 print!("{}", render_cluster_errors_table(&list.errors));
503 }
504 }
505 Ok(())
506}
507
508async fn cluster_coordinator_node_id(client: &DcpClient) -> Option<String> {
512 let jobs = client.list_cluster_jobs(1_000).await.ok()?;
513 jobs.nodes
514 .iter()
515 .flat_map(|node| node.jobs.iter())
516 .map(|job| job.coordinator_node_id.as_str())
517 .find(|id| !id.is_empty())
518 .map(str::to_owned)
519}
520
521async fn status(
522 client: &DcpClient,
523 addr: SocketAddr,
524 job: &str,
525 cluster: bool,
526 json: bool,
527) -> Result<(), CliError> {
528 let status = if cluster {
529 client.cluster_job_status(job, 1_000).await
530 } else {
531 client.job_status(job).await
532 }
533 .map_err(|error| command_error(addr, error))?;
534 if json {
535 print_json(&JobOutput {
536 job: JobView::from(&status),
537 })?;
538 } else {
539 print!("{}", render_status_table(&status));
540 }
541 Ok(())
542}
543
544struct SubmitArgs<'a> {
545 cluster: bool,
546 factory: &'a str,
547 name: Option<&'a str>,
548 raw_params: &'a [String],
549 role: Option<&'a str>,
550 node: Option<&'a str>,
551}
552
553async fn submit(
554 client: &DcpClient,
555 addr: SocketAddr,
556 args: SubmitArgs<'_>,
557 json: bool,
558) -> Result<(), CliError> {
559 if !args.cluster {
560 return Err(CliError::Usage(
561 "`datum submit` currently requires --cluster; use `datum start` for local jobs."
562 .to_owned(),
563 ));
564 }
565 let params = parse_params(args.raw_params)?;
566 let instance_name = args.name.unwrap_or(args.factory);
567 let placement = placement_spec(args.role, args.node);
568 let status = client
569 .submit_cluster_job(args.factory, instance_name, params, placement, 1_000)
570 .await
571 .map_err(|error| command_error(addr, error))?;
572 if json {
573 print_json(&ActionOutput {
574 action: "submitted",
575 message: format!("submitted cluster job '{}'", status.name),
576 job: JobView::from(&status),
577 })?;
578 } else {
579 println!(
580 "submitted cluster job '{}' from factory '{}' to node '{}' — state {}, placement generation {}",
581 status.name,
582 args.factory,
583 empty_dash(&status.placement_node_id),
584 status.state,
585 status.placement_generation
586 );
587 }
588 Ok(())
589}
590
591fn placement_spec(role: Option<&str>, node: Option<&str>) -> PlacementSpec {
592 PlacementSpec {
593 role_constraint: role.unwrap_or_default().to_owned(),
594 strategy: if node.is_some() {
595 PlacementStrategy::Pinned as i32
596 } else {
597 PlacementStrategy::LeastJobs as i32
598 },
599 pinned_node_id: node.unwrap_or_default().to_owned(),
600 }
601}
602
603async fn start(
604 client: &DcpClient,
605 addr: SocketAddr,
606 factory: &str,
607 name: Option<&str>,
608 raw_params: &[String],
609 json: bool,
610) -> Result<(), CliError> {
611 let params = parse_params(raw_params)?;
612 let instance_name = name.unwrap_or(factory);
613 let status = client
614 .start_job(factory, instance_name, params)
615 .await
616 .map_err(|error| command_error(addr, error))?;
617 if json {
618 print_json(&ActionOutput {
619 action: "started",
620 message: format!("started '{}'", status.name),
621 job: JobView::from(&status),
622 })?;
623 } else {
624 println!(
625 "started '{}' from factory '{}' — state {}, generation {}",
626 status.name, factory, status.state, status.generation
627 );
628 }
629 Ok(())
630}
631
632async fn drain(
633 client: &DcpClient,
634 addr: SocketAddr,
635 job: &str,
636 cluster: bool,
637 json: bool,
638) -> Result<(), CliError> {
639 let initial = if cluster {
640 client.drain_cluster_job(job, 1_000).await
641 } else {
642 client.drain_job(job).await
643 }
644 .map_err(|error| command_error(addr, error))?;
645 let status = wait_for_drain(client, job, cluster, initial)
646 .await
647 .map_err(|error| command_error(addr, error))?;
648 if json {
649 print_json(&ActionOutput {
650 action: "drained",
651 message: format!("drained '{}'", status.name),
652 job: JobView::from(&status),
653 })?;
654 } else if status.state == "Drained" {
655 println!("drained '{}' — in-flight streams completed", status.name);
656 } else {
657 println!(
658 "drain requested for '{}' — current state {}; run `datum status {}` to follow it",
659 status.name, status.state, status.name
660 );
661 }
662 Ok(())
663}
664
665async fn wait_for_drain(
666 client: &DcpClient,
667 job: &str,
668 cluster: bool,
669 mut status: WireJobStatus,
670) -> Result<WireJobStatus, DcpError> {
671 let wait = status
672 .drain_remaining_ms
673 .map(|ms| Duration::from_millis(ms.saturating_add(1_000)))
674 .unwrap_or(Duration::from_secs(31))
675 .min(Duration::from_secs(60));
676 let started = tokio::time::Instant::now();
677 while !is_drain_terminal(&status) && started.elapsed() < wait {
678 tokio::time::sleep(DRAIN_POLL).await;
679 status = if cluster {
680 client.cluster_job_status(job, 1_000).await?
681 } else {
682 client.job_status(job).await?
683 };
684 }
685 Ok(status)
686}
687
688fn is_drain_terminal(status: &WireJobStatus) -> bool {
689 matches!(
690 status.state.as_str(),
691 "Completed" | "Drained" | "Stopped" | "Failed"
692 )
693}
694
695async fn stop(
696 client: &DcpClient,
697 addr: SocketAddr,
698 job: &str,
699 cluster: bool,
700 json: bool,
701) -> Result<(), CliError> {
702 let status = if cluster {
703 client.stop_cluster_job(job, 1_000).await
704 } else {
705 client.stop_job(job).await
706 }
707 .map_err(|error| command_error(addr, error))?;
708 if json {
709 print_json(&ActionOutput {
710 action: "stopped",
711 message: format!("stopped '{}'", status.name),
712 job: JobView::from(&status),
713 })?;
714 } else {
715 println!("stopped '{}' — state {}", status.name, status.state);
716 }
717 Ok(())
718}
719
720async fn restart(
721 client: &DcpClient,
722 addr: SocketAddr,
723 job: &str,
724 json: bool,
725) -> Result<(), CliError> {
726 let status = client
727 .restart_job(job)
728 .await
729 .map_err(|error| command_error(addr, error))?;
730 if json {
731 print_json(&ActionOutput {
732 action: "restarted",
733 message: format!("restarted '{}'", status.name),
734 job: JobView::from(&status),
735 })?;
736 } else {
737 println!(
738 "restarted '{}' — state {}, generation {}",
739 status.name, status.state, status.generation
740 );
741 }
742 Ok(())
743}
744
745async fn events(
746 client: &DcpClient,
747 addr: SocketAddr,
748 follow: bool,
749 json: bool,
750) -> Result<(), CliError> {
751 let mut subscription = client
752 .subscribe_events()
753 .await
754 .map_err(|error| command_error(addr, error))?;
755 if follow {
756 while let Some(event) = subscription.recv().await {
757 print_event(&event, json)?;
758 }
759 if let Some(reason) = subscription.disconnect_reason() {
760 return Err(CliError::Connect {
761 addr,
762 message: reason,
763 });
764 }
765 return Ok(());
766 }
767
768 let events = collect_events(&mut subscription).await;
769 if json {
770 print_json(&EventsOutput {
771 events: events.iter().map(EventView::from).collect(),
772 })?;
773 } else if events.is_empty() {
774 println!("no events observed — run `datum events --follow` to keep waiting");
775 } else {
776 print!("{}", render_events_table(&events));
777 }
778 Ok(())
779}
780
781async fn collect_events(subscription: &mut EventSubscription) -> Vec<Event> {
782 let mut events = Vec::new();
783 loop {
784 let timeout = if events.is_empty() {
785 EVENT_INITIAL_IDLE
786 } else {
787 EVENT_IDLE
788 };
789 match tokio::time::timeout(timeout, subscription.recv()).await {
790 Ok(Some(event)) => events.push(event),
791 Ok(None) | Err(_) => break,
792 }
793 }
794 events
795}
796
797fn print_event(event: &Event, json: bool) -> Result<(), CliError> {
798 if json {
799 print_json_line(&EventLine {
800 event: EventView::from(event),
801 })?;
802 } else {
803 println!(
804 "{} {} '{}' generation {}",
805 event.sequence, event.kind, event.name, event.generation
806 );
807 io::stdout().flush().map_err(io_usage_error)?;
808 }
809 Ok(())
810}
811
812async fn metrics(
813 client: &DcpClient,
814 addr: SocketAddr,
815 job: &str,
816 follow: bool,
817 json: bool,
818) -> Result<(), CliError> {
819 let mut subscription = client
820 .subscribe_metrics(0)
821 .await
822 .map_err(|error| command_error(addr, error))?;
823 if follow {
824 while let Some(sample) = subscription.recv().await {
825 let filtered = filter_metric_sample(job, &sample);
826 if filtered.streams.is_empty() {
827 continue;
828 }
829 print_metric_sample_follow(job, &filtered, json)?;
830 }
831 if let Some(reason) = subscription.disconnect_reason() {
832 return Err(CliError::Connect {
833 addr,
834 message: reason,
835 });
836 }
837 return Ok(());
838 }
839
840 let sample = wait_for_metric_sample(job, &mut subscription).await;
841 print_metric_sample(job, &sample, json)
842}
843
844async fn wait_for_metric_sample(
845 job: &str,
846 subscription: &mut MetricSubscription,
847) -> MetricSampleView {
848 let deadline = tokio::time::Instant::now() + METRIC_SNAPSHOT_WAIT;
849 let mut latest = MetricSampleView {
850 timestamp_ms: 0,
851 streams: Vec::new(),
852 };
853 while tokio::time::Instant::now() < deadline {
854 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
855 match tokio::time::timeout(
856 remaining.min(Duration::from_millis(100)),
857 subscription.recv(),
858 )
859 .await
860 {
861 Ok(Some(sample)) => {
862 latest = filter_metric_sample(job, &sample);
863 if !latest.streams.is_empty() {
864 break;
865 }
866 }
867 Ok(None) => break,
868 Err(_) => {}
869 }
870 }
871 latest
872}
873
874fn print_metric_sample(job: &str, sample: &MetricSampleView, json: bool) -> Result<(), CliError> {
875 if json {
876 print_json(&MetricsOutput { job, sample })?;
877 } else if sample.streams.is_empty() {
878 println!("no metrics for '{job}' yet");
879 } else {
880 print!("{}", render_metrics_table(sample));
881 }
882 Ok(())
883}
884
885fn print_metric_sample_follow(
886 job: &str,
887 sample: &MetricSampleView,
888 json: bool,
889) -> Result<(), CliError> {
890 if json {
891 print_json_line(&MetricsOutput { job, sample })?;
892 } else {
893 print!("{}", render_metrics_table(sample));
894 io::stdout().flush().map_err(io_usage_error)?;
895 }
896 Ok(())
897}
898
899fn filter_metric_sample(job: &str, sample: &MetricSample) -> MetricSampleView {
900 let prefix = format!("{job}:");
901 MetricSampleView {
902 timestamp_ms: sample.timestamp_ms,
903 streams: sample
904 .streams
905 .iter()
906 .filter(|metric| metric.name == job || metric.name.starts_with(&prefix))
907 .map(StreamMetricView::from)
908 .collect(),
909 }
910}
911
912fn sort_cluster_jobs(list: &mut ClusterJobList) {
913 list.nodes
914 .sort_by(|left, right| left.node_id.cmp(&right.node_id));
915 for node in &mut list.nodes {
916 node.jobs.sort_by(|left, right| left.name.cmp(&right.name));
917 }
918 list.errors
919 .sort_by(|left, right| left.node_id.cmp(&right.node_id));
920}
921
922fn parse_params(raw_params: &[String]) -> Result<HashMap<String, String>, CliError> {
923 let mut params = HashMap::new();
924 let mut seen = HashSet::new();
925 for raw in raw_params {
926 let Some((key, value)) = raw.split_once('=') else {
927 return Err(CliError::Usage(format!(
928 "invalid --param '{raw}'; use --param key=value."
929 )));
930 };
931 if key.trim().is_empty() {
932 return Err(CliError::Usage(format!(
933 "invalid --param '{raw}'; the key before '=' cannot be empty."
934 )));
935 }
936 if !seen.insert(key.to_owned()) {
937 return Err(CliError::Usage(format!(
938 "duplicate --param key '{key}'; pass each key once."
939 )));
940 }
941 params.insert(key.to_owned(), value.to_owned());
942 }
943 Ok(params)
944}
945
946#[derive(Serialize)]
947struct JobsOutput {
948 jobs: Vec<JobView>,
949}
950
951#[derive(Serialize)]
952struct ClusterJobsOutput {
953 partial: bool,
954 nodes: Vec<ClusterJobNodeView>,
955 errors: Vec<ClusterNodeErrorView>,
956}
957
958impl From<&ClusterJobList> for ClusterJobsOutput {
959 fn from(list: &ClusterJobList) -> Self {
960 Self {
961 partial: list.partial,
962 nodes: list.nodes.iter().map(ClusterJobNodeView::from).collect(),
963 errors: list.errors.iter().map(ClusterNodeErrorView::from).collect(),
964 }
965 }
966}
967
968#[derive(Serialize)]
969struct ClusterNodesOutput {
970 partial: bool,
971 coordinator_node_id: Option<String>,
972 nodes: Vec<ClusterNodeView>,
973 errors: Vec<ClusterNodeErrorView>,
974}
975
976impl ClusterNodesOutput {
977 fn new(list: &ClusterNodeList, coordinator_node_id: Option<String>) -> Self {
978 Self {
979 partial: list.partial,
980 coordinator_node_id,
981 nodes: list.nodes.iter().map(ClusterNodeView::from).collect(),
982 errors: list.errors.iter().map(ClusterNodeErrorView::from).collect(),
983 }
984 }
985}
986
987#[derive(Serialize)]
988struct JobOutput {
989 job: JobView,
990}
991
992#[derive(Serialize)]
993struct ActionOutput {
994 action: &'static str,
995 message: String,
996 job: JobView,
997}
998
999#[derive(Serialize)]
1000struct EventsOutput {
1001 events: Vec<EventView>,
1002}
1003
1004#[derive(Serialize)]
1005struct EventLine {
1006 event: EventView,
1007}
1008
1009#[derive(Serialize)]
1010struct MetricsOutput<'a> {
1011 job: &'a str,
1012 sample: &'a MetricSampleView,
1013}
1014
1015#[derive(Serialize)]
1016struct JobView {
1017 name: String,
1018 job_id: u64,
1019 state: String,
1020 desired_state: String,
1021 generation: u64,
1022 starts_total: u64,
1023 restarts_total: u64,
1024 last_start_at_ms: Option<u64>,
1025 last_exit_at_ms: Option<u64>,
1026 last_exit_reason: String,
1027 backoff_remaining_ms: Option<u64>,
1028 drain_remaining_ms: Option<u64>,
1029 drain_supported: bool,
1030 active_streams: Option<u64>,
1031 cluster_job: bool,
1032 factory_name: String,
1033 placement_strategy: String,
1034 role_constraint: String,
1035 coordinator_node_id: String,
1036 placement_node_id: String,
1037 placement_generation: u64,
1038 placement_history: Vec<PlacementHistoryView>,
1039}
1040
1041impl From<&WireJobStatus> for JobView {
1042 fn from(status: &WireJobStatus) -> Self {
1043 Self {
1044 name: status.name.clone(),
1045 job_id: status.job_id,
1046 state: status.state.clone(),
1047 desired_state: status.desired_state.clone(),
1048 generation: status.generation,
1049 starts_total: status.starts_total,
1050 restarts_total: status.restarts_total,
1051 last_start_at_ms: status.last_start_at_ms,
1052 last_exit_at_ms: status.last_exit_at_ms,
1053 last_exit_reason: status.last_exit_reason.clone(),
1054 backoff_remaining_ms: status.backoff_remaining_ms,
1055 drain_remaining_ms: status.drain_remaining_ms,
1056 drain_supported: status.drain_supported,
1057 active_streams: status.active_streams,
1058 cluster_job: status.cluster_job,
1059 factory_name: status.factory_name.clone(),
1060 placement_strategy: placement_strategy_text(status),
1061 role_constraint: status
1062 .placement
1063 .as_ref()
1064 .map(|placement| placement.role_constraint.clone())
1065 .unwrap_or_default(),
1066 coordinator_node_id: status.coordinator_node_id.clone(),
1067 placement_node_id: status.placement_node_id.clone(),
1068 placement_generation: status.placement_generation,
1069 placement_history: status
1070 .placement_history
1071 .iter()
1072 .map(PlacementHistoryView::from)
1073 .collect(),
1074 }
1075 }
1076}
1077
1078#[derive(Serialize)]
1079struct PlacementHistoryView {
1080 generation: u64,
1081 from_node_id: String,
1082 to_node_id: String,
1083 reason: String,
1084 timestamp_ms: u64,
1085}
1086
1087impl From<&datum_agent::dcp::proto::ClusterPlacementHistory> for PlacementHistoryView {
1088 fn from(history: &datum_agent::dcp::proto::ClusterPlacementHistory) -> Self {
1089 Self {
1090 generation: history.generation,
1091 from_node_id: history.from_node_id.clone(),
1092 to_node_id: history.to_node_id.clone(),
1093 reason: history.reason.clone(),
1094 timestamp_ms: history.timestamp_ms,
1095 }
1096 }
1097}
1098
1099fn placement_strategy_text(status: &WireJobStatus) -> String {
1100 let Some(placement) = status.placement.as_ref() else {
1101 return String::new();
1102 };
1103 match PlacementStrategy::try_from(placement.strategy).unwrap_or(PlacementStrategy::LeastJobs) {
1104 PlacementStrategy::LeastJobs => "LeastJobs".to_owned(),
1105 PlacementStrategy::Pinned => format!("Pinned({})", placement.pinned_node_id),
1106 }
1107}
1108
1109#[derive(Serialize)]
1110struct ClusterJobNodeView {
1111 node_id: String,
1112 address: String,
1113 local: bool,
1114 jobs: Vec<JobView>,
1115}
1116
1117impl From<&ClusterJobNode> for ClusterJobNodeView {
1118 fn from(node: &ClusterJobNode) -> Self {
1119 Self {
1120 node_id: node.node_id.clone(),
1121 address: node.address.clone(),
1122 local: node.local,
1123 jobs: node.jobs.iter().map(JobView::from).collect(),
1124 }
1125 }
1126}
1127
1128#[derive(Serialize)]
1129struct ClusterNodeView {
1130 node_id: String,
1131 member_state: String,
1132 address: String,
1133 agent_addr: String,
1134 roles: Vec<String>,
1135 unreachable: bool,
1136 local: bool,
1137 session_state: String,
1138}
1139
1140impl From<&ClusterNodeStatus> for ClusterNodeView {
1141 fn from(node: &ClusterNodeStatus) -> Self {
1142 Self {
1143 node_id: node.node_id.clone(),
1144 member_state: node.member_state.clone(),
1145 address: node.address.clone(),
1146 agent_addr: node.agent_addr.clone(),
1147 roles: node.roles.clone(),
1148 unreachable: node.unreachable,
1149 local: node.local,
1150 session_state: node.session_state.clone(),
1151 }
1152 }
1153}
1154
1155#[derive(Serialize)]
1156struct ClusterNodeErrorView {
1157 node_id: String,
1158 message: String,
1159}
1160
1161impl From<&ClusterNodeError> for ClusterNodeErrorView {
1162 fn from(error: &ClusterNodeError) -> Self {
1163 Self {
1164 node_id: error.node_id.clone(),
1165 message: error.message.clone(),
1166 }
1167 }
1168}
1169
1170#[derive(Serialize)]
1171struct EventView {
1172 sequence: u64,
1173 timestamp_ms: u64,
1174 name: String,
1175 job_id: u64,
1176 generation: u64,
1177 kind: String,
1178 detail: String,
1179}
1180
1181impl From<&Event> for EventView {
1182 fn from(event: &Event) -> Self {
1183 Self {
1184 sequence: event.sequence,
1185 timestamp_ms: event.timestamp_ms,
1186 name: event.name.clone(),
1187 job_id: event.job_id,
1188 generation: event.generation,
1189 kind: event.kind.clone(),
1190 detail: event.detail.clone(),
1191 }
1192 }
1193}
1194
1195#[derive(Serialize)]
1196struct MetricSampleView {
1197 timestamp_ms: u64,
1198 streams: Vec<StreamMetricView>,
1199}
1200
1201#[derive(Serialize)]
1202struct StreamMetricView {
1203 id: u64,
1204 name: String,
1205 elements_through: u64,
1206 restarts: u64,
1207 state: String,
1208 started_at_ms: u64,
1209 state_changed_at_ms: u64,
1210 finished_at_ms: Option<u64>,
1211 uptime_ms: u64,
1212}
1213
1214impl From<&StreamMetric> for StreamMetricView {
1215 fn from(metric: &StreamMetric) -> Self {
1216 Self {
1217 id: metric.id,
1218 name: metric.name.clone(),
1219 elements_through: metric.elements_through,
1220 restarts: metric.restarts,
1221 state: metric.state.clone(),
1222 started_at_ms: metric.started_at_ms,
1223 state_changed_at_ms: metric.state_changed_at_ms,
1224 finished_at_ms: metric.finished_at_ms,
1225 uptime_ms: metric.uptime_ms,
1226 }
1227 }
1228}
1229
1230fn print_json<T: Serialize>(value: &T) -> Result<(), CliError> {
1231 let json = serde_json::to_string_pretty(value).map_err(|error| {
1232 CliError::Usage(format!(
1233 "could not encode JSON output: {error}; retry without --json."
1234 ))
1235 })?;
1236 println!("{json}");
1237 Ok(())
1238}
1239
1240fn print_json_line<T: Serialize>(value: &T) -> Result<(), CliError> {
1241 let json = serde_json::to_string(value).map_err(|error| {
1242 CliError::Usage(format!(
1243 "could not encode JSON output: {error}; retry without --json."
1244 ))
1245 })?;
1246 println!("{json}");
1247 io::stdout().flush().map_err(io_usage_error)?;
1248 Ok(())
1249}
1250
1251fn io_usage_error(error: io::Error) -> CliError {
1252 CliError::Usage(format!(
1253 "could not write CLI output: {error}; check stdout/stderr."
1254 ))
1255}
1256
1257fn render_jobs_table(jobs: &[WireJobStatus]) -> String {
1258 let rows = jobs
1259 .iter()
1260 .map(|job| {
1261 vec![
1262 job.name.clone(),
1263 job.job_id.to_string(),
1264 job.state.clone(),
1265 job.desired_state.clone(),
1266 job.generation.to_string(),
1267 format_number(job.starts_total),
1268 format_number(job.restarts_total),
1269 format_optional_number(job.active_streams),
1270 ]
1271 })
1272 .collect::<Vec<_>>();
1273 render_table(
1274 &[
1275 "JOB", "ID", "STATE", "DESIRED", "GEN", "STARTS", "RESTARTS", "ACTIVE",
1276 ],
1277 &rows,
1278 )
1279}
1280
1281fn render_cluster_jobs_table(list: &ClusterJobList) -> String {
1282 let rows = list
1283 .nodes
1284 .iter()
1285 .flat_map(|node| {
1286 if node.jobs.is_empty() {
1287 vec![vec![
1288 node.node_id.clone(),
1289 node.address.clone(),
1290 "-".to_owned(),
1291 "-".to_owned(),
1292 "-".to_owned(),
1293 "-".to_owned(),
1294 "-".to_owned(),
1295 "-".to_owned(),
1296 "-".to_owned(),
1297 "-".to_owned(),
1298 "-".to_owned(),
1299 "-".to_owned(),
1300 ]]
1301 } else {
1302 node.jobs
1303 .iter()
1304 .map(|job| {
1305 vec![
1306 node.node_id.clone(),
1307 node.address.clone(),
1308 job.name.clone(),
1309 job.job_id.to_string(),
1310 job.state.clone(),
1311 job.desired_state.clone(),
1312 job.generation.to_string(),
1313 format_number(job.starts_total),
1314 format_number(job.restarts_total),
1315 cluster_marker(job),
1316 empty_dash(&job.placement_node_id).to_owned(),
1317 placement_generation_text(job),
1318 ]
1319 })
1320 .collect::<Vec<_>>()
1321 }
1322 })
1323 .collect::<Vec<_>>();
1324 render_table(
1325 &[
1326 "NODE",
1327 "ADDRESS",
1328 "JOB",
1329 "ID",
1330 "STATE",
1331 "DESIRED",
1332 "GEN",
1333 "STARTS",
1334 "RESTARTS",
1335 "CLUSTER",
1336 "PLACED_ON",
1337 "PGEN",
1338 ],
1339 &rows,
1340 )
1341}
1342
1343fn render_nodes_table(nodes: &[ClusterNodeStatus], coordinator_node_id: Option<&str>) -> String {
1344 let rows = nodes
1345 .iter()
1346 .map(|node| {
1347 vec![
1348 node.node_id.clone(),
1349 node.member_state.clone(),
1350 coordinator_marker(&node.node_id, coordinator_node_id).to_owned(),
1351 node.unreachable.to_string(),
1352 node.session_state.clone(),
1353 node.agent_addr.clone(),
1354 node.roles.join(","),
1355 ]
1356 })
1357 .collect::<Vec<_>>();
1358 render_table(
1359 &[
1360 "NODE",
1361 "MEMBER",
1362 "COORD",
1363 "UNREACHABLE",
1364 "SESSION",
1365 "AGENT_ADDR",
1366 "ROLES",
1367 ],
1368 &rows,
1369 )
1370}
1371
1372fn coordinator_marker(node_id: &str, coordinator_node_id: Option<&str>) -> &'static str {
1373 if coordinator_node_id.is_some_and(|coordinator| coordinator == node_id) {
1374 "*"
1375 } else {
1376 "-"
1377 }
1378}
1379
1380fn render_cluster_errors_table(errors: &[ClusterNodeError]) -> String {
1381 let rows = errors
1382 .iter()
1383 .map(|error| vec![error.node_id.clone(), error.message.clone()])
1384 .collect::<Vec<_>>();
1385 render_table(&["NODE", "ERROR"], &rows)
1386}
1387
1388fn cluster_marker(job: &WireJobStatus) -> String {
1389 if job.cluster_job {
1390 "yes".to_owned()
1391 } else {
1392 "-".to_owned()
1393 }
1394}
1395
1396fn placement_generation_text(job: &WireJobStatus) -> String {
1397 if job.cluster_job {
1398 job.placement_generation.to_string()
1399 } else {
1400 "-".to_owned()
1401 }
1402}
1403
1404fn render_status_table(status: &WireJobStatus) -> String {
1405 let rows = vec![
1406 vec!["job".to_owned(), status.name.clone()],
1407 vec!["id".to_owned(), status.job_id.to_string()],
1408 vec!["state".to_owned(), status.state.clone()],
1409 vec!["desired".to_owned(), status.desired_state.clone()],
1410 vec!["generation".to_owned(), status.generation.to_string()],
1411 vec!["starts".to_owned(), format_number(status.starts_total)],
1412 vec!["restarts".to_owned(), format_number(status.restarts_total)],
1413 vec![
1414 "last_start_ms".to_owned(),
1415 format_optional_number(status.last_start_at_ms),
1416 ],
1417 vec![
1418 "last_exit_ms".to_owned(),
1419 format_optional_number(status.last_exit_at_ms),
1420 ],
1421 vec![
1422 "last_exit_reason".to_owned(),
1423 empty_dash(&status.last_exit_reason).to_owned(),
1424 ],
1425 vec![
1426 "backoff_remaining_ms".to_owned(),
1427 format_optional_number(status.backoff_remaining_ms),
1428 ],
1429 vec![
1430 "drain_remaining_ms".to_owned(),
1431 format_optional_number(status.drain_remaining_ms),
1432 ],
1433 vec![
1434 "drain_supported".to_owned(),
1435 status.drain_supported.to_string(),
1436 ],
1437 vec![
1438 "active_streams".to_owned(),
1439 format_optional_number(status.active_streams),
1440 ],
1441 vec!["cluster_job".to_owned(), status.cluster_job.to_string()],
1442 vec![
1443 "factory".to_owned(),
1444 empty_dash(&status.factory_name).to_owned(),
1445 ],
1446 vec![
1447 "placement_strategy".to_owned(),
1448 empty_dash(&placement_strategy_text(status)).to_owned(),
1449 ],
1450 vec![
1451 "role_constraint".to_owned(),
1452 status
1453 .placement
1454 .as_ref()
1455 .map(|placement| empty_dash(&placement.role_constraint).to_owned())
1456 .unwrap_or_else(|| "-".to_owned()),
1457 ],
1458 vec![
1459 "coordinator".to_owned(),
1460 empty_dash(&status.coordinator_node_id).to_owned(),
1461 ],
1462 vec![
1463 "placed_on".to_owned(),
1464 empty_dash(&status.placement_node_id).to_owned(),
1465 ],
1466 vec![
1467 "placement_generation".to_owned(),
1468 placement_generation_text(status),
1469 ],
1470 ];
1471 render_table(&["FIELD", "VALUE"], &rows)
1472}
1473
1474fn render_events_table(events: &[Event]) -> String {
1475 let rows = events
1476 .iter()
1477 .map(|event| {
1478 vec![
1479 event.sequence.to_string(),
1480 event.timestamp_ms.to_string(),
1481 event.name.clone(),
1482 event.job_id.to_string(),
1483 event.generation.to_string(),
1484 event.kind.clone(),
1485 empty_dash(&event.detail).to_owned(),
1486 ]
1487 })
1488 .collect::<Vec<_>>();
1489 render_table(
1490 &["SEQ", "TIME_MS", "JOB", "ID", "GEN", "KIND", "DETAIL"],
1491 &rows,
1492 )
1493}
1494
1495fn render_metrics_table(sample: &MetricSampleView) -> String {
1496 let rows = sample
1497 .streams
1498 .iter()
1499 .map(|metric| {
1500 vec![
1501 sample.timestamp_ms.to_string(),
1502 metric.name.clone(),
1503 metric.id.to_string(),
1504 metric.state.clone(),
1505 format_number(metric.elements_through),
1506 format_number(metric.restarts),
1507 metric.uptime_ms.to_string(),
1508 ]
1509 })
1510 .collect::<Vec<_>>();
1511 render_table(
1512 &[
1513 "TIME_MS",
1514 "STREAM",
1515 "ID",
1516 "STATE",
1517 "ELEMENTS",
1518 "RESTARTS",
1519 "UPTIME_MS",
1520 ],
1521 &rows,
1522 )
1523}
1524
1525#[must_use]
1527pub fn render_table(headers: &[&str], rows: &[Vec<String>]) -> String {
1528 let mut widths = headers
1529 .iter()
1530 .map(|header| header.len())
1531 .collect::<Vec<_>>();
1532 for row in rows {
1533 for (index, cell) in row.iter().enumerate().take(widths.len()) {
1534 widths[index] = widths[index].max(cell.len());
1535 }
1536 }
1537
1538 let mut output = String::new();
1539 push_table_row(
1540 &mut output,
1541 &headers
1542 .iter()
1543 .map(|cell| (*cell).to_owned())
1544 .collect::<Vec<_>>(),
1545 &widths,
1546 );
1547 push_separator(&mut output, &widths);
1548 for row in rows {
1549 push_table_row(&mut output, row, &widths);
1550 }
1551 output
1552}
1553
1554fn push_table_row(output: &mut String, row: &[String], widths: &[usize]) {
1555 for (index, width) in widths.iter().enumerate() {
1556 if index > 0 {
1557 output.push_str(" ");
1558 }
1559 let cell = row.get(index).map(String::as_str).unwrap_or("");
1560 output.push_str(cell);
1561 for _ in cell.len()..*width {
1562 output.push(' ');
1563 }
1564 }
1565 output.push('\n');
1566}
1567
1568fn push_separator(output: &mut String, widths: &[usize]) {
1569 for (index, width) in widths.iter().enumerate() {
1570 if index > 0 {
1571 output.push_str(" ");
1572 }
1573 for _ in 0..*width {
1574 output.push('-');
1575 }
1576 }
1577 output.push('\n');
1578}
1579
1580fn empty_dash(value: &str) -> &str {
1581 if value.is_empty() { "-" } else { value }
1582}
1583
1584fn format_optional_number(value: Option<u64>) -> String {
1585 value.map(format_number).unwrap_or_else(|| "-".to_owned())
1586}
1587
1588fn format_number(value: u64) -> String {
1589 let raw = value.to_string();
1590 let mut output = String::with_capacity(raw.len() + raw.len() / 3);
1591 for (index, ch) in raw.chars().rev().enumerate() {
1592 if index > 0 && index % 3 == 0 {
1593 output.push(',');
1594 }
1595 output.push(ch);
1596 }
1597 output.chars().rev().collect()
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602 use super::*;
1603
1604 #[test]
1605 fn table_renderer_pads_columns_stably() {
1606 let output = render_table(
1607 &["JOB", "STATE", "STARTS"],
1608 &[
1609 vec!["ingest".to_owned(), "Running".to_owned(), "1".to_owned()],
1610 vec![
1611 "daily-rollup".to_owned(),
1612 "Drained".to_owned(),
1613 "12,003".to_owned(),
1614 ],
1615 ],
1616 );
1617
1618 assert_eq!(
1619 output,
1620 "JOB STATE STARTS\n\
1621 ------------ ------- ------\n\
1622 ingest Running 1 \n\
1623 daily-rollup Drained 12,003\n"
1624 );
1625 }
1626
1627 #[test]
1628 fn params_require_key_value_pairs() {
1629 assert!(parse_params(&["threads=4".to_owned()]).is_ok());
1630 assert!(matches!(
1631 parse_params(&["threads".to_owned()]),
1632 Err(CliError::Usage(_))
1633 ));
1634 assert!(matches!(
1635 parse_params(&["threads=4".to_owned(), "threads=8".to_owned()]),
1636 Err(CliError::Usage(_))
1637 ));
1638 }
1639
1640 #[test]
1641 fn numbers_use_operator_grouping() {
1642 assert_eq!(format_number(0), "0");
1643 assert_eq!(format_number(12_003), "12,003");
1644 assert_eq!(format_number(9_876_543_210), "9,876,543,210");
1645 }
1646
1647 #[test]
1648 fn help_and_version_exit_zero() {
1649 let help_err = Cli::try_parse_from(["datum", "--help"]).unwrap_err();
1650 assert_eq!(clap_error_exit_code(&help_err), EXIT_OK);
1651
1652 let subcommand_help_err = Cli::try_parse_from(["datum", "nodes", "--help"]).unwrap_err();
1653 assert_eq!(clap_error_exit_code(&subcommand_help_err), EXIT_OK);
1654
1655 let version_err = Cli::try_parse_from(["datum", "--version"]).unwrap_err();
1656 assert_eq!(clap_error_exit_code(&version_err), EXIT_OK);
1657 }
1658
1659 #[test]
1660 fn unknown_flag_exits_nonzero() {
1661 let error = Cli::try_parse_from(["datum", "--not-a-real-flag"]).unwrap_err();
1662 assert_ne!(clap_error_exit_code(&error), EXIT_OK);
1663
1664 let missing_job = Cli::try_parse_from(["datum", "status"]).unwrap_err();
1665 assert_ne!(clap_error_exit_code(&missing_job), EXIT_OK);
1666 }
1667
1668 fn cluster_node_status(node_id: &str) -> ClusterNodeStatus {
1669 ClusterNodeStatus {
1670 node_id: node_id.to_owned(),
1671 member_state: "Up".to_owned(),
1672 address: "127.0.0.1:9555".to_owned(),
1673 agent_addr: "127.0.0.1:9556".to_owned(),
1674 roles: vec!["worker".to_owned()],
1675 unreachable: false,
1676 local: false,
1677 session_state: "connected".to_owned(),
1678 }
1679 }
1680
1681 #[test]
1682 fn coordinator_marker_flags_only_the_matching_node() {
1683 assert_eq!(coordinator_marker("node-a", Some("node-a")), "*");
1684 assert_eq!(coordinator_marker("node-a", Some("node-b")), "-");
1685 assert_eq!(coordinator_marker("node-a", None), "-");
1686 }
1687
1688 #[test]
1689 fn nodes_table_marks_the_coordinator_row() {
1690 let nodes = vec![cluster_node_status("node-a"), cluster_node_status("node-b")];
1691 let output = render_nodes_table(&nodes, Some("node-b"));
1692
1693 assert!(output.contains("COORD"));
1694 let lines: Vec<&str> = output.lines().collect();
1695 let node_a_row = lines[2];
1696 let node_b_row = lines[3];
1697 assert!(!node_a_row.split_whitespace().any(|token| token == "*"));
1698 assert!(node_b_row.split_whitespace().any(|token| token == "*"));
1699 }
1700
1701 #[test]
1702 fn cluster_nodes_output_json_carries_coordinator_node_id() {
1703 let list = ClusterNodeList {
1704 nodes: vec![cluster_node_status("node-a")],
1705 errors: Vec::new(),
1706 partial: false,
1707 };
1708 let output = ClusterNodesOutput::new(&list, Some("node-a".to_owned()));
1709
1710 let value = serde_json::to_value(&output).expect("serializes");
1711 assert_eq!(value["coordinator_node_id"], "node-a");
1712 assert_eq!(value["nodes"][0]["node_id"], "node-a");
1713 }
1714}