use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use crate::catalog_args::{CREDENTIAL_ENV, TOKEN_ENV};
use crate::service_paths::{IMAGE_EXEC, K8S_STATE_DIR, installed_state_dir};
use crate::error::{BifrostError, Result};
use crate::watch_cli::{FALKOR_URL_ENV, WatchArgs};
pub const SECRET_ENV: [&str; 6] = [
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
"BIFROST_S3_CA_PEM",
FALKOR_URL_ENV,
CREDENTIAL_ENV,
TOKEN_ENV,
];
pub const DEFAULT_NAME: &str = "bifrost-falkordb";
const DESCRIPTION: &str = "bifrost: Iceberg lake into a FalkorDB graph";
#[derive(Subcommand, Debug, Clone)]
pub enum FalkordbCommand {
Unit(ServiceArgs),
Install(ServiceArgs),
Uninstall(LifecycleArgs),
Start(LifecycleArgs),
Stop(LifecycleArgs),
Status(LifecycleArgs),
K8s(K8sArgs),
}
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct ServiceArgs {
#[command(flatten)]
pub watch: WatchArgs,
#[command(flatten)]
pub life: LifecycleArgs,
}
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct LifecycleArgs {
#[arg(long, default_value = DEFAULT_NAME)]
pub name: String,
#[arg(long)]
pub system: bool,
#[arg(long)]
pub purge: bool,
}
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct K8sArgs {
#[command(flatten)]
pub watch: WatchArgs,
#[arg(long, default_value = DEFAULT_NAME)]
pub name: String,
#[arg(long)]
pub image: String,
#[arg(long, default_value = "default")]
pub namespace: String,
#[arg(long, default_value = "1Gi")]
pub state_size: String,
#[arg(long, value_name = "DIR")]
pub out: PathBuf,
}
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn has_userinfo(url: &str) -> bool {
let rest = url.split_once("://").map_or(url, |(_, r)| r);
rest.split(['/', '?', '#']).next().is_some_and(|authority| authority.contains('@'))
}
pub fn service_watch(w: &WatchArgs, state: &Path) -> Result<WatchArgs> {
let refuse = |what: &str| Err(BifrostError::Other(format!("bifrost falkordb: {what}")));
if w.once {
return refuse("--once makes no service (it would exit after one round) - use `bifrost watch --once`");
}
if w.dry_run {
return refuse("--dry-run makes no service (it writes no graph) - use `bifrost watch --dry-run`");
}
if w.state.is_some() {
return refuse("--state: an installed service keeps its cursor in its own state dir");
}
if let Some(url) = &w.falkor
&& has_userinfo(url)
{
return refuse(&format!(
"--falkor carries a password, and argv is world-readable - set {FALKOR_URL_ENV} instead (it goes to the secret channel)"
));
}
Ok(WatchArgs { state: Some(state.to_path_buf()), ..w.clone() })
}
pub fn service_spec(name: &str, exec: &str, watch: &WatchArgs, system: bool) -> skidbladnir::service::ServiceSpec {
use skidbladnir::service::{AgentMode, ServiceSpec};
use skidbladnir::systemd::{RestartPolicy, Scope};
let mut spec = ServiceSpec::new(name, DESCRIPTION, exec)
.scope(if system { Scope::System } else { Scope::User })
.restart(RestartPolicy::OnFailure)
.agent(AgentMode::Fat)
.env("RUST_LOG", "info");
if let Some(dir) = watch.state.as_deref().and_then(Path::parent) {
spec = spec.working_dir(dir.to_string_lossy());
}
if system {
spec = spec.user("bifrost");
}
for a in watch.to_args() {
spec = spec.arg(a);
}
for k in SECRET_ENV {
if let Some(v) = env(k) {
spec = spec.secret_env(k, v);
}
}
spec
}
fn scope(system: bool) -> skidbladnir::systemd::Scope {
if system { skidbladnir::systemd::Scope::System } else { skidbladnir::systemd::Scope::User }
}
fn lifecycle(watch: Option<&WatchArgs>, life: &LifecycleArgs) -> Result<skidbladnir::service::Lifecycle> {
let exec = std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| IMAGE_EXEC.into());
let state_dir = installed_state_dir(&life.name, life.system);
let mut spec = match watch {
Some(w) => {
let cwd = std::env::current_dir()?;
let w = service_watch(&w.absolutized(&cwd), &state_dir.join("cursor.json"))?;
service_spec(&life.name, &exec, &w, life.system)
}
None => skidbladnir::service::ServiceSpec::new(&life.name, DESCRIPTION, &exec).scope(scope(life.system)),
};
if life.purge {
let root = skidbladnir::uninstall::DataRoot::from_env("BIFROST_FALKORDB_DATA", state_dir)
.map_err(|e| BifrostError::Other(format!("state dir: {e}")))?;
spec = spec.data_root(&root);
}
Ok(skidbladnir::service::Lifecycle::new(spec))
}
fn sk(e: anyhow::Error) -> BifrostError {
BifrostError::Other(format!("skidbladnir: {e:#}"))
}
fn in_image(w: &WatchArgs) -> Result<()> {
let c = &w.lake.catalog;
let files = [
Some(&w.mapping.mapping),
w.lake.warehouse.as_ref(),
w.lake.s3_ca.as_ref(),
w.tls.tls_cert.as_ref(),
w.tls.tls_key.as_ref(),
w.tls.tls_ca.as_ref(),
c.grpc_ca.as_ref(),
c.grpc_cert.as_ref(),
c.grpc_key.as_ref(),
w.metrics_file.as_ref(),
];
for f in files.into_iter().flatten() {
if !f.is_absolute() {
return Err(BifrostError::Other(format!(
"bifrost falkordb k8s: {} is relative - name it as a path inside the image (skidbladnir mounts no ConfigMap)",
f.display()
)));
}
}
Ok(())
}
pub fn k8s_files(args: &K8sArgs) -> Result<Vec<skidbladnir::service::RenderedFile>> {
use skidbladnir::service::{ServiceRenderer as _, Storage};
in_image(&args.watch)?;
let w = service_watch(&args.watch, &Path::new(K8S_STATE_DIR).join("cursor.json"))?;
let spec = service_spec(&args.name, IMAGE_EXEC, &w, false)
.image(&args.image)
.namespace(&args.namespace)
.replicas(1)
.stateful(true)
.storage(Storage::new(&args.state_size, K8S_STATE_DIR));
Ok(skidbladnir::k8s::KubernetesRenderer.render(&spec).files)
}
pub fn dispatch(cmd: FalkordbCommand) -> Result<()> {
match cmd {
FalkordbCommand::Unit(a) => {
let lc = lifecycle(Some(&a.watch), &a.life)?;
print!("{}", crate::service_paths::unit_text(&lc, &a.life.name, a.life.system));
Ok(())
}
FalkordbCommand::Install(a) => {
let lc = lifecycle(Some(&a.watch), &a.life)?;
crate::service_paths::install(&lc, &a.life.name, a.life.system).map_err(sk)?;
lc.enable().map_err(sk)?;
lc.start().map_err(sk)?;
println!("bifrost falkordb: INSTALLED and started ({})", lc.unit());
Ok(())
}
FalkordbCommand::Uninstall(l) => {
let r = lifecycle(None, &l)?.uninstall().map_err(sk)?;
let env = crate::service_paths::remove_env_file(&l.name, l.system)?;
println!(
"bifrost falkordb: unit removed = {}, env file removed = {env}, erased {} path(s)",
r.unit_removed,
r.erased.len()
);
Ok(())
}
FalkordbCommand::Start(l) => lifecycle(None, &l)?.start().map_err(sk),
FalkordbCommand::Stop(l) => lifecycle(None, &l)?.stop().map_err(sk),
FalkordbCommand::Status(l) => {
println!("{}", if lifecycle(None, &l)?.status().map_err(sk)? { "active" } else { "inactive" });
Ok(())
}
FalkordbCommand::K8s(a) => {
let files = k8s_files(&a)?;
std::fs::create_dir_all(&a.out)?;
for f in files {
let file = a.out.join(Path::new(&f.path).file_name().unwrap_or(f.path.as_ref()));
std::fs::write(&file, &f.contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(f.mode))?;
}
println!("{}", file.display());
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(Parser)]
struct T {
#[command(subcommand)]
cmd: FalkordbCommand,
}
fn parse(s: &str) -> std::result::Result<FalkordbCommand, clap::Error> {
T::try_parse_from(std::iter::once("t").chain(s.split_whitespace())).map(|t| t.cmd)
}
#[derive(Parser)]
struct W {
#[command(flatten)]
w: WatchArgs,
}
const LAKE: &str = "--rest-uri https://polaris:8181 --catalog polaris --rest-warehouse cat \
--s3-endpoint https://minio:9000 --s3-bucket lake --mapping /etc/bifrost/m.yml \
--falkor rediss://falkor:6380 --graph social";
#[test]
fn the_unit_runs_watch_with_its_own_cursor_and_parses_back() {
let Ok(FalkordbCommand::Install(a)) = parse(&format!("install {LAKE} --system")) else { panic!() };
assert_eq!(a.life.name, DEFAULT_NAME);
let state = installed_state_dir(&a.life.name, true).join("cursor.json");
let w = service_watch(&a.watch, &state).unwrap();
let spec = service_spec(&a.life.name, "/usr/bin/bifrost", &w, true);
let unit = spec.render_systemd_unit();
let args = w.to_args();
assert_eq!(args[0], "watch");
for x in &args {
assert!(unit.contains(x.as_str()), "the unit carries {x}");
}
assert!(unit.contains("/var/lib/bifrost-falkordb/cursor.json"));
let back = W::try_parse_from(std::iter::once("t".to_string()).chain(args.into_iter().skip(1))).unwrap().w;
assert_eq!(back, w, "what the service runs parses back to what was asked for");
}
#[test]
fn once_dry_run_state_and_a_password_in_the_url_are_refused() {
for extra in ["--once", "--dry-run", "--state /tmp/s.json"] {
let Ok(FalkordbCommand::Unit(a)) = parse(&format!("unit {LAKE} {extra}")) else { panic!() };
assert!(service_watch(&a.watch, Path::new("/s/c.json")).is_err(), "{extra} refused");
}
let lake = LAKE.replace("rediss://falkor:6380", "redis://:pw@falkor:6379");
let Ok(FalkordbCommand::Unit(a)) = parse(&format!("unit {lake}")) else { panic!() };
let e = service_watch(&a.watch, Path::new("/s/c.json")).unwrap_err().to_string();
assert!(e.contains(FALKOR_URL_ENV) && !e.contains(":pw@"), "{e}");
}
#[test]
fn userinfo_is_found_only_in_the_authority() {
assert!(has_userinfo("redis://:pw@h:6379"));
assert!(has_userinfo("rediss://u:p@h"));
assert!(!has_userinfo("redis://h:6379"));
assert!(!has_userinfo("redis://h:6379/0?x=a@b"));
}
#[test]
fn systemd_and_k8s_run_the_same_command_and_no_secret_is_on_it() {
unsafe {
std::env::set_var(CREDENTIAL_ENV, "id:hunter2-not-on-argv");
std::env::set_var(FALKOR_URL_ENV, "redis://:hunter3@f:6379");
}
let Ok(FalkordbCommand::K8s(k)) = parse(&format!("k8s {LAKE} --image reg/bifrost:1 --out /tmp/x")) else {
panic!()
};
let state = Path::new(K8S_STATE_DIR).join("cursor.json");
let w = service_watch(&k.watch, &state).unwrap();
let unit = service_spec(&k.name, IMAGE_EXEC, &w, false).render_systemd_unit();
let files = k8s_files(&k).unwrap();
let workload = files.iter().find(|f| f.contents.contains("kind: StatefulSet")).expect("a StatefulSet");
for a in w.to_args() {
assert!(workload.contents.contains(&a), "k8s args carry {a}");
assert!(unit.contains(&a), "the unit carries {a}");
}
for secret in ["hunter2", "hunter3"] {
assert!(!unit.contains(secret), "a secret never lands in the 0644 unit");
assert!(!workload.contents.contains(secret), "a secret never lands in the workload");
}
let secret = files.iter().find(|f| f.contents.contains("kind: Secret")).expect("a Secret for the credentials");
assert_eq!(secret.mode, 0o600);
assert!(workload.contents.contains(K8S_STATE_DIR), "the cursor lives on the volume");
}
#[test]
fn k8s_refuses_a_file_that_cannot_be_in_the_image() {
let lake = LAKE.replace("/etc/bifrost/m.yml", "m.yml");
let Ok(FalkordbCommand::K8s(k)) = parse(&format!("k8s {lake} --image i --out /tmp/x")) else { panic!() };
assert!(k8s_files(&k).unwrap_err().to_string().contains("inside the image"));
}
#[test]
fn lifecycle_verbs_take_only_a_name() {
let Ok(FalkordbCommand::Uninstall(l)) = parse("uninstall --name g1 --system --purge") else { panic!() };
assert_eq!(l, LifecycleArgs { name: "g1".into(), system: true, purge: true });
assert!(parse("status --mapping m.yml").is_err());
}
}