1use std::path::PathBuf;
16use std::sync::{Arc, Mutex};
17
18use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
19
20use crate::{
21 ALERT_EVAL_INTERVAL, DEFAULT_METRICS_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL,
22 alerts::{self, AlertEvaluator},
23 ast::Query,
24 collector::{self, CollectorConfig},
25 config::{self, ConfigAction, DolConfig},
26 dashboard,
27 docker::BollardDockerClient,
28 events::{self, BollardEventSource},
29 executor::{
30 self, ExecutionResult, Theme, render_csv, render_jsonl, render_table,
31 render_table_with_theme,
32 },
33 export::{self, ExportFormat},
34 metrics::{BollardMetricsCollector, MetricsCollector},
35 parser, planner,
36 sqlite_store::SqliteTelemetryStore,
37 storage::TelemetryStore,
38};
39
40#[derive(Debug, Parser)]
41#[command(
42 name = "dol",
43 version,
44 about = "Docker Observability Language — query, monitor, and analyze your Docker infrastructure",
45 long_about = "DOL (Docker Observability Language) is a query language for Docker infrastructure.
46Use SQL-like pipelines to observe containers, stream events, set up alerts,
47analyze anomalies, and inspect historical data — all from a single CLI.
48
49See the full language reference at: https://github.com/genc-murat/DockQL",
50 subcommand_value_name = "COMMAND",
51 subcommand_negates_reqs = true,
52 after_help = r#"
53EXAMPLES:
54
55 Basic queries:
56 dol "observe containers" List all containers
57 dol "observe containers | where state = running" Filter running containers
58 dol "observe containers | select name, image, status" Select specific fields
59 dol "observe containers | sort by memory desc | limit 5" Top 5 by memory usage
60 dol "events containers" Stream Docker events live
61
62 Advanced queries:
63 dol "observe containers | group by image | count" Count containers per image
64 dol "alert when cpu > 80% for 2m then webhook http://..." CPU alert with webhook
65 dol "inspect container <name>" Inspect a single container
66 dol "compose ls" List compose projects
67 dol "compose myapp networks | where action = connect" Stream compose network events
68 dol "compose myapp logs api-service | where message contains error" Stream compose service logs
69 dol "analyze containers find anomalies" Detect issues automatically
70 dol "analyze containers explain" Full diagnostic summary
71
72 Streaming targets (compose): logs, networks — add a pipeline to switch to live streaming mode
73
74 Working with files and store:
75 dol -f examples/ping.dol Run query from file
76 dol --explain "observe containers" Show query plan (dry run)
77 dol --store ./dol.db --collect Start background collector
78
79 Output and integration:
80 dol "observe containers" --output json JSON output
81 dol "observe containers" --output csv --export results.csv Export to CSV file
82 dol "observe containers" --watch 5 Re-run every 5 seconds
83 dol "observe containers" --theme light Light theme for tables
84
85 Interactive modes:
86 dol repl Interactive REPL with tab completion
87 dol top Live container monitor (top-like)
88 dol dashboard Multi-panel dashboard with events
89 dol config view Show current configuration
90 dol config set theme light Set default theme to light
91
92For more: https://github.com/genc-murat/DockQL
93"#
94)]
95#[allow(clippy::struct_excessive_bools)]
96pub struct Cli {
97 pub query: Option<String>,
99
100 #[arg(short = 'f', long)]
102 pub file: Option<String>,
103
104 #[arg(long, value_enum)]
106 pub output: Option<OutputFormat>,
107
108 #[arg(long)]
110 pub store: Option<String>,
111
112 #[arg(long)]
114 pub collect: bool,
115
116 #[arg(long, default_value_t = DEFAULT_METRICS_INTERVAL)]
118 pub metrics_interval: u64,
119
120 #[arg(long, default_value_t = DEFAULT_SNAPSHOT_INTERVAL)]
122 pub snapshot_interval: u64,
123
124 #[arg(long)]
126 pub store_stats: bool,
127
128 #[arg(long)]
130 pub apply_retention: bool,
131
132 #[arg(long)]
134 pub explain: bool,
135
136 #[arg(long)]
138 pub watch: Option<u64>,
139
140 #[arg(long)]
143 pub timeout: Option<u64>,
144
145 #[arg(long)]
147 pub export: Option<PathBuf>,
148
149 #[arg(long)]
151 pub export_format: Option<ExportFormat>,
152
153 #[arg(long)]
155 pub export_influx: Option<String>,
156
157 #[arg(long)]
159 pub export_grafana_loki: Option<String>,
160
161 #[arg(long)]
163 pub export_prometheus: Option<String>,
164
165 #[arg(long)]
167 pub host: Option<String>,
168
169 #[arg(long, value_enum)]
171 pub completion: Option<clap_complete::Shell>,
172
173 #[arg(long)]
175 pub diff: bool,
176
177 #[arg(long, value_enum)]
180 pub theme: Option<Theme>,
181
182 #[command(subcommand)]
184 pub command: Option<CliCommand>,
185}
186
187#[derive(Debug, Subcommand)]
188pub enum CliCommand {
189 Config {
191 #[command(subcommand)]
192 action: ConfigAction,
193 },
194 Repl,
196 Top,
198 Dashboard,
200}
201
202#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
204pub enum OutputFormat {
205 Table,
206 Json,
207 #[value(name = "json-compact")]
209 JsonCompact,
210 Csv,
211 Jsonl,
212}
213
214fn apply_host(cli_host: Option<&str>, config_host: Option<&str>) {
218 let host = cli_host.or(config_host);
219 if let Some(host) = host {
220 unsafe {
222 std::env::set_var("DOCKER_HOST", host);
223 }
224 }
225}
226
227#[allow(clippy::significant_drop_tightening)]
228pub async fn run(cli: Cli) -> anyhow::Result<()> {
229 let config = DolConfig::load();
230
231 alerts::init_alert_timeouts(
233 config.webhook_timeout.unwrap_or(10),
234 config.restart_timeout.unwrap_or(30),
235 );
236
237 apply_host(cli.host.as_deref(), config.host.as_deref());
240
241 if let Some(cmd) = &cli.command {
243 match cmd {
244 CliCommand::Config { action } => {
245 return config::execute_config(action.clone());
246 }
247 CliCommand::Repl => {
248 return crate::repl::run_repl(&config).await;
249 }
250 CliCommand::Top => {
251 return dashboard::run_top(&config).await;
252 }
253 CliCommand::Dashboard => {
254 return dashboard::run_dashboard(&config).await;
255 }
256 }
257 }
258
259 let effective_theme = cli
261 .theme
262 .or_else(|| {
263 config
264 .theme
265 .as_deref()
266 .and_then(|s| match s.to_lowercase().as_str() {
267 "light" => Some(Theme::Light),
268 "dark" => Some(Theme::Dark),
269 _ => None,
270 })
271 })
272 .unwrap_or(Theme::Dark);
273
274 let output_format = cli.output.unwrap_or(OutputFormat::Table);
275
276 if let Some(shell) = cli.completion {
277 let mut cmd = Cli::command();
278 let name = cmd.get_name().to_string();
279 clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
280 return Ok(());
281 }
282
283 if cli.store_stats {
285 let store_path = cli
286 .store
287 .as_deref()
288 .or(config.store.as_deref())
289 .ok_or_else(|| anyhow::anyhow!("--store-stats requires --store <path>"))?;
290 let store = SqliteTelemetryStore::open(store_path)?;
291 let stats = store.stats()?;
292 println!("Telemetry Store Statistics:");
293 println!(" Metrics: {}", stats.metric_count);
294 println!(" Events: {}", stats.event_count);
295 println!(" Snapshots: {}", stats.snapshot_count);
296 return Ok(());
297 }
298
299 if cli.apply_retention {
301 let store_path = cli
302 .store
303 .as_deref()
304 .or(config.store.as_deref())
305 .ok_or_else(|| anyhow::anyhow!("--apply-retention requires --store <path>"))?;
306 let mut store = SqliteTelemetryStore::open(store_path)?;
307 let stats = store.apply_retention()?;
308 println!("Retention cleanup complete:");
309 println!(" Metrics deleted: {}", stats.metrics_deleted);
310 println!(" Events deleted: {}", stats.events_deleted);
311 println!(" Snapshots deleted: {}", stats.snapshots_deleted);
312 return Ok(());
313 }
314
315 if cli.collect {
317 let store_path = cli
318 .store
319 .as_deref()
320 .or(config.store.as_deref())
321 .ok_or_else(|| anyhow::anyhow!("--collect requires --store <path>"))?;
322 let store = SqliteTelemetryStore::open(store_path)?;
323 let store = Arc::new(Mutex::new(store));
324 let docker_inner = BollardDockerClient::connect_with_config(&config)?;
325 let metrics = BollardMetricsCollector::with_config(
326 std::sync::Arc::new(docker_inner.clone()),
327 &config,
328 );
329 let docker = docker_inner;
330 let config_cfg = CollectorConfig {
331 snapshot_interval_secs: cli.snapshot_interval,
332 metrics_interval_secs: cli.metrics_interval,
333 };
334
335 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
336
337 println!(
338 "Starting background collector (metrics every {}s, snapshots every {}s)...",
339 config_cfg.metrics_interval_secs, config_cfg.snapshot_interval_secs
340 );
341 println!("Press Ctrl+C to stop.");
342
343 let handle = collector::spawn_metrics_collector(
344 Arc::clone(&store),
345 docker,
346 metrics,
347 config_cfg,
348 shutdown_rx,
349 );
350
351 tokio::signal::ctrl_c().await?;
352 println!("\nShutting down collector...");
353 shutdown_tx.send(true)?;
354 handle.await?;
355
356 if let Ok(store) = store.lock() {
357 let stats = store.stats()?;
358 println!("Final store statistics:");
359 println!(" Metrics: {}", stats.metric_count);
360 println!(" Events: {}", stats.event_count);
361 println!(" Snapshots: {}", stats.snapshot_count);
362 }
363
364 return Ok(());
365 }
366
367 let query = if let Some(ref file_path) = cli.file {
369 std::fs::read_to_string(file_path)
370 .map_err(|e| anyhow::anyhow!("failed to read file '{file_path}': {e}"))?
371 } else {
372 cli.query.as_deref().unwrap_or_default().to_owned()
373 };
374 let query = query.trim().to_owned();
375
376 if query.is_empty() {
377 anyhow::bail!(
378 "empty DOL query; pass a query such as `observe containers` or use `--file <path>`"
379 );
380 }
381
382 let parsed = parser::parse(&query)?;
383
384 if cli.explain {
385 let plan = planner::plan(&parsed.query);
386 println!("{plan}");
387 return Ok(());
388 }
389
390 let export_writer = if let Some(ref path) = cli.export {
391 let file = std::fs::File::create(path)?;
392 Some(Mutex::new(file))
393 } else {
394 None
395 };
396
397 let output_result = |result: &ExecutionResult| -> anyhow::Result<()> {
398 if let Some(export_fmt) = cli.export_format {
400 if let Some(ref writer) = export_writer {
401 use std::io::Write;
402 let mut file = writer.lock().expect("lock writer");
403 match export_fmt {
404 ExportFormat::Influx => {
405 let text = export::format_as_influx(result, "containers");
406 file.write_all(text.as_bytes())?;
407 }
408 ExportFormat::Loki => {
409 let text = export::format_as_loki(result)?;
410 file.write_all(text.as_bytes())?;
411 }
412 ExportFormat::Prometheus => {
413 let text = export::format_as_prometheus(result);
414 file.write_all(text.as_bytes())?;
415 }
416 }
417 }
418 return Ok(());
419 }
420
421 match output_format {
422 OutputFormat::Table => {
423 let text = render_table_with_theme(result, effective_theme);
424 if let Some(ref writer) = export_writer {
425 use std::io::Write;
426 let mut file = writer.lock().expect("lock writer");
427 writeln!(file, "{text}")?;
428 } else {
429 print!("{text}");
430 }
431 }
432 OutputFormat::Json => {
433 if let Ok(json) = serde_json::to_string_pretty(&result) {
434 if let Some(ref writer) = export_writer {
435 use std::io::Write;
436 let mut file = writer.lock().expect("lock writer");
437 file.write_all(json.as_bytes())?;
438 } else {
439 println!("{json}");
440 }
441 }
442 }
443 OutputFormat::JsonCompact => {
444 if let Ok(json) = serde_json::to_string(&result) {
445 if let Some(ref writer) = export_writer {
446 use std::io::Write;
447 let mut file = writer.lock().expect("lock writer");
448 file.write_all(json.as_bytes())?;
449 } else {
450 println!("{json}");
451 }
452 }
453 }
454 OutputFormat::Csv => {
455 let csv = render_csv(result);
456 if let Some(ref writer) = export_writer {
457 use std::io::Write;
458 let mut file = writer.lock().expect("lock writer");
459 writeln!(file, "{csv}")?;
460 } else {
461 println!("{csv}");
462 }
463 }
464 OutputFormat::Jsonl => {
465 let jsonl = render_jsonl(result);
466 if let Some(ref writer) = export_writer {
467 use std::io::Write;
468 let mut file = writer.lock().expect("lock writer");
469 file.write_all(jsonl.as_bytes())?;
470 if !jsonl.is_empty() {
471 writeln!(file)?;
472 }
473 } else {
474 println!("{jsonl}");
475 }
476 }
477 }
478 Ok(())
479 };
480
481 if needs_store(&parsed.query) {
483 let store_path = cli.store.as_deref().or(config.store.as_deref()).ok_or_else(|| {
484 anyhow::anyhow!(
485 "this query requires historical data; provide --store <path> to use a telemetry store"
486 )
487 })?;
488 let store = SqliteTelemetryStore::open(store_path)?;
489 let result = if let Some(secs) = cli.timeout {
490 tokio::time::timeout(
491 std::time::Duration::from_secs(secs),
492 executor::execute_with_store(&parsed.query, &store),
493 )
494 .await
495 .map_err(|_| anyhow::anyhow!("query timed out after {secs}s"))??
496 } else {
497 executor::execute_with_store(&parsed.query, &store).await?
498 };
499 output_result(&result)?;
500 run_exports(&cli, &result).await?;
501 return Ok(());
502 }
503
504 if let Query::Alert(rule) = &parsed.query {
506 if cli.watch.is_none() {
508 let alert_docker = BollardDockerClient::connect_with_config(&config)
509 .ok()
510 .map(std::sync::Arc::new);
511 let mut evaluator = AlertEvaluator::new();
512 let mut interval =
513 tokio::time::interval(std::time::Duration::from_secs(ALERT_EVAL_INTERVAL));
514
515 let store: Option<Arc<Mutex<SqliteTelemetryStore>>> = cli
516 .store
517 .as_deref()
518 .or(config.store.as_deref())
519 .map(SqliteTelemetryStore::open)
520 .transpose()?
521 .map(|s| Arc::new(Mutex::new(s)));
522
523 println!("Evaluating alert... (Ctrl+C to stop)");
524 loop {
525 tokio::select! {
526 _ = tokio::signal::ctrl_c() => {
527 break;
528 }
529 _ = interval.tick() => {
530 let samples = if let (Some(secs), Some(ad)) = (cli.timeout, alert_docker.as_ref()) {
533 let m = BollardMetricsCollector::with_config(std::sync::Arc::clone(ad), &config);
534 match tokio::time::timeout(
535 std::time::Duration::from_secs(secs),
536 m.collect(),
537 )
538 .await
539 {
540 Ok(Ok(s)) => s,
541 Ok(Err(e)) => {
542 eprintln!("Metrics collection error: {e}");
543 continue;
544 }
545 Err(_) => {
546 eprintln!("metrics collection timed out after {secs}s");
547 continue;
548 }
549 }
550 } else if let Some(ref ad) = alert_docker {
551 match BollardMetricsCollector::with_config(std::sync::Arc::clone(ad), &config).collect().await {
552 Ok(s) => s,
553 Err(e) => {
554 eprintln!("Metrics collection error: {e}");
555 continue;
556 }
557 }
558 } else {
559 eprintln!("[metrics] Docker not connected — skipping collection");
560 continue;
561 };
562
563 if let Some(ref store) = store
564 && let Ok(mut s) = store.lock() {
565 for sample in &samples {
566 let _ = s.write_metric(sample.clone());
567 }
568 }
569
570 match evaluator.evaluate_samples(rule, &samples, std::time::Instant::now()) {
571 Ok(events) => {
572 for event in events {
573 match output_format {
574 OutputFormat::Table => println!("{}", alerts::render_alert_event(&event)),
575 OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => println!("{}", serde_json::to_string(&event)?),
576 OutputFormat::Csv => println!("{},{},{:?}", event.container_name, event.message, event.action),
577 }
578 }
579 }
580 Err(e) => eprintln!("Alert evaluation error: {e}"),
581 }
582 }
583 }
584 }
585 }
586 }
588
589 if let Query::Events(events_query) = &parsed.query {
591 let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
592 let source = BollardEventSource::new(std::sync::Arc::clone(&docker));
593
594 let store: Option<Arc<Mutex<SqliteTelemetryStore>>> = cli
595 .store
596 .as_deref()
597 .or(config.store.as_deref())
598 .map(SqliteTelemetryStore::open)
599 .transpose()?
600 .map(|s| Arc::new(Mutex::new(s)));
601
602 let (event_callback_store, event_callback_fmt) = (store.clone(), output_format);
603 let event_callback =
604 move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
605 if let Some(ref store) = event_callback_store
606 && let Ok(mut s) = store.lock()
607 && let (Some(time), Some(action)) = (
608 row.fields.get("time").and_then(|v| v.as_str()),
609 row.fields.get("action").and_then(|v| v.as_str()),
610 )
611 {
612 let event = crate::docker::DockerEvent {
613 time: time.to_owned(),
614 event_type: row
615 .fields
616 .get("type")
617 .and_then(|v| v.as_str())
618 .unwrap_or("container")
619 .to_owned(),
620 action: action.to_owned(),
621 actor_id: row
622 .fields
623 .get("actor_id")
624 .and_then(|v| v.as_str())
625 .unwrap_or_default()
626 .to_owned(),
627 container: row
628 .fields
629 .get("container")
630 .and_then(|v| v.as_str())
631 .map(str::to_owned),
632 image: row
633 .fields
634 .get("image")
635 .and_then(|v| v.as_str())
636 .map(str::to_owned),
637 attributes: Vec::new(),
638 };
639 let _ = s.write_event(event);
640 }
641
642 let result = ExecutionResult { rows: vec![row] };
643 match event_callback_fmt {
644 OutputFormat::Table => {
645 println!("{}", render_table(&result));
646 }
647 OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
648 println!(
649 "{}",
650 serde_json::to_string(&result.rows[0])
651 .map_err(crate::events::EventsError::Json)?
652 );
653 }
654 OutputFormat::Csv => {
655 let mut columns: Vec<String> =
656 result.rows[0].fields.keys().cloned().collect();
657 columns.sort();
658 let vals: Vec<String> = columns
659 .iter()
660 .map(|c| {
661 result.rows[0]
662 .fields
663 .get(c)
664 .map(crate::eval::render_json_cell)
665 .unwrap_or_default()
666 })
667 .collect();
668 println!("{}", vals.join(","));
669 }
670 }
671 Ok(())
672 };
673
674 if let Some(secs) = cli.timeout {
675 tokio::time::timeout(
676 std::time::Duration::from_secs(secs),
677 events::stream_events(events_query, &source, &event_callback),
678 )
679 .await
680 .map_err(|_| anyhow::anyhow!("events stream timed out after {secs}s"))??;
681 } else {
682 events::stream_events(events_query, &source, &event_callback).await?;
683 }
684 return Ok(());
685 }
686
687 if let Query::Logs(logs_query) = &parsed.query {
689 let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
690 let source = events::BollardLogSource::new(std::sync::Arc::clone(&docker));
691
692 let log_output_format = output_format;
693 let log_callback =
694 move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
695 let result = ExecutionResult { rows: vec![row] };
696 match log_output_format {
697 OutputFormat::Table => {
698 println!("{}", render_table(&result));
699 }
700 OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
701 println!(
702 "{}",
703 serde_json::to_string(&result.rows[0])
704 .map_err(crate::events::EventsError::Json)?
705 );
706 }
707 OutputFormat::Csv => {
708 let mut columns: Vec<String> =
709 result.rows[0].fields.keys().cloned().collect();
710 columns.sort();
711 let vals: Vec<String> = columns
712 .iter()
713 .map(|c| {
714 result.rows[0]
715 .fields
716 .get(c)
717 .map(crate::eval::render_json_cell)
718 .unwrap_or_default()
719 })
720 .collect();
721 println!("{}", vals.join(","));
722 }
723 }
724 Ok(())
725 };
726
727 if let Some(secs) = cli.timeout {
728 tokio::time::timeout(
729 std::time::Duration::from_secs(secs),
730 events::stream_logs(logs_query, &source, &log_callback),
731 )
732 .await
733 .map_err(|_| anyhow::anyhow!("log stream timed out after {secs}s"))??;
734 } else {
735 events::stream_logs(logs_query, &source, &log_callback).await?;
736 }
737 return Ok(());
738 }
739
740 if let Query::Compose(compose_query) = &parsed.query
742 && compose_query.target == crate::ast::ComposeTarget::Logs
743 {
744 let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
745
746 let compose_log_output_format = output_format;
747 let compose_log_callback =
748 move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
749 let result = ExecutionResult { rows: vec![row] };
750 match compose_log_output_format {
751 OutputFormat::Table => {
752 println!("{}", render_table(&result));
753 }
754 OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
755 println!(
756 "{}",
757 serde_json::to_string(&result.rows[0])
758 .map_err(crate::events::EventsError::Json)?
759 );
760 }
761 OutputFormat::Csv => {
762 let mut columns: Vec<String> =
763 result.rows[0].fields.keys().cloned().collect();
764 columns.sort();
765 let vals: Vec<String> = columns
766 .iter()
767 .map(|c| {
768 result.rows[0]
769 .fields
770 .get(c)
771 .map(crate::eval::render_json_cell)
772 .unwrap_or_default()
773 })
774 .collect();
775 println!("{}", vals.join(","));
776 }
777 }
778 Ok(())
779 };
780
781 if let Some(secs) = cli.timeout {
782 tokio::time::timeout(
783 std::time::Duration::from_secs(secs),
784 events::stream_compose_logs(compose_query, docker, compose_log_callback),
785 )
786 .await
787 .map_err(|_| anyhow::anyhow!("compose log stream timed out after {secs}s"))??;
788 } else {
789 events::stream_compose_logs(compose_query, docker, compose_log_callback).await?;
790 }
791 return Ok(());
792 }
793
794 if let Query::Compose(compose_query) = &parsed.query
796 && compose_query.target == crate::ast::ComposeTarget::Networks
797 && !compose_query.pipeline.is_empty()
798 {
799 let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
800
801 let compose_net_output_format = output_format;
802 let compose_net_callback =
803 move |row: crate::executor::Row| -> Result<(), crate::events::EventsError> {
804 let result = ExecutionResult { rows: vec![row] };
805 match compose_net_output_format {
806 OutputFormat::Table => {
807 println!("{}", render_table(&result));
808 }
809 OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => {
810 println!(
811 "{}",
812 serde_json::to_string(&result.rows[0])
813 .map_err(crate::events::EventsError::Json)?
814 );
815 }
816 OutputFormat::Csv => {
817 let mut columns: Vec<String> =
818 result.rows[0].fields.keys().cloned().collect();
819 columns.sort();
820 let vals: Vec<String> = columns
821 .iter()
822 .map(|c| {
823 result.rows[0]
824 .fields
825 .get(c)
826 .map(crate::eval::render_json_cell)
827 .unwrap_or_default()
828 })
829 .collect();
830 println!("{}", vals.join(","));
831 }
832 }
833 Ok(())
834 };
835
836 if let Some(secs) = cli.timeout {
837 tokio::time::timeout(
838 std::time::Duration::from_secs(secs),
839 events::stream_compose_networks(compose_query, docker, compose_net_callback),
840 )
841 .await
842 .map_err(|_| anyhow::anyhow!("compose networks stream timed out after {secs}s"))??;
843 } else {
844 events::stream_compose_networks(compose_query, docker, compose_net_callback).await?;
845 }
846 return Ok(());
847 }
848
849 let mut alert_evaluator = match &parsed.query {
851 Query::Alert(_) => Some(AlertEvaluator::new()),
852 _ => None,
853 };
854 let alert_rule: Option<crate::ast::AlertRule> = match &parsed.query {
855 Query::Alert(r) => Some(r.clone()),
856 _ => None,
857 };
858 let alert_store: Option<Arc<Mutex<SqliteTelemetryStore>>> = if alert_rule.is_some() {
859 cli.store
860 .as_deref()
861 .or(config.store.as_deref())
862 .map(SqliteTelemetryStore::open)
863 .transpose()?
864 .map(|s| Arc::new(Mutex::new(s)))
865 } else {
866 None
867 };
868
869 let docker = std::sync::Arc::new(BollardDockerClient::connect_with_config(&config)?);
871 let metrics = BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker), &config);
872
873 if let Some(interval_secs) = cli.watch {
874 let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
875 loop {
876 tokio::select! {
877 _ = tokio::signal::ctrl_c() => break,
878 _ = interval.tick() => {
879 if let (Some(ev), Some(rule)) = (&mut alert_evaluator, &alert_rule) {
880 let samples = if let Some(secs) = cli.timeout {
882 match tokio::time::timeout(
883 std::time::Duration::from_secs(secs),
884 BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker), &config).collect(),
885 )
886 .await
887 {
888 Ok(Ok(s)) => s,
889 Ok(Err(e)) => {
890 eprintln!("Metrics collection error: {e}");
891 continue;
892 }
893 Err(_) => {
894 eprintln!("metrics collection timed out after {secs}s");
895 continue;
896 }
897 }
898 } else {
899 match BollardMetricsCollector::with_config(std::sync::Arc::clone(&docker), &config).collect().await {
900 Ok(s) => s,
901 Err(e) => {
902 eprintln!("Metrics collection error: {e}");
903 continue;
904 }
905 }
906 };
907
908 if let Some(ref store) = alert_store
909 && let Ok(mut s) = store.lock() {
910 for sample in &samples {
911 let _ = s.write_metric(sample.clone());
912 }
913 }
914
915 match ev.evaluate_samples(rule, &samples, std::time::Instant::now()) {
916 Ok(events) => {
917 for event in events {
918 match output_format {
919 OutputFormat::Table => println!("{}", alerts::render_alert_event(&event)),
920 OutputFormat::Json | OutputFormat::JsonCompact | OutputFormat::Jsonl => println!("{}", serde_json::to_string(&event)?),
921 OutputFormat::Csv => println!("{},{},{:?}", event.container_name, event.message, event.action),
922 }
923 }
924 }
925 Err(e) => eprintln!("Alert evaluation error: {e}"),
926 }
927 } else {
928 let result = if let Some(secs) = cli.timeout {
930 let q = parsed.query.clone(); tokio::time::timeout(
931 std::time::Duration::from_secs(secs),
932 executor::execute_with_metrics(&q, Arc::clone(&docker), &metrics),
933 )
934 .await
935 .map_err(|_| anyhow::anyhow!("query timed out after {secs}s"))?
936 } else {
937 executor::execute_with_metrics(&parsed.query, Arc::clone(&docker), &metrics).await
938 };
939
940 match result {
941 Ok(ref result) => {
942 if let Err(e) = output_result(result) {
943 eprintln!("Error writing output: {e}");
944 }
945 if let Err(e) = run_exports(&cli, result).await {
946 eprintln!("Export error: {e}");
947 }
948 }
949 Err(e) => eprintln!("Error: {e}"),
950 }
951 }
952 }
953 }
954 }
955 return Ok(());
956 }
957
958 let result = if let Some(secs) = cli.timeout {
960 tokio::time::timeout(
961 std::time::Duration::from_secs(secs),
962 executor::execute_with_metrics(&parsed.query, Arc::clone(&docker), &metrics),
963 )
964 .await
965 .map_err(|_| anyhow::anyhow!("query timed out after {secs}s"))?
966 } else {
967 executor::execute_with_metrics(&parsed.query, Arc::clone(&docker), &metrics).await
968 }?;
969
970 if cli.diff {
971 let store_path = cli
972 .store
973 .as_deref()
974 .or(config.store.as_deref())
975 .ok_or_else(|| anyhow::anyhow!("--diff requires --store <path>"))?;
976 let store = SqliteTelemetryStore::open(store_path)?;
977 let diff_output = executor::render_diff(&result, &store)?;
978 println!("{diff_output}");
979 return Ok(());
980 }
981
982 output_result(&result)?;
983 run_exports(&cli, &result).await?;
984
985 Ok(())
986}
987
988async fn run_exports(cli: &Cli, result: &ExecutionResult) -> anyhow::Result<()> {
990 if let Some(ref url) = cli.export_influx {
991 eprintln!(
992 "Pushing {} rows to InfluxDB at {url} ...",
993 result.rows.len()
994 );
995 export::push_to_influxdb(url, result).await?;
996 }
997 if let Some(ref url) = cli.export_grafana_loki {
998 eprintln!(
999 "Pushing {} rows to Grafana Loki at {url} ...",
1000 result.rows.len()
1001 );
1002 export::push_to_loki(url, result).await?;
1003 }
1004 if let Some(ref url) = cli.export_prometheus {
1005 eprintln!(
1006 "Pushing {} rows to Prometheus Pushgateway at {url} ...",
1007 result.rows.len()
1008 );
1009 export::push_to_prometheus(url, result).await?;
1010 }
1011 Ok(())
1012}
1013const fn needs_store(query: &Query) -> bool {
1014 match query {
1015 Query::Inspect(q) => q.at.is_some(),
1016 Query::Events(q) => q.time.is_some(),
1017 _ => false,
1018 }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 use super::*;
1024
1025 const TEST_RUN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
1027
1028 #[tokio::test]
1029 async fn rejects_empty_query() {
1030 let cli = Cli {
1031 query: Some(" ".to_owned()),
1032 file: None,
1033 output: None,
1034 store: None,
1035 collect: false,
1036 metrics_interval: DEFAULT_METRICS_INTERVAL,
1037 snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1038 store_stats: false,
1039 apply_retention: false,
1040 explain: false,
1041 watch: None,
1042 timeout: None,
1043 export: None,
1044 export_format: None,
1045 export_influx: None,
1046 export_grafana_loki: None,
1047 export_prometheus: None,
1048 host: None,
1049 completion: None,
1050 diff: false,
1051 theme: None,
1052 command: None,
1053 };
1054
1055 let error = run(cli).await.unwrap_err();
1056
1057 assert!(error.to_string().contains("empty DOL query"));
1058 }
1059
1060 #[tokio::test]
1061 async fn historical_query_requires_store_flag() {
1062 let cli = Cli {
1063 query: Some("inspect container api at \"2026-01-01 12:00:00\"".to_owned()),
1064 file: None,
1065 output: None,
1066 store: None,
1067 collect: false,
1068 metrics_interval: DEFAULT_METRICS_INTERVAL,
1069 snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1070 store_stats: false,
1071 apply_retention: false,
1072 explain: false,
1073 watch: None,
1074 timeout: None,
1075 export: None,
1076 export_format: None,
1077 export_influx: None,
1078 export_grafana_loki: None,
1079 export_prometheus: None,
1080 host: None,
1081 completion: None,
1082 diff: false,
1083 theme: None,
1084 command: None,
1085 };
1086
1087 let error = run(cli).await.unwrap_err();
1088
1089 assert!(error.to_string().contains("--store"));
1090 }
1091
1092 #[test]
1093 fn test_needs_store_inspect_at_returns_true() {
1094 let query = parser::parse("inspect container test at \"2026-01-01T00:00:00Z\"")
1095 .expect("parse")
1096 .query;
1097 assert!(needs_store(&query));
1098 }
1099
1100 #[test]
1101 fn test_needs_store_events_time_returns_true() {
1102 let query = parser::parse(
1103 "events containers from \"2026-01-01T00:00:00Z\" to \"2026-01-02T00:00:00Z\"",
1104 )
1105 .expect("parse")
1106 .query;
1107 assert!(needs_store(&query));
1108 }
1109
1110 #[test]
1111 fn test_needs_store_plain_observe_returns_false() {
1112 let query = parser::parse("observe containers").expect("parse").query;
1113 assert!(!needs_store(&query));
1114 }
1115
1116 #[test]
1117 fn test_needs_store_alert_returns_false() {
1118 let query = parser::parse("alert when cpu > 80% for 30s then print \"alert\"")
1119 .expect("parse")
1120 .query;
1121 assert!(!needs_store(&query));
1122 }
1123
1124 #[test]
1125 fn test_needs_store_plain_events_returns_false() {
1126 let query = parser::parse("events containers").expect("parse").query;
1127 assert!(!needs_store(&query));
1128 }
1129
1130 #[test]
1131 fn test_apply_host_sequential() {
1132 let original = std::env::var("DOCKER_HOST").ok();
1133
1134 unsafe {
1136 std::env::remove_var("DOCKER_HOST");
1137 }
1138 apply_host(Some("tcp://192.168.1.100:2375"), None);
1139 assert_eq!(
1140 std::env::var("DOCKER_HOST").unwrap(),
1141 "tcp://192.168.1.100:2375"
1142 );
1143
1144 apply_host(Some("tcp://cli:2375"), Some("tcp://config:2375"));
1146 assert_eq!(std::env::var("DOCKER_HOST").unwrap(), "tcp://cli:2375");
1147
1148 unsafe {
1150 std::env::remove_var("DOCKER_HOST");
1151 }
1152 apply_host(None, Some("tcp://config:2375"));
1153 assert_eq!(std::env::var("DOCKER_HOST").unwrap(), "tcp://config:2375");
1154
1155 unsafe {
1157 std::env::remove_var("DOCKER_HOST");
1158 }
1159 apply_host(None, None);
1160 assert_eq!(std::env::var("DOCKER_HOST").unwrap_or_default(), "");
1161
1162 match original {
1164 Some(v) => unsafe {
1165 std::env::set_var("DOCKER_HOST", v);
1166 },
1167 None => unsafe {
1168 std::env::remove_var("DOCKER_HOST");
1169 },
1170 }
1171 }
1172
1173 #[test]
1174 fn test_output_format_value_enum() {
1175 assert!(matches!(OutputFormat::Table, OutputFormat::Table));
1177 assert!(matches!(OutputFormat::Json, OutputFormat::Json));
1178 assert!(matches!(
1179 OutputFormat::JsonCompact,
1180 OutputFormat::JsonCompact
1181 ));
1182 assert!(matches!(OutputFormat::Csv, OutputFormat::Csv));
1183 assert!(matches!(OutputFormat::Jsonl, OutputFormat::Jsonl));
1184 }
1185
1186 #[test]
1187 fn test_output_format_jsoncompact_is_distinct() {
1188 match OutputFormat::JsonCompact {
1190 OutputFormat::Json => panic!("JsonCompact should not equal Json"),
1191 OutputFormat::JsonCompact => {}
1192 _ => {}
1193 }
1194 }
1195
1196 #[tokio::test]
1197 async fn watch_with_alert_runs_without_panic() {
1198 let cli = Cli {
1199 query: Some(r#"alert when cpu > 80% for 30s then print "High""#.to_owned()),
1200 file: None,
1201 watch: Some(1),
1202 output: None,
1203 store: None,
1204 collect: false,
1205 metrics_interval: DEFAULT_METRICS_INTERVAL,
1206 snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1207 store_stats: false,
1208 apply_retention: false,
1209 explain: false,
1210 timeout: None,
1211 export: None,
1212 export_format: None,
1213 export_influx: None,
1214 export_grafana_loki: None,
1215 export_prometheus: None,
1216 host: None,
1217 completion: None,
1218 diff: false,
1219 theme: None,
1220 command: None,
1221 };
1222
1223 let result = tokio::time::timeout(TEST_RUN_TIMEOUT, run(cli)).await;
1225
1226 assert!(
1227 result.is_err(),
1228 "watch+alert loop should run until timeout (no docker)"
1229 );
1230 }
1231
1232 #[cfg_attr(
1235 target_os = "windows",
1236 ignore = "flaky on Windows (ctrl_c resolves early in CI)"
1237 )]
1238 #[tokio::test]
1239 async fn watch_with_alert_with_timeout() {
1240 let cli = Cli {
1241 query: Some(r#"alert when cpu > 80% for 30s then print "High""#.to_owned()),
1242 file: None,
1243 watch: Some(5),
1244 output: None,
1245 store: None,
1246 collect: false,
1247 metrics_interval: DEFAULT_METRICS_INTERVAL,
1248 snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1249 store_stats: false,
1250 apply_retention: false,
1251 explain: false,
1252 timeout: Some(1),
1253 export: None,
1254 export_format: None,
1255 export_influx: None,
1256 export_grafana_loki: None,
1257 export_prometheus: None,
1258 host: None,
1259 completion: None,
1260 diff: false,
1261 theme: None,
1262 command: None,
1263 };
1264
1265 let result = tokio::time::timeout(TEST_RUN_TIMEOUT, run(cli)).await;
1268
1269 assert!(
1270 result.is_err(),
1271 "watch+alert with timeout should still loop indefinitely"
1272 );
1273 }
1274
1275 #[tokio::test]
1276 async fn alert_without_watch_runs_dedicated_loop() {
1277 let cli = Cli {
1278 query: Some(r#"alert when cpu > 80% for 30s then print "High""#.to_owned()),
1279 file: None,
1280 watch: None,
1281 output: None,
1282 store: None,
1283 collect: false,
1284 metrics_interval: DEFAULT_METRICS_INTERVAL,
1285 snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
1286 store_stats: false,
1287 apply_retention: false,
1288 explain: false,
1289 timeout: None,
1290 export: None,
1291 export_format: None,
1292 export_influx: None,
1293 export_grafana_loki: None,
1294 export_prometheus: None,
1295 host: None,
1296 completion: None,
1297 diff: false,
1298 theme: None,
1299 command: None,
1300 };
1301
1302 let result = tokio::time::timeout(TEST_RUN_TIMEOUT, run(cli)).await;
1304
1305 assert!(
1306 result.is_err(),
1307 "dedicated alert loop should run until timeout (no docker)"
1308 );
1309 }
1310}