Skip to main content

faucet_cli/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! # faucet-cli
4//!
5//! A config-driven runner for [`faucet-stream`](https://docs.rs/faucet-stream)
6//! pipelines.  Define a source, optional transforms, a sink, and (optionally)
7//! a state store in a YAML or JSON file, then run it with the `faucet` binary —
8//! no Rust code required.
9//!
10//! The library half of this crate exposes the same building blocks the binary
11//! uses (config parsing, env interpolation, the connector registry) so that
12//! integrations and tests can reuse them.
13
14pub mod auth_catalog;
15pub mod backfill;
16#[cfg(feature = "catalog")]
17pub mod catalog;
18pub mod cli;
19pub mod commands;
20pub mod compose;
21pub mod config;
22pub mod conformance;
23pub mod dlq_replay;
24pub mod env_config;
25pub mod env_loader;
26pub mod error;
27pub mod executor;
28pub mod expand;
29pub mod init_template;
30pub mod interpolate;
31#[cfg(feature = "lineage")]
32pub mod lineage_glue;
33pub mod merge;
34#[cfg(feature = "notify")]
35pub mod notify;
36pub mod obs;
37pub mod pipeline_test;
38pub mod registry;
39pub mod registry_index;
40pub mod replication;
41pub mod scaffold;
42#[cfg(feature = "schedule")]
43pub mod schedule;
44pub mod schema_compose;
45pub mod secrets;
46#[cfg(feature = "serve")]
47pub mod serve;
48pub mod sla;
49pub mod state;
50pub mod transforms;
51#[cfg(feature = "cli-tui")]
52pub mod tui;
53
54pub use error::{CliError, CliResult};
55
56use crate::cli::{Cli, Command};
57use crate::registry::PluginRegistry;
58
59/// Entry point for a custom `faucet` binary that bundles third-party
60/// connectors.
61///
62/// A custom-CLI author writes a tiny `main.rs` that builds a [`PluginRegistry`]
63/// with their connectors registered on top of the built-ins and hands it here:
64///
65/// ```no_run
66/// use faucet_cli::registry::PluginRegistry;
67/// fn main() -> std::process::ExitCode {
68///     faucet_cli::run_main(PluginRegistry::with_builtins())
69/// }
70/// ```
71///
72/// This installs `registry` as the process-global connector registry (so every
73/// command — `run`, `validate`, `schema`, `list`, `preview`, `serve`, … — sees
74/// the custom connectors), parses argv, installs the tracing subscriber, and
75/// dispatches. The return value is the process exit code (the failed-probe /
76/// failed-case / failed-unit count for `doctor` / `test` / `backfill`, `1` for
77/// any other error, `0` on success). The stock `faucet` binary calls this with
78/// `PluginRegistry::with_builtins()`.
79pub fn run_main(registry: PluginRegistry) -> std::process::ExitCode {
80    use clap::Parser;
81    use std::process::ExitCode;
82
83    if let Err(err) = registry.install() {
84        commands::report(&err);
85        return ExitCode::from(1);
86    }
87    let cli = Cli::parse();
88    #[cfg(feature = "serve")]
89    let is_serve = matches!(cli.command, Command::Serve(_));
90    #[cfg(not(feature = "serve"))]
91    let is_serve = false;
92    // A `--tui` run on a real terminal routes logs into the TUI's in-memory
93    // ring (the stdout subscriber would corrupt the alternate screen).
94    #[cfg(feature = "cli-tui")]
95    let is_tui = matches!(&cli.command, Command::Run(a) if tui::is_tui_session(a.tui));
96    #[cfg(not(feature = "cli-tui"))]
97    let is_tui = false;
98    // `serve` installs its own (redacting, run-scoped) subscriber; every other
99    // command uses the plain redacting fmt subscriber.
100    if !is_serve && !is_tui {
101        install_tracing(&cli.log_level);
102    }
103    #[cfg(feature = "cli-tui")]
104    if is_tui {
105        tui::install_tui_tracing(&cli.log_level);
106    }
107
108    let runtime = match tokio::runtime::Builder::new_multi_thread()
109        .enable_all()
110        .build()
111    {
112        Ok(rt) => rt,
113        Err(e) => {
114            eprintln!("error: failed to start async runtime: {e}");
115            return ExitCode::from(1);
116        }
117    };
118
119    runtime.block_on(async move {
120        match run_command(cli).await {
121            Ok(()) => ExitCode::SUCCESS,
122            // `doctor` / `test` / `backfill` already printed their report; the
123            // exit code is the failed count (clamped to 255).
124            Err(CliError::DoctorFailed { failed }) => ExitCode::from(failed.min(255) as u8),
125            Err(CliError::TestsFailed { failed }) => ExitCode::from(failed.min(255) as u8),
126            Err(CliError::BackfillFailed { failed }) => ExitCode::from(failed.min(255) as u8),
127            Err(err) => {
128                commands::report(&err);
129                ExitCode::from(1)
130            }
131        }
132    })
133}
134
135/// Dispatch a parsed [`Cli`] to the matching command. Public so custom hosts and
136/// integration tests can drive the exact same code path as [`run_main`] with a
137/// programmatically-built `Cli` (and a registry installed via
138/// [`PluginRegistry::install`]).
139pub async fn run_command(cli: Cli) -> CliResult<()> {
140    #[cfg(feature = "serve")]
141    let serve_log_level = cli.log_level.clone();
142    match cli.command {
143        Command::Run(args) => commands::run::run(args).await,
144        Command::Backfill(args) => commands::backfill::run(args).await,
145        Command::Replicate(args) => commands::replicate::run(args).await,
146        Command::Discover(args) => commands::discover::run(args).await,
147        Command::Validate(args) => commands::validate::run(args).await,
148        Command::Schema(args) => commands::schema::run(args).await,
149        Command::List(args) => commands::list::run(args).await,
150        Command::Search(args) => commands::search::run(args).await,
151        Command::Conformance(args) => commands::conformance::run(args).await,
152        Command::Install(args) => commands::install::run(args).await,
153        Command::Preview(args) => commands::preview::run(args).await,
154        Command::Plan(args) => commands::plan::run(args).await,
155        #[cfg(feature = "cli-dev")]
156        Command::Dev(args) => commands::dev::run(args).await,
157        Command::Init(args) => commands::init::run(args).await,
158        Command::New(args) => commands::new::run(args).await,
159        Command::Doctor(args) => commands::doctor::run(args).await,
160        Command::Test(args) => commands::test::run(args).await,
161        Command::Dlq(args) => commands::dlq::run(args).await,
162        #[cfg(feature = "contract")]
163        Command::Contract(args) => commands::contract::run(args).await,
164        #[cfg(feature = "masking")]
165        Command::Masking(args) => commands::masking::run(args).await,
166        #[cfg(feature = "schedule")]
167        Command::Schedule(args) => commands::schedule::run(args).await,
168        #[cfg(feature = "serve")]
169        Command::Serve(args) => commands::serve::run(args, serve_log_level).await,
170        #[cfg(feature = "notify")]
171        Command::Notify(args) => commands::notify::run(args).await,
172        #[cfg(feature = "catalog")]
173        Command::Catalog(args) => commands::catalog::run(args).await,
174    }
175}
176
177#[cfg(feature = "observability")]
178fn install_tracing(level: &str) {
179    use crate::secrets::registry::RedactingMakeWriter;
180    use tracing_subscriber::EnvFilter;
181    let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
182    let _ = tracing_subscriber::fmt()
183        .with_env_filter(filter)
184        .with_writer(RedactingMakeWriter)
185        .try_init();
186}
187
188/// Stub used when the `observability` feature is disabled. Logging falls back to
189/// whatever the host environment has wired (or nothing).
190#[cfg(not(feature = "observability"))]
191fn install_tracing(_level: &str) {}
192
193/// Convenience entry point for integration tests and custom hosts: parse a
194/// YAML config string, expand the matrix, and run all rows.
195///
196/// This skips the `install_observability` call that [`commands::run::run`]
197/// performs — callers can wire their own `metrics` recorder / tracing
198/// subscriber before calling this function (or not at all).
199pub async fn run_from_yaml_str(yaml: &str) -> CliResult<executor::RunSummary> {
200    // Parse first, then resolve ${env}/${file}/${secret} INTO the parsed tree
201    // (post-parse) so a resolved value can never alter the document's structure
202    // (F43) — mirroring the binary's `from_path` path.
203    let mut value: serde_json::Value =
204        serde_yaml::from_str(yaml).map_err(|e| CliError::ParseConfig {
205            path: std::path::PathBuf::from("<yaml-string>"),
206            message: e.to_string(),
207        })?;
208    interpolate::interpolate_value(&mut value)?;
209    let interpolated = serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
210        path: std::path::PathBuf::from("<yaml-string>"),
211        message: e.to_string(),
212    })?;
213    let mut cfg: config::PipelineConfig =
214        serde_yaml::from_str(&interpolated).map_err(|e| CliError::ParseConfig {
215            path: std::path::PathBuf::from("<yaml-string>"),
216            message: e.to_string(),
217        })?;
218    if cfg.version != 1 {
219        return Err(CliError::ParseConfig {
220            path: std::path::PathBuf::from("<yaml-string>"),
221            message: format!(
222                "unsupported pipeline version {}, only version 1 is recognised",
223                cfg.version
224            ),
225        });
226    }
227    crate::secrets::resolve_secrets(&mut cfg).await?;
228    let pipeline_name = cfg.name.clone().unwrap_or_else(|| "unnamed".to_string());
229    let auth = auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
230    let resilience = match &cfg.resilience {
231        Some(spec) => Some(spec.to_policy()?),
232        None => None,
233    };
234    #[cfg(feature = "catalog")]
235    let catalog = match cfg.catalog.as_ref() {
236        Some(spec) => Some(catalog::connect_from_spec(spec).await?),
237        None => None,
238    };
239    let nodes = expand::expand(&cfg)?;
240    executor::run_expanded(
241        nodes,
242        executor::ExecuteOptions {
243            pipeline_name,
244            execution: cfg.execution.clone(),
245            dry_run: false,
246            limit: None,
247            state_path_override: None,
248            shard: None,
249            auth,
250            clock: chrono::Utc::now().fixed_offset(),
251            cancel: None,
252            resilience,
253            sla: cfg.sla.clone(),
254            #[cfg(feature = "lineage")]
255            lineage: None,
256            #[cfg(feature = "lineage")]
257            lineage_cfg: None,
258            #[cfg(feature = "notify")]
259            notifier: None,
260            #[cfg(feature = "catalog")]
261            catalog,
262        },
263    )
264    .await
265}