diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..bd686d34 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = [ + "crates/client", + "crates/example", +] +resolver = "3" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index f5143f0f..9d9a42e8 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "process-compose-client" -version = "1.64.1" +version = "1.73.0" description = "Client for Process Compose via OpenAPI and/or project file" license = "Apache-2.0" edition = "2021" @@ -19,16 +19,25 @@ crate-type = ["rlib"] serde_json = { version = "^1.0", default-features = false } openapiv3 = { version = "^2", default-features = false } -progenitor = { version = "^0.10", default-features = false, optional = true } +progenitor = { version = "^0.11", default-features = false, optional = true } prettyplease = { version = "^0.2.24", optional = true } syn = { version = "^2.0.80", optional = true } -schemars = { version = "^0.8.*" } +schemars = { version = "^0.8.*" } # update to 1.0 blocked by typify typify = { version = "^0.4", default-features = false, optional = true } +process-wrap = { version = "9.0", default-features = false, optional = true, features = ["tokio1", "kill-on-drop", ]} + + +clap = {version = "4.5", default-features = false, features = ["derive", "std", "env", "help", "usage", "error-context", "suggestions"], optional = true} +bon = { version = "3.7", default-features = false, optional = true} +struct_field_names = { version = "0.2", default-features = false, optional = true} + [features] -default = ["progenitor", "typify"] +default = ["progenitor", "typify", "cli"] typify = ["dep:typify", "dep:prettyplease"] progenitor = ["dep:progenitor", "dep:prettyplease", "dep:syn"] + +cli = ["dep:process-wrap", "dep:bon", "dep:clap", "dep:struct_field_names"] diff --git a/crates/client/README.md b/crates/client/README.md index cc532f77..48008e41 100644 --- a/crates/client/README.md +++ b/crates/client/README.md @@ -1,5 +1,8 @@ -Build time utility to get Rust Process Compose interface: +Compile time and runtime utility to get Rust Process Compose interface. + +# Features - as raw OpenAPI schema or with `progenitor` client. - as raw project config JSON schema or with `typify` builder. +- as Rust "native" crate behind `cli` providing process-wrap handle produced from command line args builder \ No newline at end of file diff --git a/crates/client/src/cli/cmd/flags.rs b/crates/client/src/cli/cmd/flags.rs new file mode 100644 index 00000000..7ebdde13 --- /dev/null +++ b/crates/client/src/cli/cmd/flags.rs @@ -0,0 +1,42 @@ +//! Keep in sync with `flags.go`. +use std::time::Duration; + +/// Default refresh interval +pub const DEFAULT_REFRESH_RATE: Duration = Duration::from_secs(1); + +/// Default log level +pub const DEFAULT_LOG_LEVEL: &str = "info"; + +/// Default port number +pub const DEFAULT_PORT_NUM: u16 = 8080; + +/// Default bind address (host) +pub const DEFAULT_ADDRESS: &str = "localhost"; + +/// Default log length (number of lines kept in memory) +pub const DEFAULT_LOG_LENGTH: usize = 1000; + +/// Default sort column name +pub const DEFAULT_SORT_COLUMN: &str = "NAME"; + +/// Default theme name +pub const DEFAULT_THEME_NAME: &str = "Default"; + +/// Represents absence of a namespace selection +pub const NO_NAMESPACE: &str = ""; + +pub mod env { + pub const PORT_NUM: &str = "PC_PORT_NUM"; + pub const DISABLE_TUI: &str = "PC_DISABLE_TUI"; + pub const CONFIG_FILES: &str = "PC_CONFIG_FILES"; + pub const SHORTCUTS_FILES: &str = "PC_SHORTCUTS_FILES"; + pub const NO_SERVER: &str = "PC_NO_SERVER"; + pub const SOCKET_PATH: &str = "PC_SOCKET_PATH"; + pub const READ_ONLY: &str = "PC_READ_ONLY"; + pub const DISABLE_DOTENV: &str = "PC_DISABLE_DOTENV"; + pub const TUI_FULL_SCREEN: &str = "PC_TUI_FULL_SCREEN"; + pub const HIDE_DISABLED_PROC: &str = "PC_HIDE_DISABLED_PROC"; + pub const ORDERED_SHUTDOWN: &str = "PC_ORDERED_SHUTDOWN"; + pub const RECURSIVE_METRICS: &str = "PC_RECURSIVE_METRICS"; + pub const LOG_FILE: &str = "PC_LOG_FILE"; +} diff --git a/crates/client/src/cli/cmd/mod.rs b/crates/client/src/cli/cmd/mod.rs new file mode 100644 index 00000000..ee390ff6 --- /dev/null +++ b/crates/client/src/cli/cmd/mod.rs @@ -0,0 +1,14 @@ +// Define macro before submodules so it's in scope for them +macro_rules! arg { + ($ty:ty, $field:ident) => {{ + <$ty as clap::CommandFactory>::command() + .get_arguments() + .find(|a| a.get_id() == <$ty>::FIELD_NAMES.$field) + .and_then(|a| a.get_long()) + .expect("long argument name not found") + }}; +} + +pub mod flags; +pub mod parent; +pub mod up; diff --git a/crates/client/src/cli/cmd/parent.rs b/crates/client/src/cli/cmd/parent.rs new file mode 100644 index 00000000..fb642fcf --- /dev/null +++ b/crates/client/src/cli/cmd/parent.rs @@ -0,0 +1,116 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +use crate::cli::cmd::flags::env; +use crate::cli::cmd::flags::DEFAULT_PORT_NUM; +use crate::cli::cmd::up::ProcessComposeFlagsUp; +/// Process Compose startup flags +#[derive(Debug, Clone, Parser, struct_field_names::StructFieldNames, bon::Builder)] +pub struct ProcessComposeFlags { + /// Specify the log file path (env: PC_LOG_FILE) + /// Default "/tmp/process-compose-.log" + #[arg(long = "log-file", env = "PC_LOG_FILE")] + pub log_file: Option, + + /// Disable HTTP server (env: PC_NO_SERVER) + #[arg(long = "no-server", env = env::NO_SERVER)] + #[builder(default = false)] + pub no_server: bool, + + /// Shut down processes in reverse dependency order (env: PC_ORDERED_SHUTDOWN) + #[arg(long = "ordered-shutdown", env = env::ORDERED_SHUTDOWN)] + #[builder(default = false)] + pub ordered_shutdown: bool, + + /// Port number (env: PC_PORT_NUM) + #[arg(long = "port", env = env::PORT_NUM, default_value_t = DEFAULT_PORT_NUM)] + #[builder(default = DEFAULT_PORT_NUM)] + pub port: u16, + + /// Enable read-only mode (env: PC_READ_ONLY) + #[arg(long = "read-only", env = env::READ_ONLY)] + #[builder(default = false)] + pub read_only: bool, + + /// Path to unix socket (env: PC_SOCKET_PATH) + /// Default "/tmp/process-compose-.sock" + #[arg(long = "unix-socket", env = env::SOCKET_PATH)] + pub unix_socket: Option, + + /// Use unix domain sockets instead of TCP + #[arg(long = "use-uds", default_value_t = false)] + #[builder(default = false)] + pub use_uds: bool, + + #[command(subcommand)] + pub subcommand: Option, +} + +#[derive(Debug, Clone, Subcommand, struct_field_names::EnumVariantNames)] +pub enum ProcessComposeCommand { + /// Run process compose project + Up(ProcessComposeFlagsUp), +} + +impl ProcessComposeCommand { + pub fn up(value: ProcessComposeFlagsUp) -> Self { + Self::Up(value) + } +} + +impl TryInto> for ProcessComposeCommand { + type Error = String; + + fn try_into(self) -> Result, Self::Error> { + let mut args = Vec::new(); + match self { + ProcessComposeCommand::Up(up) => { + args.push("up".to_string()); + let up_args: Vec = up.try_into()?; + args.extend(up_args); + } + } + Ok(args) + } +} + +impl TryInto> for ProcessComposeFlags { + type Error = String; + + fn try_into(self) -> Result, Self::Error> { + let mut args = Vec::new(); + + if let Some(path) = self.log_file { + args.push(format!("--{}", arg!(ProcessComposeFlags, log_file))); + args.push(path.to_string_lossy().to_string()); + } + if self.no_server { + args.push(format!("--{}", arg!(ProcessComposeFlags, no_server))); + } + if self.ordered_shutdown { + args.push(format!("--{}", arg!(ProcessComposeFlags, ordered_shutdown))); + } + + // Always include port to be explicit + args.push(format!("--{}", arg!(ProcessComposeFlags, port))); + args.push(self.port.to_string()); + + if self.read_only { + args.push(format!("--{}", arg!(ProcessComposeFlags, read_only))); + } + if let Some(sock) = self.unix_socket { + args.push(format!("--{}", arg!(ProcessComposeFlags, unix_socket))); + args.push(sock.to_string_lossy().to_string()); + } + if self.use_uds { + args.push(format!("--{}", arg!(ProcessComposeFlags, use_uds))); + } + + if let Some(sub) = self.subcommand { + let mut sub_args: Vec = sub.try_into()?; + args.append(&mut sub_args); + } + + Ok(args) + } +} diff --git a/crates/client/src/cli/cmd/up.rs b/crates/client/src/cli/cmd/up.rs new file mode 100644 index 00000000..57da3681 --- /dev/null +++ b/crates/client/src/cli/cmd/up.rs @@ -0,0 +1,214 @@ +use clap::Parser; +use std::num::NonZero; +use std::path::PathBuf; + +use crate::cli::cmd::flags::env; +use crate::cli::cmd::flags::{DEFAULT_REFRESH_RATE, DEFAULT_SORT_COLUMN, DEFAULT_THEME_NAME}; + +#[derive(Debug, Clone, Parser, struct_field_names::StructFieldNames, bon::Builder)] +pub struct ProcessComposeFlagsUp { + /// Path to config files to load (env: PC_CONFIG_FILES) + #[arg(long = "config", env = env::CONFIG_FILES)] + #[builder(default = Vec::new())] + pub config: Vec, + + /// Detach the TUI after successful startup (requires --detached-with-tui) + #[arg(long = "detach-on-success", default_value_t = false)] + #[builder(default = false)] + pub detach_on_success: bool, + + /// Run in detached mode + #[cfg(unix)] + #[arg(long = "detached", default_value_t = false)] + #[builder(default = false)] + pub detached: bool, + + /// Run in detached mode with TUI + #[cfg(unix)] + #[arg(long = "detached-with-tui", default_value_t = false)] + #[builder(default = false)] + pub detached_with_tui: bool, + + /// Disable .env file loading (env: PC_DISABLE_DOTENV=1) + #[arg(long = "disable-dotenv", env = env::DISABLE_DOTENV, default_value_t = false)] + #[builder(default = false)] + pub disable_dotenv: bool, + + /// Validate the config and exit + #[arg(long = "dry-run", default_value_t = false)] + #[builder(default = false)] + pub dry_run: bool, + + /// Path to env files to load (default .env) + #[arg(long = "env", default_value = ".env")] + #[builder(default = vec![PathBuf::from(".env")])] + pub env_files: Vec, + + /// Hide disabled processes (env: PC_HIDE_DISABLED_PROC) + #[arg(long = "hide-disabled", env = env::HIDE_DISABLED_PROC, default_value_t = false)] + #[builder(default = false)] + pub hide_disabled: bool, + + /// Keep the project running even after all processes exit + #[arg(long = "keep-project", default_value_t = false)] + #[builder(default = false)] + pub keep_project: bool, + + /// Truncate process logs buffer on startup + #[arg(long = "logs-truncate", default_value_t = false)] + #[builder(default = false)] + pub logs_truncate: bool, + + /// Run only specified namespaces (default: all) + #[arg(long = "namespace")] + #[builder(default = Vec::new())] + pub namespaces: Vec, + + /// Do not start dependent processes + #[arg(long = "no-deps", default_value_t = false)] + #[builder(default = false)] + pub no_deps: bool, + + /// Collect metrics recursively (env: PC_RECURSIVE_METRICS) + #[arg(long = "recursive-metrics", env = env::RECURSIVE_METRICS, default_value_t = false)] + #[builder(default = false)] + pub recursive_metrics: bool, + + /// TUI refresh interval in seconds + #[arg(long = "ref-rate", default_value_t = DEFAULT_REFRESH_RATE.as_secs().try_into().unwrap())] + #[builder(default = NonZero::new(DEFAULT_REFRESH_RATE.as_secs()).unwrap())] + pub refresh_rate: NonZero, + + /// Sort in reverse order + #[arg(long = "reverse", default_value_t = false)] + #[builder(default = false)] + pub reverse: bool, + + /// Paths to shortcut config files to load (env: PC_SHORTCUTS_FILES) + #[arg(long = "shortcuts", env = env::SHORTCUTS_FILES)] + #[builder(default = Vec::new())] + pub shortcuts: Vec, + + /// Slow(er) refresh interval for resource metrics (must be > --ref-rate) + #[arg(long = "slow-ref-rate")] + pub slow_refresh_rate: Option, + + /// Sort column name (default NAME) + #[arg(long = "sort", default_value = DEFAULT_SORT_COLUMN)] + #[builder(default = DEFAULT_SORT_COLUMN.to_string())] + pub sort: String, + + /// Select process compose theme (default Default) + #[arg(long = "theme", default_value = DEFAULT_THEME_NAME)] + #[builder(default = DEFAULT_THEME_NAME.to_string())] + pub theme: String, + + /// Enable / disable TUI (use --tui=false to disable) (env: PC_DISABLE_TUI) + #[arg(long = "tui", env = env::DISABLE_TUI, default_value_t = true)] + #[builder(default = true)] + pub tui: bool, +} + +impl TryInto> for ProcessComposeFlagsUp { + type Error = String; + + fn try_into(self) -> Result, Self::Error> { + let mut args = Vec::new(); + + for p in self.config { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, config))); + args.push(p.to_string_lossy().to_string()); + } + + if self.detach_on_success { + args.push(format!( + "--{}", + arg!(ProcessComposeFlagsUp, detach_on_success) + )); + } + + #[cfg(unix)] + if self.detached { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, detached))); + } + + #[cfg(unix)] + if self.detached_with_tui { + args.push(format!( + "--{}", + arg!(ProcessComposeFlagsUp, detached_with_tui) + )); + } + + if self.disable_dotenv { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, disable_dotenv))); + } + if self.dry_run { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, dry_run))); + } + + for p in self.env_files { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, env_files))); + args.push(p.to_string_lossy().to_string()); + } + + if self.hide_disabled { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, hide_disabled))); + } + if self.keep_project { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, keep_project))); + } + if self.logs_truncate { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, logs_truncate))); + } + for ns in self.namespaces { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, namespaces))); + args.push(ns); + } + if self.no_deps { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, no_deps))); + } + if self.recursive_metrics { + args.push(format!( + "--{}", + arg!(ProcessComposeFlagsUp, recursive_metrics) + )); + } + + // refresh rate + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, refresh_rate))); + args.push(self.refresh_rate.get().to_string()); + + if self.reverse { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, reverse))); + } + + for p in self.shortcuts { + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, shortcuts))); + args.push(p.to_string_lossy().to_string()); + } + if let Some(slow) = self.slow_refresh_rate { + args.push(format!( + "--{}", + arg!(ProcessComposeFlagsUp, slow_refresh_rate) + )); + args.push(slow); + } + + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, sort))); + args.push(self.sort); + + args.push(format!("--{}", arg!(ProcessComposeFlagsUp, theme))); + args.push(self.theme); + + if !self.tui { + args.push(format!( + "--{}={}", + arg!(ProcessComposeFlagsUp, tui), + self.tui + )); + } + + Ok(args) + } +} diff --git a/crates/client/src/cli/exec.rs b/crates/client/src/cli/exec.rs new file mode 100644 index 00000000..76017728 --- /dev/null +++ b/crates/client/src/cli/exec.rs @@ -0,0 +1,27 @@ +//! Execute process compose process. +use process_wrap::tokio::*; + +/// Environment variable for the path to the process compose binary, +/// if defined, used instead of default OS and process search strategy. +pub const ENV_PC_BIN: &str = "PC_BIN"; + +/// Comand line fully controlled by +/// - OS binary path search or `PC_BIN` +/// - command line arguments generated from `ProcessComposeFlags` +/// +/// Returns a `CommandWrap` that can be used to interact with the spawned process, including adding wrappers. +pub fn process_compose( + command: super::cmd::parent::ProcessComposeFlags, +) -> Result { + let args: Vec = command.try_into()?; + let mut command = "process-compose".to_string(); + if let Some(pc_bin) = std::env::var_os(ENV_PC_BIN) { + command = pc_bin.to_string_lossy().to_string(); + } + + let command = CommandWrap::with_new(command, |command| { + command.args(&args); + }); + + Ok(command) +} diff --git a/crates/client/src/cli/mod.rs b/crates/client/src/cli/mod.rs new file mode 100644 index 00000000..2f3a0325 --- /dev/null +++ b/crates/client/src/cli/mod.rs @@ -0,0 +1,2 @@ +pub mod cmd; +pub mod exec; diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index d38dbc51..8ed4fca2 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1,7 +1,11 @@ //! provides generator to use in build.rs, with default `progenitor` provider + use openapiv3::OpenAPI; use std::sync::OnceLock; +#[cfg(feature = "cli")] +pub mod cli; + /// Raw OpenAPI spec string pub const OPENAPI_JSON_STRING: &str = include_str!("../../../src/docs/swagger.json"); diff --git a/crates/example/Cargo.toml b/crates/example/Cargo.toml index e6c11110..c176d311 100644 --- a/crates/example/Cargo.toml +++ b/crates/example/Cargo.toml @@ -13,7 +13,11 @@ serde = { version = "1.0", default-features = false, features = ["derive"]} tokio = { version = "1.41", features = ["macros", "rt-multi-thread"] } serde_json = { version = "1.0", default-features = false } -progenitor-client = { version = "0.10", default-features = false, optional = false} +progenitor-client = { version = "0.11", default-features = false, optional = false} + +# Use the local client crate for CLI flags and exec +process-compose-client = { path = "../client", default-features = false, features = ["cli"] } +process-wrap = { version = "9.0", default-features = false, features = ["tokio1", ]} [build-dependencies] process-compose-client = { path = "../client", default-features = false, features = ["progenitor", "typify"] } diff --git a/crates/example/src/bin/cli.rs b/crates/example/src/bin/cli.rs new file mode 100644 index 00000000..c22f4f28 --- /dev/null +++ b/crates/example/src/bin/cli.rs @@ -0,0 +1,36 @@ +use process_compose_client::cli::cmd::parent::{ProcessComposeCommand, ProcessComposeFlags}; +use process_compose_client::cli::cmd::up::ProcessComposeFlagsUp; +use process_compose_client::cli::exec; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Locate the repository root by walking up until process-compose.yaml is found + let project_path = { + let mut dir = std::env::current_dir()?; + loop { + if dir.join("process-compose.yaml").is_file() { + break dir; + } + if !dir.pop() { + return Err("process-compose.yaml not found in any parent directory".into()); + } + } + }; + let path = project_path.join("process-compose.yaml"); + let override_path = project_path.join("process-compose.override.yaml"); + let up = ProcessComposeFlagsUp::builder() + .config(vec![path.clone(), override_path.clone()]) + .tui(true) + .build(); + let flags = ProcessComposeFlags::builder() + .subcommand(ProcessComposeCommand::up(up)) + .build(); + + let _child = exec::process_compose(flags) + .unwrap() + .spawn()? + .wait() + .await?; + + Ok(()) +} diff --git a/crates/example/src/main.rs b/crates/example/src/bin/gen.rs similarity index 93% rename from crates/example/src/main.rs rename to crates/example/src/bin/gen.rs index 4ebbe193..5a851493 100644 --- a/crates/example/src/main.rs +++ b/crates/example/src/bin/gen.rs @@ -1,4 +1,5 @@ // includes generated code +#![allow(renamed_and_removed_lints)] include!(concat!(env!("OUT_DIR"), "/client.rs")); include!(concat!(env!("OUT_DIR"), "/config.rs"));