Skip to main content

faucet_cli/commands/
replicate.rs

1//! `faucet replicate` — load a config with a `replication:` block, validate it,
2//! and run the two-phase snapshot→CDC orchestration.
3
4use crate::cli::ReplicateArgs;
5use crate::config::PipelineConfig;
6use crate::error::{CliError, CliResult};
7use crate::replication::compiled::CompiledReplication;
8use crate::replication::{ReplicationOptions, run_replication};
9
10/// Execute the `replicate` subcommand.
11pub async fn run(args: ReplicateArgs) -> CliResult<()> {
12    let cwd = std::env::current_dir()?;
13    let env_path =
14        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
15    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
16    let path = match args.config {
17        Some(p) => p,
18        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
19    };
20
21    let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
22    let spec = cfg.replication.as_ref().ok_or_else(|| {
23        CliError::Config(
24            "no `replication:` block in config — use `faucet run` for a one-shot run, or add a \
25             `replication:` block (see `faucet schema replication`)"
26                .into(),
27        )
28    })?;
29    // Install observability before compiling the replication spec so the
30    // tracing subscriber is live and `CompiledReplication::compile`'s
31    // non-upsert-sink warning is actually emitted (it would be lost otherwise).
32    crate::obs::install(&cfg)?;
33
34    let compiled = CompiledReplication::compile(spec, &cfg)?;
35
36    let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
37        path.file_stem()
38            .and_then(|s| s.to_str())
39            .unwrap_or("pipeline")
40            .to_owned()
41    });
42    let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
43    let resilience = match &cfg.resilience {
44        Some(spec) => Some(spec.to_policy()?),
45        None => None,
46    };
47    #[cfg(feature = "notify")]
48    let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
49    #[cfg(feature = "catalog")]
50    let catalog = match cfg.catalog.as_ref() {
51        Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
52        None => None,
53    };
54
55    run_replication(
56        &cfg,
57        &compiled,
58        ReplicationOptions {
59            pipeline_name,
60            execution: cfg.execution.clone(),
61            auth,
62            clock: chrono::Utc::now().fixed_offset(),
63            resilience,
64            sla: cfg.sla.clone(),
65            #[cfg(feature = "notify")]
66            notifier,
67            #[cfg(feature = "catalog")]
68            catalog,
69        },
70    )
71    .await?;
72
73    // Flush any buffered OTLP telemetry before exiting (no-op without `otel`).
74    faucet_core::shutdown_otel();
75
76    println!("replication finished");
77    Ok(())
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::io::Write;
84
85    /// Write `yaml` to a `faucet.yaml` inside a fresh temp dir and return the
86    /// path (the dir is leaked so the file outlives the call — fine for a test).
87    fn write_config(yaml: &str) -> std::path::PathBuf {
88        let dir = tempfile::tempdir().expect("tempdir");
89        let path = dir.path().join("repl.yaml");
90        let mut f = std::fs::File::create(&path).expect("create config");
91        f.write_all(yaml.as_bytes()).expect("write config");
92        f.flush().expect("flush");
93        // Keep the dir alive for the duration of the test process.
94        std::mem::forget(dir);
95        path
96    }
97
98    fn args(path: std::path::PathBuf) -> ReplicateArgs {
99        ReplicateArgs {
100            config: Some(path),
101            env_file: None,
102            no_env_file: true,
103            profile: None,
104        }
105    }
106
107    /// A valid pipeline config with NO `replication:` block must error out before
108    /// any orchestration runs (no Docker / network reached). Works under default
109    /// features (rest source + jsonl sink are always present).
110    #[tokio::test]
111    async fn errors_when_no_replication_block() {
112        let path = write_config(
113            r#"
114version: 1
115name: plain
116pipeline:
117  source: { type: rest, config: { url: "https://example.com/api" } }
118  sink:   { type: jsonl, config: { path: ./out.jsonl } }
119"#,
120        );
121        let err = run(args(path)).await.unwrap_err();
122        assert!(
123            format!("{err}").contains("replication"),
124            "should mention the missing replication block: {err}"
125        );
126    }
127
128    /// A config WITH a `replication:` block that fails `CompiledReplication::compile`
129    /// (here: `state: memory`, which the durable-state rule rejects) must error
130    /// before `run_replication` — so no Docker is needed. Gated on the connector
131    /// kinds being compiled in so `source_supports_exactly_once` / `source_schema`
132    /// resolve (otherwise compile would fail earlier on an unknown-kind error,
133    /// which is a different, also-acceptable failure but not the branch under test).
134    #[cfg(all(
135        feature = "source-postgres-cdc",
136        feature = "source-postgres",
137        feature = "sink-postgres"
138    ))]
139    #[tokio::test]
140    async fn errors_when_replication_spec_invalid() {
141        let path = write_config(
142            r#"
143version: 1
144name: mirror
145pipeline:
146  source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
147  sink:   { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
148  state:  { type: memory, config: {} }
149replication:
150  mode: snapshot_then_cdc
151  snapshot:
152    source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
153"#,
154        );
155        let err = run(args(path)).await.unwrap_err();
156        assert!(
157            format!("{err}").contains("durable state"),
158            "should reject memory state at compile time: {err}"
159        );
160    }
161}