Browse Source

CLI Rework (#979)

This pull request initiates a series of CLI rework efforts aimed at
improving maintainability, consistency, and user experience.
tags/v0.3.12-fix
Haixuan Xavier Tao GitHub 6 months ago
parent
commit
135a45138d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
22 changed files with 1043 additions and 860 deletions
  1. +1
    -1
      apis/python/node/src/lib.rs
  2. +41
    -4
      binaries/cli/src/command/build/mod.rs
  3. +41
    -1
      binaries/cli/src/command/check.rs
  4. +66
    -0
      binaries/cli/src/command/coordinator.rs
  5. +91
    -0
      binaries/cli/src/command/daemon.rs
  6. +28
    -0
      binaries/cli/src/command/destroy.rs
  7. +28
    -4
      binaries/cli/src/command/graph.rs
  8. +1
    -0
      binaries/cli/src/command/graph/.gitignore
  9. +0
    -0
      binaries/cli/src/command/graph/mermaid-template.html
  10. +59
    -0
      binaries/cli/src/command/list.rs
  11. +46
    -1
      binaries/cli/src/command/logs.rs
  12. +96
    -52
      binaries/cli/src/command/mod.rs
  13. +21
    -0
      binaries/cli/src/command/new.rs
  14. +36
    -4
      binaries/cli/src/command/run.rs
  15. +15
    -0
      binaries/cli/src/command/runtime.rs
  16. +139
    -0
      binaries/cli/src/command/self_.rs
  17. +1
    -1
      binaries/cli/src/command/start/attach.rs
  18. +78
    -48
      binaries/cli/src/command/start/mod.rs
  19. +111
    -0
      binaries/cli/src/command/stop.rs
  20. +20
    -1
      binaries/cli/src/command/up.rs
  21. +117
    -0
      binaries/cli/src/common.rs
  22. +7
    -743
      binaries/cli/src/lib.rs

+ 1
- 1
apis/python/node/src/lib.rs View File

@@ -381,7 +381,7 @@ pub fn resolve_dataflow(dataflow: String) -> eyre::Result<PathBuf> {
#[pyfunction]
#[pyo3(signature = (dataflow_path, uv=None))]
pub fn run(dataflow_path: String, uv: Option<bool>) -> eyre::Result<()> {
dora_cli::command::run(dataflow_path, uv.unwrap_or_default())
dora_cli::run_func(dataflow_path, uv.unwrap_or_default())
}

#[pymodule]


+ 41
- 4
binaries/cli/src/command/build/mod.rs View File

@@ -5,9 +5,13 @@ use dora_core::{
};
use dora_message::{descriptor::NodeSource, BuildId};
use eyre::Context;
use std::collections::BTreeMap;
use std::{collections::BTreeMap, net::IpAddr};

use crate::{connect_to_coordinator, resolve_dataflow, session::DataflowSession};
use super::{default_tracing, Executable};
use crate::{
common::{connect_to_coordinator, local_working_dir, resolve_dataflow},
session::DataflowSession,
};

use distributed::{build_distributed_dataflow, wait_until_dataflow_built};
use local::build_dataflow_locally;
@@ -16,9 +20,42 @@ mod distributed;
mod git;
mod local;

#[derive(Debug, clap::Args)]
/// Run build commands provided in the given dataflow.
pub struct Build {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH")]
dataflow: String,
/// Address of the dora coordinator
#[clap(long, value_name = "IP")]
coordinator_addr: Option<IpAddr>,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT")]
coordinator_port: Option<u16>,
// Use UV to build nodes.
#[clap(long, action)]
uv: bool,
// Run build on local machine
#[clap(long, action)]
local: bool,
}

impl Executable for Build {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;
build(
self.dataflow,
self.coordinator_addr,
self.coordinator_port,
self.uv,
self.local,
)
}
}

pub fn build(
dataflow: String,
coordinator_addr: Option<std::net::IpAddr>,
coordinator_addr: Option<IpAddr>,
coordinator_port: Option<u16>,
uv: bool,
force_local: bool,
@@ -104,7 +141,7 @@ pub fn build(
BuildKind::ThroughCoordinator {
mut coordinator_session,
} => {
let local_working_dir = super::local_working_dir(
let local_working_dir = local_working_dir(
&dataflow_path,
&dataflow_descriptor,
&mut *coordinator_session,


+ 41
- 1
binaries/cli/src/command/check.rs View File

@@ -1,11 +1,15 @@
use crate::connect_to_coordinator;
use super::{default_tracing, Executable};
use crate::{common::connect_to_coordinator, LOCALHOST};
use communication_layer_request_reply::TcpRequestReplyConnection;
use dora_core::descriptor::DescriptorExt;
use dora_core::{descriptor::Descriptor, topics::DORA_COORDINATOR_PORT_CONTROL_DEFAULT};
use dora_message::{cli_to_coordinator::ControlRequest, coordinator_to_cli::ControlRequestReply};
use eyre::{bail, Context};
use std::{
io::{IsTerminal, Write},
net::SocketAddr,
};
use std::{net::IpAddr, path::PathBuf};
use termcolor::{Color, ColorChoice, ColorSpec, WriteColor};

pub fn check_environment(coordinator_addr: SocketAddr) -> eyre::Result<()> {
@@ -75,3 +79,39 @@ pub fn daemon_running(session: &mut TcpRequestReplyConnection) -> Result<bool, e

Ok(running)
}

#[derive(Debug, clap::Args)]
/// Check if the coordinator and the daemon is running.
pub struct Check {
/// Path to the dataflow descriptor file (enables additional checks)
#[clap(long, value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
dataflow: Option<PathBuf>,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
}

impl Executable for Check {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;

match self.dataflow {
Some(dataflow) => {
let working_dir = dataflow
.canonicalize()
.context("failed to canonicalize dataflow path")?
.parent()
.ok_or_else(|| eyre::eyre!("dataflow path has no parent dir"))?
.to_owned();
Descriptor::blocking_read(&dataflow)?.check(&working_dir)?;
check_environment((self.coordinator_addr, self.coordinator_port).into())?
}
None => check_environment((self.coordinator_addr, self.coordinator_port).into())?,
}

Ok(())
}
}

+ 66
- 0
binaries/cli/src/command/coordinator.rs View File

@@ -0,0 +1,66 @@
use super::Executable;
use crate::LISTEN_WILDCARD;
use dora_coordinator::Event;
use dora_core::topics::{DORA_COORDINATOR_PORT_CONTROL_DEFAULT, DORA_COORDINATOR_PORT_DEFAULT};

#[cfg(feature = "tracing")]
use dora_tracing::TracingBuilder;

use eyre::Context;
use std::net::{IpAddr, SocketAddr};
use tokio::runtime::Builder;
use tracing::level_filters::LevelFilter;

#[derive(Debug, clap::Args)]
/// Run coordinator
pub struct Coordinator {
/// Network interface to bind to for daemon communication
#[clap(long, default_value_t = LISTEN_WILDCARD)]
interface: IpAddr,
/// Port number to bind to for daemon communication
#[clap(long, default_value_t = DORA_COORDINATOR_PORT_DEFAULT)]
port: u16,
/// Network interface to bind to for control communication
#[clap(long, default_value_t = LISTEN_WILDCARD)]
control_interface: IpAddr,
/// Port number to bind to for control communication
#[clap(long, default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
control_port: u16,
/// Suppresses all log output to stdout.
#[clap(long)]
quiet: bool,
}

impl Executable for Coordinator {
fn execute(self) -> eyre::Result<()> {
#[cfg(feature = "tracing")]
{
let name = "dora-coordinator";
let mut builder = TracingBuilder::new(name);
if !self.quiet {
builder = builder.with_stdout("info");
}
builder = builder.with_file(name, LevelFilter::INFO)?;
builder
.build()
.wrap_err("failed to set up tracing subscriber")?;
}

let rt = Builder::new_multi_thread()
.enable_all()
.build()
.context("tokio runtime failed")?;
rt.block_on(async {
let bind = SocketAddr::new(self.interface, self.port);
let bind_control = SocketAddr::new(self.control_interface, self.control_port);
let (port, task) =
dora_coordinator::start(bind, bind_control, futures::stream::empty::<Event>())
.await?;
if !self.quiet {
println!("Listening for incoming daemon connection on {port}");
}
task.await
})
.context("failed to run dora-coordinator")
}
}

+ 91
- 0
binaries/cli/src/command/daemon.rs View File

@@ -0,0 +1,91 @@
use super::Executable;
use crate::{common::handle_dataflow_result, session::DataflowSession};
use dora_core::topics::{
DORA_COORDINATOR_PORT_DEFAULT, DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT, LOCALHOST,
};

use dora_daemon::LogDestination;
#[cfg(feature = "tracing")]
use dora_tracing::TracingBuilder;

use eyre::Context;
use std::{
net::{IpAddr, SocketAddr},
path::PathBuf,
};
use tokio::runtime::Builder;
use tracing::level_filters::LevelFilter;

#[derive(Debug, clap::Args)]
/// Run daemon
pub struct Daemon {
/// Unique identifier for the machine (required for distributed dataflows)
#[clap(long)]
machine_id: Option<String>,
/// Local listen port for event such as dynamic node.
#[clap(long, default_value_t = DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT)]
local_listen_port: u16,
/// Address and port number of the dora coordinator
#[clap(long, short, default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, default_value_t = DORA_COORDINATOR_PORT_DEFAULT)]
coordinator_port: u16,
#[clap(long, hide = true)]
run_dataflow: Option<PathBuf>,
/// Suppresses all log output to stdout.
#[clap(long)]
quiet: bool,
}

impl Executable for Daemon {
fn execute(self) -> eyre::Result<()> {
#[cfg(feature = "tracing")]
{
let name = "dora-daemon";
let filename = self
.machine_id
.as_ref()
.map(|id| format!("{name}-{id}"))
.unwrap_or(name.to_string());
let mut builder = TracingBuilder::new(name);
if !self.quiet {
builder = builder.with_stdout("info,zenoh=warn");
}
builder = builder.with_file(filename, LevelFilter::INFO)?;
builder
.build()
.wrap_err("failed to set up tracing subscriber")?;
}

let rt = Builder::new_multi_thread()
.enable_all()
.build()
.context("tokio runtime failed")?;
rt.block_on(async {
match self.run_dataflow {
Some(dataflow_path) => {
tracing::info!("Starting dataflow `{}`", dataflow_path.display());
if self.coordinator_addr != LOCALHOST {
tracing::info!(
"Not using coordinator addr {} as `run_dataflow` is for local dataflow only. Please use the `start` command for remote coordinator",
self.coordinator_addr
);
}
let dataflow_session =
DataflowSession::read_session(&dataflow_path).context("failed to read DataflowSession")?;

let result = dora_daemon::Daemon::run_dataflow(&dataflow_path,
dataflow_session.build_id, dataflow_session.local_build, dataflow_session.session_id, false,
LogDestination::Tracing,
).await?;
handle_dataflow_result(result, None)
}
None => {
dora_daemon::Daemon::run(SocketAddr::new(self.coordinator_addr, self.coordinator_port), self.machine_id, self.local_listen_port).await
}
}
})
.context("failed to run dora-daemon")
}
}

+ 28
- 0
binaries/cli/src/command/destroy.rs View File

@@ -0,0 +1,28 @@
use super::{default_tracing, up, Executable};
use dora_core::topics::{DORA_COORDINATOR_PORT_CONTROL_DEFAULT, LOCALHOST};
use std::net::IpAddr;
use std::path::PathBuf;

#[derive(Debug, clap::Args)]
/// Destroy running coordinator and daemon. If some dataflows are still running, they will be stopped first.
pub struct Destroy {
/// Use a custom configuration
#[clap(long, hide = true)]
config: Option<PathBuf>,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
}

impl Executable for Destroy {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;
up::destroy(
self.config.as_deref(),
(self.coordinator_addr, self.coordinator_port).into(),
)
}
}

binaries/cli/src/graph/mod.rs → binaries/cli/src/command/graph.rs View File

@@ -1,11 +1,35 @@
use std::{fs::File, io::Write, path::Path};

use super::Executable;
use dora_core::descriptor::{Descriptor, DescriptorExt};
use eyre::Context;
use std::{
fs::File,
io::Write,
path::{Path, PathBuf},
};

const MERMAID_TEMPLATE: &str = include_str!("graph/mermaid-template.html");

#[derive(Debug, clap::Args)]
/// Generate a visualization of the given graph using mermaid.js. Use --open to open browser.
pub struct Graph {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
dataflow: PathBuf,
/// Visualize the dataflow as a Mermaid diagram (instead of HTML)
#[clap(long, action)]
mermaid: bool,
/// Open the HTML visualization in the browser
#[clap(long, action)]
open: bool,
}

const MERMAID_TEMPLATE: &str = include_str!("mermaid-template.html");
impl Executable for Graph {
fn execute(self) -> eyre::Result<()> {
create(self.dataflow, self.mermaid, self.open)
}
}

pub(crate) fn create(dataflow: std::path::PathBuf, mermaid: bool, open: bool) -> eyre::Result<()> {
fn create(dataflow: std::path::PathBuf, mermaid: bool, open: bool) -> eyre::Result<()> {
if mermaid {
let visualized = visualize_as_mermaid(&dataflow)?;
println!("{visualized}");

+ 1
- 0
binaries/cli/src/command/graph/.gitignore View File

@@ -0,0 +1 @@
!*template.html

binaries/cli/src/graph/mermaid-template.html → binaries/cli/src/command/graph/mermaid-template.html View File


+ 59
- 0
binaries/cli/src/command/list.rs View File

@@ -0,0 +1,59 @@
use std::io::Write;

use super::{default_tracing, Executable};
use crate::{
common::{connect_to_coordinator, query_running_dataflows},
LOCALHOST,
};
use clap::Args;
use communication_layer_request_reply::TcpRequestReplyConnection;
use dora_core::topics::DORA_COORDINATOR_PORT_CONTROL_DEFAULT;
use dora_message::coordinator_to_cli::DataflowStatus;
use eyre::eyre;
use tabwriter::TabWriter;

#[derive(Debug, Args)]
/// List running dataflows.
pub struct ListArgs {
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
pub coordinator_addr: std::net::IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
pub coordinator_port: u16,
}

impl Executable for ListArgs {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;

let mut session =
connect_to_coordinator((self.coordinator_addr, self.coordinator_port).into())
.map_err(|_| eyre!("Failed to connect to coordinator"))?;

list(&mut *session)
}
}

fn list(session: &mut TcpRequestReplyConnection) -> Result<(), eyre::ErrReport> {
let list = query_running_dataflows(session)?;

let mut tw = TabWriter::new(vec![]);
tw.write_all(b"UUID\tName\tStatus\n")?;
for entry in list.0 {
let uuid = entry.id.uuid;
let name = entry.id.name.unwrap_or_default();
let status = match entry.status {
DataflowStatus::Running => "Running",
DataflowStatus::Finished => "Succeeded",
DataflowStatus::Failed => "Failed",
};
tw.write_all(format!("{uuid}\t{name}\t{status}\n").as_bytes())?;
}
tw.flush()?;
let formatted = String::from_utf8(tw.into_inner()?)?;

println!("{formatted}");

Ok(())
}

+ 46
- 1
binaries/cli/src/command/logs.rs View File

@@ -1,9 +1,54 @@
use super::{default_tracing, Executable};
use crate::common::{connect_to_coordinator, query_running_dataflows};
use bat::{Input, PrettyPrinter};
use clap::Args;
use communication_layer_request_reply::TcpRequestReplyConnection;
use dora_core::topics::{DORA_COORDINATOR_PORT_CONTROL_DEFAULT, LOCALHOST};
use dora_message::{cli_to_coordinator::ControlRequest, coordinator_to_cli::ControlRequestReply};
use eyre::{bail, Context, Result};
use uuid::Uuid;

use bat::{Input, PrettyPrinter};
#[derive(Debug, Args)]
/// Show logs of a given dataflow and node.
pub struct LogsArgs {
/// Identifier of the dataflow
#[clap(value_name = "UUID_OR_NAME")]
pub dataflow: Option<String>,
/// Show logs for the given node
#[clap(value_name = "NAME")]
pub node: String,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
pub coordinator_addr: std::net::IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
pub coordinator_port: u16,
}

impl Executable for LogsArgs {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;

let mut session =
connect_to_coordinator((self.coordinator_addr, self.coordinator_port).into())
.wrap_err("failed to connect to dora coordinator")?;
let list =
query_running_dataflows(&mut *session).wrap_err("failed to query running dataflows")?;
if let Some(dataflow) = self.dataflow {
let uuid = Uuid::parse_str(&dataflow).ok();
let name = if uuid.is_some() { None } else { Some(dataflow) };
logs(&mut *session, uuid, name, self.node)
} else {
let active = list.get_active();
let uuid = match &active[..] {
[] => bail!("No dataflows are running"),
[uuid] => uuid.clone(),
_ => inquire::Select::new("Choose dataflow to show logs:", active).prompt()?,
};
logs(&mut *session, Some(uuid.uuid), None, self.node)
}
}
}

pub fn logs(
session: &mut TcpRequestReplyConnection,


+ 96
- 52
binaries/cli/src/command/mod.rs View File

@@ -1,60 +1,104 @@
pub use build::build;
pub use logs::logs;
pub use run::run;
pub use start::start;

use std::path::{Path, PathBuf};

use communication_layer_request_reply::TcpRequestReplyConnection;
use dora_core::descriptor::Descriptor;
use dora_message::{cli_to_coordinator::ControlRequest, coordinator_to_cli::ControlRequestReply};
use eyre::{bail, Context, ContextCompat};

mod build;
pub mod check;
mod check;
mod coordinator;
mod daemon;
mod destroy;
mod graph;
mod list;
mod logs;
mod new;
mod run;
mod runtime;
mod self_;
mod start;
pub mod up;

fn local_working_dir(
dataflow_path: &Path,
dataflow_descriptor: &Descriptor,
coordinator_session: &mut TcpRequestReplyConnection,
) -> eyre::Result<Option<PathBuf>> {
Ok(
if dataflow_descriptor
.nodes
.iter()
.all(|n| n.deploy.as_ref().map(|d| d.machine.as_ref()).is_none())
&& cli_and_daemon_on_same_machine(coordinator_session)?
{
Some(
dunce::canonicalize(dataflow_path)
.context("failed to canonicalize dataflow file path")?
.parent()
.context("dataflow path has no parent dir")?
.to_owned(),
)
} else {
None
},
)
mod stop;
mod up;

pub use run::run_func;

use build::Build;
use check::Check;
use coordinator::Coordinator;
use daemon::Daemon;
use destroy::Destroy;
use eyre::Context;
use graph::Graph;
use list::ListArgs;
use logs::LogsArgs;
use new::NewArgs;
use run::Run;
use runtime::Runtime;
use self_::SelfSubCommand;
use start::Start;
use stop::Stop;
use up::Up;

/// dora-rs cli client
#[derive(Debug, clap::Subcommand)]
pub enum Command {
Check(Check),
Graph(Graph),
Build(Build),
New(NewArgs),
Run(Run),
Up(Up),
Destroy(Destroy),
Start(Start),
Stop(Stop),
List(ListArgs),
// Planned for future releases:
// Dashboard,
#[command(allow_missing_positional = true)]
Logs(LogsArgs),
// Metrics,
// Stats,
// Get,
// Upgrade,
Daemon(Daemon),
Runtime(Runtime),
Coordinator(Coordinator),

Self_ {
#[clap(subcommand)]
command: SelfSubCommand,
},
}

fn default_tracing() -> eyre::Result<()> {
#[cfg(feature = "tracing")]
{
use dora_tracing::TracingBuilder;

TracingBuilder::new("dora-cli")
.with_stdout("warn")
.build()
.wrap_err("failed to set up tracing subscriber")?;
}
Ok(())
}

pub trait Executable {
fn execute(self) -> eyre::Result<()>;
}

fn cli_and_daemon_on_same_machine(session: &mut TcpRequestReplyConnection) -> eyre::Result<bool> {
let reply_raw = session
.request(&serde_json::to_vec(&ControlRequest::CliAndDefaultDaemonOnSameMachine).unwrap())
.wrap_err("failed to send start dataflow message")?;

let result: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
match result {
ControlRequestReply::CliAndDefaultDaemonIps {
default_daemon,
cli,
} => Ok(default_daemon.is_some() && default_daemon == cli),
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected start dataflow reply: {other:?}"),
impl Executable for Command {
fn execute(self) -> eyre::Result<()> {
match self {
Command::Check(args) => args.execute(),
Command::Coordinator(args) => args.execute(),
Command::Graph(args) => args.execute(),
Command::Build(args) => args.execute(),
Command::New(args) => args.execute(),
Command::Run(args) => args.execute(),
Command::Up(args) => args.execute(),
Command::Destroy(args) => args.execute(),
Command::Start(args) => args.execute(),
Command::Stop(args) => args.execute(),
Command::List(args) => args.execute(),
Command::Logs(args) => args.execute(),
Command::Daemon(args) => args.execute(),
Command::Self_ { command } => command.execute(),
Command::Runtime(args) => args.execute(),
}
}
}

+ 21
- 0
binaries/cli/src/command/new.rs View File

@@ -0,0 +1,21 @@
use clap::Args;

use super::{default_tracing, Executable};

#[derive(Debug, Args)]
/// Generate a new project or node. Choose the language between Rust, Python, C or C++.
pub struct NewArgs {
#[clap(flatten)]
// TODO!: better impl
args: crate::CommandNew,
/// Internal flag for creating with path dependencies
#[clap(hide = true, long)]
pub internal_create_with_path_dependencies: bool,
}

impl Executable for NewArgs {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;
crate::template::create(self.args, self.internal_create_with_path_dependencies)
}
}

+ 36
- 4
binaries/cli/src/command/run.rs View File

@@ -1,12 +1,38 @@
use super::Executable;
use crate::{
common::{handle_dataflow_result, resolve_dataflow},
output::print_log_message,
session::DataflowSession,
};
use dora_daemon::{flume, Daemon, LogDestination};
use dora_tracing::TracingBuilder;
use eyre::Context;
use tokio::runtime::Builder;

use crate::{
handle_dataflow_result, output::print_log_message, resolve_dataflow, session::DataflowSession,
};
#[derive(Debug, clap::Args)]
/// Run a dataflow locally.
///
/// Directly runs the given dataflow without connecting to a dora
/// coordinator or daemon. The dataflow is executed on the local machine.
pub struct Run {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH")]
dataflow: String,
// Use UV to run nodes.
#[clap(long, action)]
uv: bool,
}

pub fn run_func(dataflow: String, uv: bool) -> eyre::Result<()> {
#[cfg(feature = "tracing")]
{
let log_level = std::env::var("RUST_LOG").ok().unwrap_or("info".to_string());
TracingBuilder::new("run")
.with_stdout(log_level)
.build()
.wrap_err("failed to set up tracing subscriber")?;
}

pub fn run(dataflow: String, uv: bool) -> Result<(), eyre::Error> {
let dataflow_path = resolve_dataflow(dataflow).context("could not resolve dataflow")?;
let dataflow_session =
DataflowSession::read_session(&dataflow_path).context("failed to read DataflowSession")?;
@@ -32,3 +58,9 @@ pub fn run(dataflow: String, uv: bool) -> Result<(), eyre::Error> {
))?;
handle_dataflow_result(result, None)
}

impl Executable for Run {
fn execute(self) -> eyre::Result<()> {
run_func(self.dataflow, self.uv)
}
}

+ 15
- 0
binaries/cli/src/command/runtime.rs View File

@@ -0,0 +1,15 @@
use eyre::Context;

use super::Executable;

#[derive(Debug, clap::Args)]
/// Run runtime
pub struct Runtime;

impl Executable for Runtime {
fn execute(self) -> eyre::Result<()> {
// No tracing: Do not set the runtime in the cli.
// ref: 72b4be808122574fcfda69650954318e0355cc7b cli::run
dora_runtime::main().context("Failed to run dora-runtime")
}
}

+ 139
- 0
binaries/cli/src/command/self_.rs View File

@@ -0,0 +1,139 @@
use super::{default_tracing, Executable};
use clap::Subcommand;
use eyre::{bail, Context};

#[derive(Debug, Subcommand)]
/// Dora CLI self-management commands
pub enum SelfSubCommand {
/// Check for updates or update the CLI
Update {
/// Only check for updates without installing
#[clap(long)]
check_only: bool,
},
/// Remove The Dora CLI from the system
Uninstall {
/// Force uninstallation without confirmation
#[clap(long)]
force: bool,
},
}

impl Executable for SelfSubCommand {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;

match self {
SelfSubCommand::Update { check_only } => {
println!("Checking for updates...");

#[cfg(target_os = "linux")]
let bin_path_in_archive = format!("dora-cli-{}/dora", env!("TARGET"));
#[cfg(target_os = "macos")]
let bin_path_in_archive = format!("dora-cli-{}/dora", env!("TARGET"));
#[cfg(target_os = "windows")]
let bin_path_in_archive = String::from("dora.exe");

let status = self_update::backends::github::Update::configure()
.repo_owner("dora-rs")
.repo_name("dora")
.bin_path_in_archive(&bin_path_in_archive)
.bin_name("dora")
.show_download_progress(true)
.current_version(env!("CARGO_PKG_VERSION"))
.build()?;

if check_only {
// Only check if an update is available
match status.get_latest_release() {
Ok(release) => {
let current_version = self_update::cargo_crate_version!();
if current_version != release.version {
println!(
"An update is available: {}. Run 'dora self update' to update",
release.version
);
} else {
println!(
"Dora CLI is already at the latest version: {}",
current_version
);
}
}
Err(e) => println!("Failed to check for updates: {}", e),
}
} else {
// Perform the actual update
match status.update() {
Ok(update_status) => match update_status {
self_update::Status::UpToDate(version) => {
println!("Dora CLI is already at the latest version: {}", version);
}
self_update::Status::Updated(version) => {
println!("Successfully updated Dora CLI to version: {}", version);
}
},
Err(e) => println!("Failed to update: {}", e),
}
}
}
SelfSubCommand::Uninstall { force } => {
if !force {
let confirmed =
inquire::Confirm::new("Are you sure you want to uninstall Dora CLI?")
.with_default(false)
.prompt()
.wrap_err("Uninstallation cancelled")?;

if !confirmed {
println!("Uninstallation cancelled");
return Ok(());
}
}

println!("Uninstalling Dora CLI...");
#[cfg(feature = "python")]
{
println!("Detected Python installation...");

// Try uv pip uninstall first
let uv_status = std::process::Command::new("uv")
.args(["pip", "uninstall", "dora-rs-cli"])
.status();

if let Ok(status) = uv_status {
if status.success() {
println!("Dora CLI has been successfully uninstalled via uv pip.");
return Ok(());
}
}

// Fall back to regular pip uninstall
println!("Trying with pip...");
let status = std::process::Command::new("pip")
.args(["uninstall", "-y", "dora-rs-cli"])
.status()
.wrap_err("Failed to run pip uninstall")?;

if status.success() {
println!("Dora CLI has been successfully uninstalled via pip.");
} else {
bail!("Failed to uninstall Dora CLI via pip.");
}
}
#[cfg(not(feature = "python"))]
{
match self_replace::self_delete() {
Ok(_) => {
println!("Dora CLI has been successfully uninstalled.");
}
Err(e) => {
bail!("Failed to uninstall Dora CLI: {}", e);
}
}
}
}
}
Ok(())
}
}

+ 1
- 1
binaries/cli/src/command/start/attach.rs View File

@@ -14,7 +14,7 @@ use std::{path::PathBuf, sync::mpsc, time::Duration};
use tracing::{error, info};
use uuid::Uuid;

use crate::handle_dataflow_result;
use crate::common::handle_dataflow_result;
use crate::output::print_log_message;

pub fn attach_dataflow(


+ 78
- 48
binaries/cli/src/command/start/mod.rs View File

@@ -1,70 +1,101 @@
use super::{default_tracing, Executable};
use crate::{
command::start::attach::attach_dataflow,
common::{connect_to_coordinator, local_working_dir, resolve_dataflow},
output::print_log_message,
session::DataflowSession,
};
use communication_layer_request_reply::{TcpConnection, TcpRequestReplyConnection};
use dora_core::descriptor::{Descriptor, DescriptorExt};
use dora_core::{
descriptor::{Descriptor, DescriptorExt},
topics::{DORA_COORDINATOR_PORT_CONTROL_DEFAULT, LOCALHOST},
};
use dora_message::{
cli_to_coordinator::ControlRequest, common::LogMessage, coordinator_to_cli::ControlRequestReply,
};
use eyre::{bail, Context};
use std::{
net::{SocketAddr, TcpStream},
net::{IpAddr, SocketAddr, TcpStream},
path::PathBuf,
};
use uuid::Uuid;

use crate::{
connect_to_coordinator, output::print_log_message, resolve_dataflow, session::DataflowSession,
};
use attach::attach_dataflow;

mod attach;

pub fn start(
#[derive(Debug, clap::Args)]
/// Start the given dataflow path. Attach a name to the running dataflow by using --name.
pub struct Start {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH")]
dataflow: String,
/// Assign a name to the dataflow
#[clap(long)]
name: Option<String>,
coordinator_socket: SocketAddr,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
/// Attach to the dataflow and wait for its completion
#[clap(long, action)]
attach: bool,
/// Run the dataflow in background
#[clap(long, action)]
detach: bool,
/// Enable hot reloading (Python only)
#[clap(long, action)]
hot_reload: bool,
// Use UV to run nodes.
#[clap(long, action)]
uv: bool,
) -> eyre::Result<()> {
let (dataflow, dataflow_descriptor, mut session, dataflow_id) =
start_dataflow(dataflow, name, coordinator_socket, uv)?;
}

let attach = match (attach, detach) {
(true, true) => eyre::bail!("both `--attach` and `--detach` are given"),
(true, false) => true,
(false, true) => false,
(false, false) => {
println!("attaching to dataflow (use `--detach` to run in background)");
true
}
};
impl Executable for Start {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;
let coordinator_socket = (self.coordinator_addr, self.coordinator_port).into();

if attach {
let log_level = env_logger::Builder::new()
.filter_level(log::LevelFilter::Info)
.parse_default_env()
.build()
.filter();
let (dataflow, dataflow_descriptor, mut session, dataflow_id) =
start_dataflow(self.dataflow, self.name, coordinator_socket, self.uv)?;

attach_dataflow(
dataflow_descriptor,
dataflow,
dataflow_id,
&mut *session,
hot_reload,
coordinator_socket,
log_level,
)
} else {
let print_daemon_name = dataflow_descriptor.nodes.iter().any(|n| n.deploy.is_some());
// wait until dataflow is started
wait_until_dataflow_started(
dataflow_id,
&mut session,
coordinator_socket,
log::LevelFilter::Info,
print_daemon_name,
)
let attach = match (self.attach, self.detach) {
(true, true) => eyre::bail!("both `--attach` and `--detach` are given"),
(true, false) => true,
(false, true) => false,
(false, false) => {
println!("attaching to dataflow (use `--detach` to run in background)");
true
}
};

if attach {
let log_level = env_logger::Builder::new()
.filter_level(log::LevelFilter::Info)
.parse_default_env()
.build()
.filter();

attach_dataflow(
dataflow_descriptor,
dataflow,
dataflow_id,
&mut *session,
self.hot_reload,
coordinator_socket,
log_level,
)
} else {
let print_daemon_name = dataflow_descriptor.nodes.iter().any(|n| n.deploy.is_some());
// wait until dataflow is started
wait_until_dataflow_started(
dataflow_id,
&mut session,
coordinator_socket,
log::LevelFilter::Info,
print_daemon_name,
)
}
}
}

@@ -83,8 +114,7 @@ fn start_dataflow(
let mut session = connect_to_coordinator(coordinator_socket)
.wrap_err("failed to connect to dora coordinator")?;

let local_working_dir =
super::local_working_dir(&dataflow, &dataflow_descriptor, &mut *session)?;
let local_working_dir = local_working_dir(&dataflow, &dataflow_descriptor, &mut *session)?;

let dataflow_id = {
let dataflow = dataflow_descriptor.clone();


+ 111
- 0
binaries/cli/src/command/stop.rs View File

@@ -0,0 +1,111 @@
use super::{default_tracing, Executable};
use crate::common::{connect_to_coordinator, handle_dataflow_result, query_running_dataflows};
use communication_layer_request_reply::TcpRequestReplyConnection;
use dora_core::topics::{DORA_COORDINATOR_PORT_CONTROL_DEFAULT, LOCALHOST};
use dora_message::cli_to_coordinator::ControlRequest;
use dora_message::coordinator_to_cli::ControlRequestReply;
use duration_str::parse;
use eyre::{bail, Context};
use std::net::IpAddr;
use std::time::Duration;
use uuid::Uuid;

#[derive(Debug, clap::Args)]
/// Stop the given dataflow UUID. If no id is provided, you will be able to choose between the running dataflows.
pub struct Stop {
/// UUID of the dataflow that should be stopped
uuid: Option<Uuid>,
/// Name of the dataflow that should be stopped
#[clap(long)]
name: Option<String>,
/// Kill the dataflow if it doesn't stop after the given duration
#[clap(long, value_name = "DURATION")]
#[arg(value_parser = parse)]
grace_duration: Option<Duration>,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
}

impl Executable for Stop {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;
let mut session =
connect_to_coordinator((self.coordinator_addr, self.coordinator_port).into())
.wrap_err("could not connect to dora coordinator")?;
match (self.uuid, self.name) {
(Some(uuid), _) => stop_dataflow(uuid, self.grace_duration, &mut *session),
(None, Some(name)) => stop_dataflow_by_name(name, self.grace_duration, &mut *session),
(None, None) => stop_dataflow_interactive(self.grace_duration, &mut *session),
}
}
}

fn stop_dataflow_interactive(
grace_duration: Option<Duration>,
session: &mut TcpRequestReplyConnection,
) -> eyre::Result<()> {
let list = query_running_dataflows(session).wrap_err("failed to query running dataflows")?;
let active = list.get_active();
if active.is_empty() {
eprintln!("No dataflows are running");
} else {
let selection = inquire::Select::new("Choose dataflow to stop:", active).prompt()?;
stop_dataflow(selection.uuid, grace_duration, session)?;
}

Ok(())
}

fn stop_dataflow(
uuid: Uuid,
grace_duration: Option<Duration>,
session: &mut TcpRequestReplyConnection,
) -> Result<(), eyre::ErrReport> {
let reply_raw = session
.request(
&serde_json::to_vec(&ControlRequest::Stop {
dataflow_uuid: uuid,
grace_duration,
})
.unwrap(),
)
.wrap_err("failed to send dataflow stop message")?;
let result: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
match result {
ControlRequestReply::DataflowStopped { uuid, result } => {
handle_dataflow_result(result, Some(uuid))
}
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected stop dataflow reply: {other:?}"),
}
}

fn stop_dataflow_by_name(
name: String,
grace_duration: Option<Duration>,
session: &mut TcpRequestReplyConnection,
) -> Result<(), eyre::ErrReport> {
let reply_raw = session
.request(
&serde_json::to_vec(&ControlRequest::StopByName {
name,
grace_duration,
})
.unwrap(),
)
.wrap_err("failed to send dataflow stop_by_name message")?;
let result: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
match result {
ControlRequestReply::DataflowStopped { uuid, result } => {
handle_dataflow_result(result, Some(uuid))
}
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected stop dataflow reply: {other:?}"),
}
}

+ 20
- 1
binaries/cli/src/command/up.rs View File

@@ -1,8 +1,27 @@
use crate::{command::check::daemon_running, connect_to_coordinator, LOCALHOST};
use super::check::daemon_running;
use super::{default_tracing, Executable};
use crate::{common::connect_to_coordinator, LOCALHOST};
use dora_core::topics::DORA_COORDINATOR_PORT_CONTROL_DEFAULT;
use dora_message::{cli_to_coordinator::ControlRequest, coordinator_to_cli::ControlRequestReply};
use eyre::{bail, Context, ContextCompat};
use std::path::PathBuf;
use std::{fs, net::SocketAddr, path::Path, process::Command, time::Duration};

#[derive(Debug, clap::Args)]
/// Spawn coordinator and daemon in local mode (with default config)
pub struct Up {
/// Use a custom configuration
#[clap(long, hide = true, value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
config: Option<PathBuf>,
}

impl Executable for Up {
fn execute(self) -> eyre::Result<()> {
default_tracing()?;
up(self.config.as_deref())
}
}

#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct UpConfig {}



+ 117
- 0
binaries/cli/src/common.rs View File

@@ -0,0 +1,117 @@
use crate::formatting::FormatDataflowError;
use communication_layer_request_reply::{RequestReplyLayer, TcpLayer, TcpRequestReplyConnection};
use dora_core::descriptor::{source_is_url, Descriptor};
use dora_download::download_file;
use dora_message::{
cli_to_coordinator::ControlRequest,
coordinator_to_cli::{ControlRequestReply, DataflowList, DataflowResult},
};
use eyre::{bail, Context, ContextCompat};
use std::{
env::current_dir,
net::SocketAddr,
path::{Path, PathBuf},
};
use tokio::runtime::Builder;
use uuid::Uuid;

pub(crate) fn handle_dataflow_result(
result: DataflowResult,
uuid: Option<Uuid>,
) -> Result<(), eyre::Error> {
if result.is_ok() {
Ok(())
} else {
Err(match uuid {
Some(uuid) => {
eyre::eyre!("Dataflow {uuid} failed:\n{}", FormatDataflowError(&result))
}
None => {
eyre::eyre!("Dataflow failed:\n{}", FormatDataflowError(&result))
}
})
}
}

pub(crate) fn query_running_dataflows(
session: &mut TcpRequestReplyConnection,
) -> eyre::Result<DataflowList> {
let reply_raw = session
.request(&serde_json::to_vec(&ControlRequest::List).unwrap())
.wrap_err("failed to send list message")?;
let reply: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
let ids = match reply {
ControlRequestReply::DataflowList(list) => list,
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected list dataflow reply: {other:?}"),
};

Ok(ids)
}

pub(crate) fn connect_to_coordinator(
coordinator_addr: SocketAddr,
) -> std::io::Result<Box<TcpRequestReplyConnection>> {
TcpLayer::new().connect(coordinator_addr)
}

pub(crate) fn resolve_dataflow(dataflow: String) -> eyre::Result<PathBuf> {
let dataflow = if source_is_url(&dataflow) {
// try to download the shared library
let target_path = current_dir().context("Could not access the current dir")?;
let rt = Builder::new_current_thread()
.enable_all()
.build()
.context("tokio runtime failed")?;
rt.block_on(async { download_file(&dataflow, &target_path).await })
.wrap_err("failed to download dataflow yaml file")?
} else {
PathBuf::from(dataflow)
};
Ok(dataflow)
}

pub(crate) fn local_working_dir(
dataflow_path: &Path,
dataflow_descriptor: &Descriptor,
coordinator_session: &mut TcpRequestReplyConnection,
) -> eyre::Result<Option<PathBuf>> {
Ok(
if dataflow_descriptor
.nodes
.iter()
.all(|n| n.deploy.as_ref().map(|d| d.machine.as_ref()).is_none())
&& cli_and_daemon_on_same_machine(coordinator_session)?
{
Some(
dunce::canonicalize(dataflow_path)
.context("failed to canonicalize dataflow file path")?
.parent()
.context("dataflow path has no parent dir")?
.to_owned(),
)
} else {
None
},
)
}

pub(crate) fn cli_and_daemon_on_same_machine(
session: &mut TcpRequestReplyConnection,
) -> eyre::Result<bool> {
let reply_raw = session
.request(&serde_json::to_vec(&ControlRequest::CliAndDefaultDaemonOnSameMachine).unwrap())
.wrap_err("failed to send start dataflow message")?;

let result: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
match result {
ControlRequestReply::CliAndDefaultDaemonIps {
default_daemon,
cli,
} => Ok(default_daemon.is_some() && default_daemon == cli),
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected start dataflow reply: {other:?}"),
}
}

+ 7
- 743
binaries/cli/src/lib.rs View File

@@ -1,42 +1,19 @@
use colored::Colorize;
use communication_layer_request_reply::{RequestReplyLayer, TcpLayer, TcpRequestReplyConnection};
use dora_coordinator::Event;
use dora_core::{
descriptor::{source_is_url, Descriptor, DescriptorExt},
topics::{
DORA_COORDINATOR_PORT_CONTROL_DEFAULT, DORA_COORDINATOR_PORT_DEFAULT,
DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT,
},
};
use dora_daemon::{Daemon, LogDestination};
use dora_download::download_file;
use dora_message::{
cli_to_coordinator::ControlRequest,
coordinator_to_cli::{ControlRequestReply, DataflowList, DataflowResult, DataflowStatus},
};
#[cfg(feature = "tracing")]
use dora_tracing::TracingBuilder;
use duration_str::parse;
use eyre::{bail, Context};
use formatting::FormatDataflowError;
use std::{env::current_dir, io::Write, net::SocketAddr};
use command::Executable;
use std::{
net::{IpAddr, Ipv4Addr},
path::PathBuf,
time::Duration,
};
use tabwriter::TabWriter;
use tokio::runtime::Builder;
use tracing::level_filters::LevelFilter;
use uuid::Uuid;

pub mod command;
mod command;
mod common;
mod formatting;
mod graph;
pub mod output;
pub mod session;
mod template;

pub use command::run_func;

const LOCALHOST: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
const LISTEN_WILDCARD: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));

@@ -44,228 +21,7 @@ const LISTEN_WILDCARD: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
#[clap(version)]
pub struct Args {
#[clap(subcommand)]
command: Command,
}

/// dora-rs cli client
#[derive(Debug, clap::Subcommand)]
enum Command {
/// Check if the coordinator and the daemon is running.
Check {
/// Path to the dataflow descriptor file (enables additional checks)
#[clap(long, value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
dataflow: Option<PathBuf>,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
},
/// Generate a visualization of the given graph using mermaid.js. Use --open to open browser.
Graph {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
dataflow: PathBuf,
/// Visualize the dataflow as a Mermaid diagram (instead of HTML)
#[clap(long, action)]
mermaid: bool,
/// Open the HTML visualization in the browser
#[clap(long, action)]
open: bool,
},
/// Run build commands provided in the given dataflow.
Build {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH")]
dataflow: String,
/// Address of the dora coordinator
#[clap(long, value_name = "IP")]
coordinator_addr: Option<IpAddr>,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT")]
coordinator_port: Option<u16>,
// Use UV to build nodes.
#[clap(long, action)]
uv: bool,
// Run build on local machine
#[clap(long, action)]
local: bool,
},
/// Generate a new project or node. Choose the language between Rust, Python, C or C++.
New {
#[clap(flatten)]
args: CommandNew,
#[clap(hide = true, long)]
internal_create_with_path_dependencies: bool,
},
/// Run a dataflow locally.
///
/// Directly runs the given dataflow without connecting to a dora
/// coordinator or daemon. The dataflow is executed on the local machine.
Run {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH")]
dataflow: String,
// Use UV to run nodes.
#[clap(long, action)]
uv: bool,
},
/// Spawn coordinator and daemon in local mode (with default config)
Up {
/// Use a custom configuration
#[clap(long, hide = true, value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
config: Option<PathBuf>,
},
/// Destroy running coordinator and daemon. If some dataflows are still running, they will be stopped first.
Destroy {
/// Use a custom configuration
#[clap(long, hide = true)]
config: Option<PathBuf>,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
},
/// Start the given dataflow path. Attach a name to the running dataflow by using --name.
Start {
/// Path to the dataflow descriptor file
#[clap(value_name = "PATH")]
dataflow: String,
/// Assign a name to the dataflow
#[clap(long)]
name: Option<String>,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
/// Attach to the dataflow and wait for its completion
#[clap(long, action)]
attach: bool,
/// Run the dataflow in background
#[clap(long, action)]
detach: bool,
/// Enable hot reloading (Python only)
#[clap(long, action)]
hot_reload: bool,
// Use UV to run nodes.
#[clap(long, action)]
uv: bool,
},
/// Stop the given dataflow UUID. If no id is provided, you will be able to choose between the running dataflows.
Stop {
/// UUID of the dataflow that should be stopped
uuid: Option<Uuid>,
/// Name of the dataflow that should be stopped
#[clap(long)]
name: Option<String>,
/// Kill the dataflow if it doesn't stop after the given duration
#[clap(long, value_name = "DURATION")]
#[arg(value_parser = parse)]
grace_duration: Option<Duration>,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
},
/// List running dataflows.
List {
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
},
// Planned for future releases:
// Dashboard,
/// Show logs of a given dataflow and node.
#[command(allow_missing_positional = true)]
Logs {
/// Identifier of the dataflow
#[clap(value_name = "UUID_OR_NAME")]
dataflow: Option<String>,
/// Show logs for the given node
#[clap(value_name = "NAME")]
node: String,
/// Address of the dora coordinator
#[clap(long, value_name = "IP", default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, value_name = "PORT", default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
coordinator_port: u16,
},
// Metrics,
// Stats,
// Get,
// Upgrade,
/// Run daemon
Daemon {
/// Unique identifier for the machine (required for distributed dataflows)
#[clap(long)]
machine_id: Option<String>,
/// Local listen port for event such as dynamic node.
#[clap(long, default_value_t = DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT)]
local_listen_port: u16,
/// Address and port number of the dora coordinator
#[clap(long, short, default_value_t = LOCALHOST)]
coordinator_addr: IpAddr,
/// Port number of the coordinator control server
#[clap(long, default_value_t = DORA_COORDINATOR_PORT_DEFAULT)]
coordinator_port: u16,
#[clap(long, hide = true)]
run_dataflow: Option<PathBuf>,
/// Suppresses all log output to stdout.
#[clap(long)]
quiet: bool,
},
/// Run runtime
Runtime,
/// Run coordinator
Coordinator {
/// Network interface to bind to for daemon communication
#[clap(long, default_value_t = LISTEN_WILDCARD)]
interface: IpAddr,
/// Port number to bind to for daemon communication
#[clap(long, default_value_t = DORA_COORDINATOR_PORT_DEFAULT)]
port: u16,
/// Network interface to bind to for control communication
#[clap(long, default_value_t = LISTEN_WILDCARD)]
control_interface: IpAddr,
/// Port number to bind to for control communication
#[clap(long, default_value_t = DORA_COORDINATOR_PORT_CONTROL_DEFAULT)]
control_port: u16,
/// Suppresses all log output to stdout.
#[clap(long)]
quiet: bool,
},
/// Dora CLI self-management commands
Self_ {
#[clap(subcommand)]
command: SelfSubCommand,
},
}

#[derive(Debug, clap::Subcommand)]
enum SelfSubCommand {
/// Check for updates or update the CLI
Update {
/// Only check for updates without installing
#[clap(long)]
check_only: bool,
},
/// Remove The Dora CLI from the system
Uninstall {
/// Force uninstallation without confirmation
#[clap(long)]
force: bool,
},
command: command::Command,
}

#[derive(Debug, clap::Args)]
@@ -298,503 +54,13 @@ enum Lang {
}

pub fn lib_main(args: Args) {
if let Err(err) = run_cli(args) {
if let Err(err) = args.command.execute() {
eprintln!("\n\n{}", "[ERROR]".bold().red());
eprintln!("{err:?}");
std::process::exit(1);
}
}

fn run_cli(args: Args) -> eyre::Result<()> {
tracing_log::LogTracer::init()?;

#[cfg(feature = "tracing")]
match &args.command {
Command::Daemon {
quiet, machine_id, ..
} => {
let name = "dora-daemon";
let filename = machine_id
.as_ref()
.map(|id| format!("{name}-{id}"))
.unwrap_or(name.to_string());

let mut builder = TracingBuilder::new(name);
if !quiet {
builder = builder.with_stdout("info,zenoh=warn");
}
builder = builder.with_file(filename, LevelFilter::INFO)?;
builder
.build()
.wrap_err("failed to set up tracing subscriber")?;
}
Command::Runtime => {
// Do not set the runtime in the cli.
}
Command::Coordinator { quiet, .. } => {
let name = "dora-coordinator";
let mut builder = TracingBuilder::new(name);
if !quiet {
builder = builder.with_stdout("info");
}
builder = builder.with_file(name, LevelFilter::INFO)?;
builder
.build()
.wrap_err("failed to set up tracing subscriber")?;
}
Command::Run { .. } | Command::Build { .. } => {
let log_level = std::env::var("RUST_LOG").ok().unwrap_or("info".to_string());
TracingBuilder::new("run")
.with_stdout(log_level)
.build()
.wrap_err("failed to set up tracing subscriber")?;
}
_ => {
TracingBuilder::new("dora-cli")
.with_stdout("warn")
.build()
.wrap_err("failed to set up tracing subscriber")?;
}
};

match args.command {
Command::Check {
dataflow,
coordinator_addr,
coordinator_port,
} => match dataflow {
Some(dataflow) => {
let working_dir = dataflow
.canonicalize()
.context("failed to canonicalize dataflow path")?
.parent()
.ok_or_else(|| eyre::eyre!("dataflow path has no parent dir"))?
.to_owned();
Descriptor::blocking_read(&dataflow)?.check(&working_dir)?;
command::check::check_environment((coordinator_addr, coordinator_port).into())?
}
None => command::check::check_environment((coordinator_addr, coordinator_port).into())?,
},
Command::Graph {
dataflow,
mermaid,
open,
} => {
graph::create(dataflow, mermaid, open)?;
}
Command::Build {
dataflow,
coordinator_addr,
coordinator_port,
uv,
local,
} => command::build(dataflow, coordinator_addr, coordinator_port, uv, local)?,
Command::New {
args,
internal_create_with_path_dependencies,
} => template::create(args, internal_create_with_path_dependencies)?,
Command::Run { dataflow, uv } => command::run(dataflow, uv)?,
Command::Up { config } => {
command::up::up(config.as_deref())?;
}
Command::Logs {
dataflow,
node,
coordinator_addr,
coordinator_port,
} => {
let mut session = connect_to_coordinator((coordinator_addr, coordinator_port).into())
.wrap_err("failed to connect to dora coordinator")?;
let list = query_running_dataflows(&mut *session)
.wrap_err("failed to query running dataflows")?;
if let Some(dataflow) = dataflow {
let uuid = Uuid::parse_str(&dataflow).ok();
let name = if uuid.is_some() { None } else { Some(dataflow) };
command::logs(&mut *session, uuid, name, node)?
} else {
let active: Vec<dora_message::coordinator_to_cli::DataflowIdAndName> =
list.get_active();
let uuid = match &active[..] {
[] => bail!("No dataflows are running"),
[uuid] => uuid.clone(),
_ => inquire::Select::new("Choose dataflow to show logs:", active).prompt()?,
};
command::logs(&mut *session, Some(uuid.uuid), None, node)?
}
}
Command::Start {
dataflow,
name,
coordinator_addr,
coordinator_port,
attach,
detach,
hot_reload,
uv,
} => {
let coordinator_socket = (coordinator_addr, coordinator_port).into();
command::start(
dataflow,
name,
coordinator_socket,
attach,
detach,
hot_reload,
uv,
)?
}
Command::List {
coordinator_addr,
coordinator_port,
} => match connect_to_coordinator((coordinator_addr, coordinator_port).into()) {
Ok(mut session) => list(&mut *session)?,
Err(_) => {
bail!("No dora coordinator seems to be running.");
}
},
Command::Stop {
uuid,
name,
grace_duration,
coordinator_addr,
coordinator_port,
} => {
let mut session = connect_to_coordinator((coordinator_addr, coordinator_port).into())
.wrap_err("could not connect to dora coordinator")?;
match (uuid, name) {
(Some(uuid), _) => stop_dataflow(uuid, grace_duration, &mut *session)?,
(None, Some(name)) => stop_dataflow_by_name(name, grace_duration, &mut *session)?,
(None, None) => stop_dataflow_interactive(grace_duration, &mut *session)?,
}
}
Command::Destroy {
config,
coordinator_addr,
coordinator_port,
} => command::up::destroy(
config.as_deref(),
(coordinator_addr, coordinator_port).into(),
)?,
Command::Coordinator {
interface,
port,
control_interface,
control_port,
quiet,
} => {
let rt = Builder::new_multi_thread()
.enable_all()
.build()
.context("tokio runtime failed")?;
rt.block_on(async {
let bind = SocketAddr::new(interface, port);
let bind_control = SocketAddr::new(control_interface, control_port);
let (port, task) =
dora_coordinator::start(bind, bind_control, futures::stream::empty::<Event>())
.await?;
if !quiet {
println!("Listening for incoming daemon connection on {port}");
}
task.await
})
.context("failed to run dora-coordinator")?
}
Command::Daemon {
coordinator_addr,
coordinator_port,
local_listen_port,
machine_id,
run_dataflow,
quiet: _,
} => {
let rt = Builder::new_multi_thread()
.enable_all()
.build()
.context("tokio runtime failed")?;
rt.block_on(async {
match run_dataflow {
Some(dataflow_path) => {
tracing::info!("Starting dataflow `{}`", dataflow_path.display());
if coordinator_addr != LOCALHOST {
tracing::info!(
"Not using coordinator addr {} as `run_dataflow` is for local dataflow only. Please use the `start` command for remote coordinator",
coordinator_addr
);
}
let dataflow_session =
DataflowSession::read_session(&dataflow_path).context("failed to read DataflowSession")?;

let result = Daemon::run_dataflow(&dataflow_path,
dataflow_session.build_id, dataflow_session.local_build, dataflow_session.session_id, false,
LogDestination::Tracing,
).await?;
handle_dataflow_result(result, None)
}
None => {
Daemon::run(SocketAddr::new(coordinator_addr, coordinator_port), machine_id, local_listen_port).await
}
}
})
.context("failed to run dora-daemon")?
}
Command::Runtime => dora_runtime::main().context("Failed to run dora-runtime")?,
Command::Self_ { command } => match command {
SelfSubCommand::Update { check_only } => {
println!("Checking for updates...");

#[cfg(target_os = "linux")]
let bin_path_in_archive = format!("dora-cli-{}/dora", env!("TARGET"));
#[cfg(target_os = "macos")]
let bin_path_in_archive = format!("dora-cli-{}/dora", env!("TARGET"));
#[cfg(target_os = "windows")]
let bin_path_in_archive = String::from("dora.exe");

let status = self_update::backends::github::Update::configure()
.repo_owner("dora-rs")
.repo_name("dora")
.bin_path_in_archive(&bin_path_in_archive)
.bin_name("dora")
.show_download_progress(true)
.current_version(env!("CARGO_PKG_VERSION"))
.build()?;

if check_only {
// Only check if an update is available
match status.get_latest_release() {
Ok(release) => {
let current_version = self_update::cargo_crate_version!();
if current_version != release.version {
println!(
"An update is available: {}. Run 'dora self update' to update",
release.version
);
} else {
println!(
"Dora CLI is already at the latest version: {}",
current_version
);
}
}
Err(e) => println!("Failed to check for updates: {}", e),
}
} else {
// Perform the actual update
match status.update() {
Ok(update_status) => match update_status {
self_update::Status::UpToDate(version) => {
println!("Dora CLI is already at the latest version: {}", version);
}
self_update::Status::Updated(version) => {
println!("Successfully updated Dora CLI to version: {}", version);
}
},
Err(e) => println!("Failed to update: {}", e),
}
}
}
SelfSubCommand::Uninstall { force } => {
if !force {
let confirmed =
inquire::Confirm::new("Are you sure you want to uninstall Dora CLI?")
.with_default(false)
.prompt()
.wrap_err("Uninstallation cancelled")?;

if !confirmed {
println!("Uninstallation cancelled");
return Ok(());
}
}

println!("Uninstalling Dora CLI...");
#[cfg(feature = "python")]
{
println!("Detected Python installation...");

// Try uv pip uninstall first
let uv_status = std::process::Command::new("uv")
.args(["pip", "uninstall", "dora-rs-cli"])
.status();

if let Ok(status) = uv_status {
if status.success() {
println!("Dora CLI has been successfully uninstalled via uv pip.");
return Ok(());
}
}

// Fall back to regular pip uninstall
println!("Trying with pip...");
let status = std::process::Command::new("pip")
.args(["uninstall", "-y", "dora-rs-cli"])
.status()
.wrap_err("Failed to run pip uninstall")?;

if status.success() {
println!("Dora CLI has been successfully uninstalled via pip.");
} else {
bail!("Failed to uninstall Dora CLI via pip.");
}
}
#[cfg(not(feature = "python"))]
{
match self_replace::self_delete() {
Ok(_) => {
println!("Dora CLI has been successfully uninstalled.");
}
Err(e) => {
bail!("Failed to uninstall Dora CLI: {}", e);
}
}
}
}
},
};

Ok(())
}

fn stop_dataflow_interactive(
grace_duration: Option<Duration>,
session: &mut TcpRequestReplyConnection,
) -> eyre::Result<()> {
let list = query_running_dataflows(session).wrap_err("failed to query running dataflows")?;
let active = list.get_active();
if active.is_empty() {
eprintln!("No dataflows are running");
} else {
let selection = inquire::Select::new("Choose dataflow to stop:", active).prompt()?;
stop_dataflow(selection.uuid, grace_duration, session)?;
}

Ok(())
}

fn stop_dataflow(
uuid: Uuid,
grace_duration: Option<Duration>,
session: &mut TcpRequestReplyConnection,
) -> Result<(), eyre::ErrReport> {
let reply_raw = session
.request(
&serde_json::to_vec(&ControlRequest::Stop {
dataflow_uuid: uuid,
grace_duration,
})
.unwrap(),
)
.wrap_err("failed to send dataflow stop message")?;
let result: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
match result {
ControlRequestReply::DataflowStopped { uuid, result } => {
handle_dataflow_result(result, Some(uuid))
}
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected stop dataflow reply: {other:?}"),
}
}

fn handle_dataflow_result(result: DataflowResult, uuid: Option<Uuid>) -> Result<(), eyre::Error> {
if result.is_ok() {
Ok(())
} else {
Err(match uuid {
Some(uuid) => {
eyre::eyre!("Dataflow {uuid} failed:\n{}", FormatDataflowError(&result))
}
None => {
eyre::eyre!("Dataflow failed:\n{}", FormatDataflowError(&result))
}
})
}
}

fn stop_dataflow_by_name(
name: String,
grace_duration: Option<Duration>,
session: &mut TcpRequestReplyConnection,
) -> Result<(), eyre::ErrReport> {
let reply_raw = session
.request(
&serde_json::to_vec(&ControlRequest::StopByName {
name,
grace_duration,
})
.unwrap(),
)
.wrap_err("failed to send dataflow stop_by_name message")?;
let result: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
match result {
ControlRequestReply::DataflowStopped { uuid, result } => {
handle_dataflow_result(result, Some(uuid))
}
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected stop dataflow reply: {other:?}"),
}
}

fn list(session: &mut TcpRequestReplyConnection) -> Result<(), eyre::ErrReport> {
let list = query_running_dataflows(session)?;

let mut tw = TabWriter::new(vec![]);
tw.write_all(b"UUID\tName\tStatus\n")?;
for entry in list.0 {
let uuid = entry.id.uuid;
let name = entry.id.name.unwrap_or_default();
let status = match entry.status {
DataflowStatus::Running => "Running",
DataflowStatus::Finished => "Succeeded",
DataflowStatus::Failed => "Failed",
};
tw.write_all(format!("{uuid}\t{name}\t{status}\n").as_bytes())?;
}
tw.flush()?;
let formatted = String::from_utf8(tw.into_inner()?)?;

println!("{formatted}");

Ok(())
}

fn query_running_dataflows(session: &mut TcpRequestReplyConnection) -> eyre::Result<DataflowList> {
let reply_raw = session
.request(&serde_json::to_vec(&ControlRequest::List).unwrap())
.wrap_err("failed to send list message")?;
let reply: ControlRequestReply =
serde_json::from_slice(&reply_raw).wrap_err("failed to parse reply")?;
let ids = match reply {
ControlRequestReply::DataflowList(list) => list,
ControlRequestReply::Error(err) => bail!("{err}"),
other => bail!("unexpected list dataflow reply: {other:?}"),
};

Ok(ids)
}

fn connect_to_coordinator(
coordinator_addr: SocketAddr,
) -> std::io::Result<Box<TcpRequestReplyConnection>> {
TcpLayer::new().connect(coordinator_addr)
}

fn resolve_dataflow(dataflow: String) -> eyre::Result<PathBuf> {
let dataflow = if source_is_url(&dataflow) {
// try to download the shared library
let target_path = current_dir().context("Could not access the current dir")?;
let rt = Builder::new_current_thread()
.enable_all()
.build()
.context("tokio runtime failed")?;
rt.block_on(async { download_file(&dataflow, &target_path).await })
.wrap_err("failed to download dataflow yaml file")?
} else {
PathBuf::from(dataflow)
};
Ok(dataflow)
}

#[cfg(feature = "python")]
use clap::Parser;
#[cfg(feature = "python")]
@@ -804,8 +70,6 @@ use pyo3::{
wrap_pyfunction, Bound, PyResult, Python,
};

use crate::session::DataflowSession;

#[cfg(feature = "python")]
#[pyfunction]
fn py_main(_py: Python) -> PyResult<()> {


Loading…
Cancel
Save