use crate::client::ControlPlane;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error("rendering the repair result failed: {0}")]
Render(#[source] serde_json::Error),
#[error("provisioning repair PARTIAL: convergence FAILED at check `{check}`")]
CheckErrored {
check: String,
},
#[error("{0}")]
InvalidDb(String),
}
type Result<T> = std::result::Result<T, Error>;
fn validate_db(db: &str) -> Result<()> {
boatramp_core::project::validate_resource_name("database", db).map_err(|err| {
if db.is_empty() {
Error::InvalidDb(format!(
"{err}; {}",
boatramp_core::project::EMPTY_DB_NAME_CURE
))
} else {
Error::InvalidDb(err.to_string())
}
})
}
#[derive(Debug, clap::Args)]
pub struct RepairArgs {
#[arg(long, default_value = boatramp_core::project::DEFAULT_DB_NAME)]
db: String,
#[arg(long, conflicts_with = "dry_run")]
apply: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
exit_nonzero_on_drift: bool,
#[arg(long)]
json: bool,
}
pub async fn run(args: RepairArgs, cp: &ControlPlane) -> Result<()> {
let RepairArgs {
db,
apply,
dry_run: _,
exit_nonzero_on_drift,
json,
} = args;
validate_db(&db)?;
if apply {
println!("running: boatramp project repair --db {db} --apply");
}
let report = cp.repair_trigger(&db, apply).await?;
render_report(&report, json)?;
if let Some(check) = report.first_error().map(str::to_string) {
return Err(Error::CheckErrored { check });
}
if !apply && exit_nonzero_on_drift && report.found_drift() {
std::process::exit(2);
}
Ok(())
}
fn render_report(report: &boatramp_core::sql::RepairReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(report).map_err(Error::Render)?
);
return Ok(());
}
println!("tenant: {}", report.tenant);
println!("backend: {}", report.backend);
println!("mode: {}", report.mode);
println!();
let (h_check, h_status, h_detail) = ("CHECK", "STATUS", "DETAIL");
println!("{h_check:<24} {h_status:<9} {h_detail}");
for c in &report.checks {
println!("{:<24} {:<9} {}", c.check, c.status.as_str(), c.detail);
}
let with_ddl: Vec<&boatramp_core::sql::RepairCheck> =
report.checks.iter().filter(|c| c.ddl.is_some()).collect();
if !with_ddl.is_empty() {
let verb = if report.mode == "apply" {
"DDL executed"
} else {
"DDL that WOULD run"
};
println!("\n{verb}:");
for c in with_ddl {
if let Some(ddl) = &c.ddl {
println!(" [{}]", c.check);
for line in ddl.lines() {
println!(" {line}");
}
}
}
}
println!();
if let Some(check) = report.first_error() {
println!("convergence PARTIAL: FAILED at [{check}]");
} else if report.found_drift() {
if report.mode == "apply" {
println!("repaired: all drift converged");
} else {
println!("drift found (dry-run: nothing changed) — re-run with --apply to converge");
}
} else {
println!("nothing to repair");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::sql::{RepairCheck, RepairReport, RepairStatus};
use clap::{Parser, Subcommand};
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Repair(RepairArgs),
}
fn parse(argv: &[&str]) -> std::result::Result<RepairArgs, clap::Error> {
let cli = Cli::try_parse_from(std::iter::once("boatramp").chain(argv.iter().copied()))?;
let Cmd::Repair(args) = cli.cmd;
Ok(args)
}
#[test]
fn repair_flags_parse_and_default_to_dry_run() {
let a = parse(&["repair"]).unwrap();
assert_eq!(a.db, boatramp_core::project::DEFAULT_DB_NAME);
assert!(!a.apply, "repair must DEFAULT to a dry-run (apply=false)");
assert!(!a.dry_run);
assert!(!a.exit_nonzero_on_drift);
assert!(!a.json);
let a = parse(&["repair", "--db", "main", "--apply", "--json"]).unwrap();
assert_eq!(a.db, "main");
assert!(a.apply);
assert!(a.json);
assert!(parse(&["repair", "--dry-run"]).unwrap().dry_run);
assert!(parse(&["repair", "--apply", "--dry-run"]).is_err());
assert!(
parse(&["repair", "--exit-nonzero-on-drift"])
.unwrap()
.exit_nonzero_on_drift
);
}
#[test]
fn validate_db_gates_unsafe_names_client_side() {
assert!(validate_db(boatramp_core::project::DEFAULT_DB_NAME).is_ok());
assert!(validate_db("main").is_ok());
for bad in ["", "a/b", "..", "a b"] {
assert!(validate_db(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn empty_db_name_rejection_carries_the_cure() {
let Err(Error::InvalidDb(msg)) = validate_db("") else {
panic!("empty --db must be rejected");
};
assert!(
msg.contains(boatramp_core::project::EMPTY_DB_NAME_CURE),
"empty-name error must carry the cure, got: {msg}"
);
}
#[test]
fn renderers_are_infallible() {
let ok = RepairReport {
tenant: "appdb_acme".into(),
backend: "shared-postgres".into(),
mode: "dry-run".into(),
checks: vec![RepairCheck {
check: "owner-role-exists".into(),
status: RepairStatus::Ok,
detail: "owner role exists".into(),
ddl: None,
}],
};
render_report(&ok, false).unwrap();
render_report(&ok, true).unwrap();
assert!(!ok.found_drift());
assert!(ok.first_error().is_none());
let drift = RepairReport {
mode: "dry-run".into(),
checks: vec![RepairCheck {
check: "db-owner".into(),
status: RepairStatus::Drift,
detail: "db owned by runtime".into(),
ddl: Some("ALTER DATABASE \"appdb_acme\" OWNER TO \"appdb_acme_owner\";".into()),
}],
..ok.clone()
};
render_report(&drift, false).unwrap();
assert!(drift.found_drift());
let errored = RepairReport {
checks: vec![RepairCheck {
check: "object-ownership".into(),
status: RepairStatus::Error,
detail: "requires a superuser maintenance connection".into(),
ddl: None,
}],
..ok.clone()
};
render_report(&errored, false).unwrap();
assert_eq!(errored.first_error(), Some("object-ownership"));
}
}