pub mod config;
pub mod serve;
use std::time::Duration;
use clap::{Args, Parser, Subcommand};
use crate::cli::config::{AuthConfig, Config, DEFAULT_HOST, DEFAULT_PORT, UpstreamConfig};
#[derive(Debug, Parser)]
#[command(name = "storage-proxy", version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Serve(ServeArgs),
Healthcheck(HealthcheckArgs),
}
#[derive(Debug, Default, Clone, Args)]
pub struct ServeArgs {
#[arg(short, long, env = "STORAGE_PROXY_CONFIG", value_name = "PATH")]
pub config: Option<String>,
#[arg(long)]
pub host: Option<String>,
#[arg(long, short)]
pub port: Option<u16>,
#[arg(long, value_name = "URL")]
pub upstream_url: Option<String>,
#[arg(long, value_name = "TOKEN")]
pub upstream_token: Option<String>,
}
impl ServeArgs {
pub fn resolve_config(&self) -> Result<Config, String> {
let mut cfg = match &self.config {
Some(path) => Config::load(path)?,
None => {
let base_url = self.upstream_url.clone().ok_or_else(|| {
"no config file and no --upstream-url: the upstream Unity Catalog URL is \
required"
.to_string()
})?;
Config {
host: None,
port: None,
base_path: None,
upstream: UpstreamConfig {
base_url,
token: None,
},
auth: AuthConfig::default(),
}
}
};
self.overlay(&mut cfg);
Ok(cfg)
}
fn overlay(&self, cfg: &mut Config) {
if let Some(host) = &self.host {
cfg.host = Some(host.clone());
}
if let Some(port) = self.port {
cfg.port = Some(port);
}
if let Some(url) = &self.upstream_url {
cfg.upstream.base_url = url.clone();
}
if let Some(token) = &self.upstream_token {
cfg.upstream.token = Some(config::ConfigValue::Value(token.clone()));
}
}
}
#[derive(Debug, Default, Clone, Args)]
pub struct HealthcheckArgs {
#[arg(short, long, env = "STORAGE_PROXY_CONFIG", value_name = "PATH")]
pub config: Option<String>,
#[arg(long)]
pub host: Option<String>,
#[arg(long, short)]
pub port: Option<u16>,
#[arg(long, value_name = "URL")]
pub url: Option<String>,
#[arg(long, default_value_t = 3)]
pub timeout_secs: u64,
}
impl HealthcheckArgs {
fn target_url(&self) -> Result<String, String> {
if let Some(url) = &self.url {
return Ok(url.clone());
}
let host = self
.host
.clone()
.or_else(|| {
self.config
.as_ref()
.and_then(|p| Config::load(p).ok())
.and_then(|c| c.host.clone())
})
.unwrap_or_else(|| DEFAULT_HOST.to_string());
let host = match host.as_str() {
"0.0.0.0" | "" | "::" => "127.0.0.1".to_string(),
_ => host,
};
let port = self
.port
.or_else(|| {
self.config
.as_ref()
.and_then(|p| Config::load(p).ok())
.and_then(|c| c.port)
})
.unwrap_or(DEFAULT_PORT);
Ok(format!("http://{host}:{port}/health"))
}
}
pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> {
let url = args.target_url()?;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(args.timeout_secs))
.build()
.map_err(|e| e.to_string())?;
let resp = client.get(&url).send().map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
return Err(format!("health endpoint returned {status}"));
}
let body = resp.text().map_err(|e| e.to_string())?;
if body.trim() != "OK" {
return Err(format!("unexpected health body {body:?}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_arg_tree_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn serve_parses_with_upstream_url() {
let cli = Cli::try_parse_from([
"storage-proxy",
"serve",
"--upstream-url",
"http://uc/",
"--port",
"9000",
])
.unwrap();
let Command::Serve(args) = cli.command else {
panic!("expected serve");
};
assert_eq!(args.upstream_url.as_deref(), Some("http://uc/"));
assert_eq!(args.port, Some(9000));
}
#[test]
fn serve_config_requires_upstream_without_file() {
let args = ServeArgs::default();
assert!(args.resolve_config().is_err());
}
#[test]
fn serve_flags_overlay_and_build_config() {
let args = ServeArgs {
upstream_url: Some("http://uc/".into()),
upstream_token: Some("secret".into()),
host: Some("127.0.0.1".into()),
port: Some(9000),
..ServeArgs::default()
};
let cfg = args.resolve_config().unwrap();
assert_eq!(cfg.upstream.base_url, "http://uc/");
assert_eq!(
cfg.upstream.token.as_ref().unwrap().value().as_deref(),
Some("secret")
);
assert_eq!(cfg.resolved_host(), "127.0.0.1");
assert_eq!(cfg.resolved_port(), 9000);
}
#[test]
fn healthcheck_parses() {
let cli = Cli::try_parse_from(["storage-proxy", "healthcheck", "--port", "9000"]).unwrap();
let Command::Healthcheck(args) = cli.command else {
panic!("expected healthcheck");
};
assert_eq!(args.port, Some(9000));
assert_eq!(args.timeout_secs, 3);
}
#[test]
fn healthcheck_url_overrides_config() {
let args = HealthcheckArgs {
url: Some("http://example.test:1234/health".into()),
..HealthcheckArgs::default()
};
assert_eq!(
args.target_url().unwrap(),
"http://example.test:1234/health"
);
}
#[test]
fn healthcheck_defaults_to_loopback_default_port() {
let args = HealthcheckArgs::default();
assert_eq!(args.target_url().unwrap(), "http://127.0.0.1:8080/health");
}
#[test]
fn no_subcommand_is_an_error() {
assert!(Cli::try_parse_from(["storage-proxy"]).is_err());
}
}