use std::path::{Path, PathBuf};
use crate::{MappingSpec, mapping::SyncMode};
pub const FALKOR_URL_ENV: &str = "BIFROST_FALKOR_URL";
pub const DEFAULT_FALKOR: &str = "redis://127.0.0.1:6379";
#[derive(clap::Args, Clone, Debug, Default, PartialEq, Eq)]
pub struct TlsArgs {
#[arg(long, value_name = "PEM", requires = "tls_key")]
pub tls_cert: Option<PathBuf>,
#[arg(long, value_name = "PEM", requires = "tls_cert")]
pub tls_key: Option<PathBuf>,
#[arg(long, value_name = "PEM")]
pub tls_ca: Option<PathBuf>,
}
impl TlsArgs {
pub fn identity(&self) -> Result<Option<graphar_falkordb::FalkorTls>, Box<dyn std::error::Error>> {
match (&self.tls_cert, &self.tls_key) {
(Some(c), Some(k)) => Ok(Some(graphar_falkordb::FalkorTls::from_files(c, k, self.tls_ca.as_deref())?)),
_ => Ok(None),
}
}
}
#[derive(clap::Args, Clone, Debug, PartialEq, Eq)]
pub struct MappingArgs {
#[arg(long)]
pub mapping: PathBuf,
#[arg(long, default_value = "cdc", value_parser = parse_mode)]
pub mode: SyncMode,
}
impl MappingArgs {
pub fn load(&self) -> Result<MappingSpec, Box<dyn std::error::Error>> {
Ok(crate::mapping::load_mapping(&self.mapping, self.mode)?)
}
}
pub fn parse_mode(s: &str) -> Result<SyncMode, String> {
serde_yaml::from_str(s).map_err(|_| format!("{s:?}: one of full, incremental, cdc"))
}
#[derive(clap::Args, Clone, Debug, PartialEq, Eq)]
#[group(skip)]
pub struct LakeArgs {
#[arg(
long,
value_name = "DIR",
conflicts_with_all = ["s3_root", "rest_uri", "grpc_uri"],
required_unless_present_any = ["s3_root", "rest_uri", "grpc_uri"]
)]
pub warehouse: Option<PathBuf>,
#[arg(long, value_name = "S3_URL", conflicts_with_all = ["rest_uri", "grpc_uri"])]
pub s3_root: Option<String>,
#[arg(long, value_name = "URL", conflicts_with = "grpc_uri")]
pub rest_uri: Option<String>,
#[arg(long, requires = "rest_uri")]
pub rest_warehouse: Option<String>,
#[arg(long, value_name = "NS")]
pub rest_namespace: Option<String>,
#[arg(long, value_name = "SERVICE", requires = "rest_uri")]
pub sigv4: Option<String>,
#[arg(long, value_name = "URL")]
pub s3_endpoint: Option<String>,
#[arg(long, default_value = "us-east-1")]
pub s3_region: String,
#[arg(long)]
pub s3_bucket: Option<String>,
#[arg(long, value_name = "PEM")]
pub s3_ca: Option<PathBuf>,
#[command(flatten)]
pub catalog: crate::catalog_args::CatalogArgs,
}
impl LakeArgs {
#[cfg(feature = "skade")]
pub fn default_state(&self) -> PathBuf {
match &self.warehouse {
Some(dir) => dir.join(".bifrost-state.json"),
None => PathBuf::from(".bifrost-state.json"),
}
}
#[cfg(feature = "skade")]
#[cfg_attr(not(feature = "skade-s3"), allow(unused_variables))]
pub fn lake(&self, state_dir: &std::path::Path) -> Result<crate::delivery::IcebergLake, Box<dyn std::error::Error>> {
use crate::delivery::IcebergLake;
self.catalog.check_unused(self.rest_uri.is_some())?;
if self.rest_namespace.is_some() && self.rest_uri.is_none() && self.catalog.grpc_uri.is_none() {
return Err("--rest-namespace needs --rest-uri or --grpc-uri".into());
}
if let Some(dir) = &self.warehouse {
return Ok(IcebergLake::Warehouse(dir.clone()));
}
#[cfg(feature = "skade-s3")]
let s3 = |root_bucket: Option<&str>| -> Result<skade::S3Config, Box<dyn std::error::Error>> {
use crate::delivery::{s3_ca, s3_config};
let endpoint = self.s3_endpoint.clone().ok_or("--s3-endpoint is required with --s3-root / --rest-uri")?;
let bucket = self
.s3_bucket
.clone()
.or_else(|| root_bucket.map(str::to_string))
.or_else(|| self.rest_warehouse.clone())
.ok_or("--s3-bucket is required (no bucket in --s3-root or --rest-warehouse)")?;
let ca = s3_ca(self.s3_ca.as_deref(), state_dir)?;
Ok(s3_config(endpoint, self.s3_region.clone(), bucket, ca)?)
};
if let Some(root) = &self.s3_root {
#[cfg(feature = "skade-s3")]
{
let bucket = crate::delivery::bucket_of(root).ok_or("--s3-root must be an s3://bucket/… URL")?;
return Ok(IcebergLake::S3 { s3: s3(Some(bucket))?, root: root.clone() });
}
#[cfg(not(feature = "skade-s3"))]
return Err(format!("--s3-root {root}: this build has no S3 reader - rebuild with --features skade-s3").into());
}
if let Some(uri) = &self.rest_uri {
#[cfg(feature = "skade-rest")]
{
let catalog = self.catalog.rest(uri, self.rest_warehouse.clone(), self.sigv4.clone(), s3(None)?, true)?;
return Ok(IcebergLake::Catalog { connector: std::sync::Arc::new(catalog), namespace: self.rest_namespace.clone() });
}
#[cfg(not(feature = "skade-rest"))]
return Err(format!("--rest-uri {uri}: this build has no REST catalog client - rebuild with --features skade-rest").into());
}
if let Some(uri) = &self.catalog.grpc_uri {
#[cfg(feature = "skade-grpc")]
{
let data = match self.s3_endpoint {
Some(_) => Some(s3(None)?),
None => None,
};
let catalog = self.catalog.grpc(uri, data, true);
return Ok(IcebergLake::Catalog { connector: std::sync::Arc::new(catalog), namespace: self.rest_namespace.clone() });
}
#[cfg(not(feature = "skade-grpc"))]
return Err(format!("--grpc-uri {uri}: this build has no gRPC catalog client - rebuild with --features skade-grpc").into());
}
Err("name the lake: --warehouse DIR, --s3-root s3://…, --rest-uri URL, or --grpc-uri URL".into())
}
}
#[derive(clap::Args, Clone, Debug, PartialEq, Eq)]
pub struct WatchArgs {
#[command(flatten)]
pub lake: LakeArgs,
#[command(flatten)]
pub mapping: MappingArgs,
#[arg(long, value_name = "URL")]
pub falkor: Option<String>,
#[command(flatten)]
pub tls: TlsArgs,
#[arg(long, default_value = "bifrost")]
pub graph: String,
#[arg(long)]
pub state: Option<PathBuf>,
#[arg(long)]
pub once: bool,
#[arg(long, default_value_t = 15)]
pub interval: u64,
#[arg(long)]
pub max_backoff: Option<u64>,
#[arg(long, value_name = "PATH")]
pub metrics_file: Option<PathBuf>,
#[arg(long, value_name = "ADDR")]
pub metrics_addr: Option<String>,
#[arg(long)]
pub dry_run: bool,
}
fn push(a: &mut Vec<String>, k: &str, v: Option<String>) {
if let Some(v) = v {
a.push(format!("--{k}"));
a.push(v);
}
}
fn path(p: &Option<PathBuf>) -> Option<String> {
p.as_ref().map(|p| p.display().to_string())
}
fn abs(cwd: &Path, p: &Option<PathBuf>) -> Option<PathBuf> {
p.as_ref().map(|p| if p.is_absolute() { p.clone() } else { cwd.join(p) })
}
impl TlsArgs {
pub fn to_args(&self) -> Vec<String> {
let mut a = Vec::new();
push(&mut a, "tls-cert", path(&self.tls_cert));
push(&mut a, "tls-key", path(&self.tls_key));
push(&mut a, "tls-ca", path(&self.tls_ca));
a
}
}
impl MappingArgs {
pub fn to_args(&self) -> Vec<String> {
let mode = serde_yaml::to_string(&self.mode).unwrap_or_default();
vec!["--mapping".into(), self.mapping.display().to_string(), "--mode".into(), mode.trim().to_string()]
}
}
impl LakeArgs {
pub fn to_args(&self) -> Vec<String> {
let mut a = Vec::new();
push(&mut a, "warehouse", path(&self.warehouse));
push(&mut a, "s3-root", self.s3_root.clone());
push(&mut a, "rest-uri", self.rest_uri.clone());
push(&mut a, "rest-warehouse", self.rest_warehouse.clone());
push(&mut a, "rest-namespace", self.rest_namespace.clone());
push(&mut a, "sigv4", self.sigv4.clone());
push(&mut a, "s3-endpoint", self.s3_endpoint.clone());
push(&mut a, "s3-region", Some(self.s3_region.clone()));
push(&mut a, "s3-bucket", self.s3_bucket.clone());
push(&mut a, "s3-ca", path(&self.s3_ca));
a.extend(self.catalog.to_args());
a
}
}
impl WatchArgs {
pub fn falkor_url(&self) -> String {
self.falkor
.clone()
.or_else(|| std::env::var(FALKOR_URL_ENV).ok().filter(|v| !v.is_empty()))
.unwrap_or_else(|| DEFAULT_FALKOR.to_string())
}
pub fn to_args(&self) -> Vec<String> {
let mut a = vec!["watch".to_string()];
a.extend(self.lake.to_args());
a.extend(self.mapping.to_args());
push(&mut a, "falkor", self.falkor.clone());
a.extend(self.tls.to_args());
push(&mut a, "graph", Some(self.graph.clone()));
push(&mut a, "state", path(&self.state));
if self.once {
a.push("--once".into());
}
push(&mut a, "interval", Some(self.interval.to_string()));
push(&mut a, "max-backoff", self.max_backoff.map(|b| b.to_string()));
push(&mut a, "metrics-file", path(&self.metrics_file));
push(&mut a, "metrics-addr", self.metrics_addr.clone());
if self.dry_run {
a.push("--dry-run".into());
}
a
}
pub fn absolutized(&self, cwd: &Path) -> Self {
let mut w = self.clone();
w.lake.warehouse = abs(cwd, &self.lake.warehouse);
w.lake.s3_ca = abs(cwd, &self.lake.s3_ca);
w.lake.catalog = self.lake.catalog.absolutized(cwd);
w.mapping.mapping = abs(cwd, &Some(self.mapping.mapping.clone())).unwrap_or_default();
w.tls.tls_cert = abs(cwd, &self.tls.tls_cert);
w.tls.tls_key = abs(cwd, &self.tls.tls_key);
w.tls.tls_ca = abs(cwd, &self.tls.tls_ca);
w.state = abs(cwd, &self.state);
w.metrics_file = abs(cwd, &self.metrics_file);
w
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(Parser)]
struct T {
#[command(flatten)]
w: WatchArgs,
}
fn parse(args: &[String]) -> WatchArgs {
T::try_parse_from(std::iter::once("t".to_string()).chain(args.iter().cloned())).unwrap().w
}
fn split(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_string).collect()
}
#[test]
fn the_watch_command_line_round_trips_through_its_rendering() {
let w = parse(&split(
"--rest-uri https://polaris:8181 --catalog polaris --rest-warehouse cat --rest-namespace ns \
--s3-endpoint https://minio:9000 --s3-bucket lake --rest-header X-T=a \
--mapping m.yml --mode incremental --falkor rediss://f:6380 --tls-cert c.pem --tls-key k.pem \
--graph g --state /s/c.json --interval 7 --max-backoff 60 --metrics-addr 127.0.0.1:9090",
));
let rendered = w.to_args();
assert_eq!(rendered[0], "watch");
assert_eq!(parse(&rendered[1..]), w);
}
#[test]
fn absolutized_anchors_every_file_and_nothing_else() {
let w = parse(&split("--warehouse wh --mapping m.yml --tls-ca ca.pem --state st.json --grpc-ca g.pem"))
.absolutized(Path::new("/srv"));
assert_eq!(w.lake.warehouse.as_deref(), Some(Path::new("/srv/wh")));
assert_eq!(w.mapping.mapping, Path::new("/srv/m.yml"));
assert_eq!(w.tls.tls_ca.as_deref(), Some(Path::new("/srv/ca.pem")));
assert_eq!(w.state.as_deref(), Some(Path::new("/srv/st.json")));
assert_eq!(w.lake.catalog.grpc_ca.as_deref(), Some(Path::new("/srv/g.pem")));
let a = parse(&split("--warehouse /abs --mapping /m.yml")).absolutized(Path::new("/srv"));
assert_eq!(a.lake.warehouse.as_deref(), Some(Path::new("/abs")));
}
#[test]
fn the_flag_wins_over_the_env_and_the_default_is_local() {
let w = parse(&split("--warehouse /w --mapping /m.yml --falkor redis://x:1"));
assert_eq!(w.falkor_url(), "redis://x:1");
}
}