use std::io::BufRead;
use std::sync::Arc;
use acme_proxy_admin::admin;
use acme_proxy_core::logfields::redact_url;
use acme_proxy_jobs::auditor::admin as audit_admin;
use acme_proxy_store::db::Database;
use acme_proxy_store::transfer;
use crate::cli::CliError;
pub async fn run_transfer_command(
to: &str,
json: bool,
yes: bool,
reader: &mut impl BufRead,
source_url: &str,
database: Arc<Database>,
) -> Result<(), CliError> {
if same_database(source_url, to) {
return Err(CliError::bad_request(format!(
"the source and the target are the same database ({})",
redact_url(source_url)
)));
}
let target = Database::open(to).await.map_err(|error| {
CliError::failed(format!(
"cannot open the target {}: {error}",
redact_url(to)
))
})?;
let pending = target.pending_migrations().await?;
if !pending.is_empty() {
return Err(CliError::bad_request(format!(
"the target's schema is {} migration(s) behind; run \
`ACME_PROXY_DATABASE__URL={} acme-proxy migrate` first",
pending.len(),
redact_url(to)
)));
}
let occupied = transfer::non_empty_tables(&target).await?;
if !occupied.is_empty() {
let held = occupied
.iter()
.map(|table| format!("{} ({} row(s))", table.table, table.rows))
.collect::<Vec<_>>()
.join(", ");
return Err(CliError::bad_request(format!(
"the target already holds rows, and a transfer is a copy rather than \
a merge: {held}. Start from an empty database — create one and run \
`acme-proxy migrate` against it"
)));
}
let counts = transfer::non_empty_tables(&database).await?;
let total: u64 = counts.iter().map(|table| table.rows).sum();
if total == 0 {
println!("The source holds no rows; there is nothing to copy.");
return Ok(());
}
let prompt = format!(
"Copy {total} row(s) from {} to {}?\n\
The source server must be stopped, or the copy is a torn snapshot.\n\
Continue?",
redact_url(source_url),
redact_url(to)
);
if !admin::confirm(&prompt, yes, reader) {
println!("Cancelled.");
return Ok(());
}
let report = database.transfer_to(&target).await?;
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::database_transferred(actor, client, report.total())
})
.await;
if json {
let tables: Vec<serde_json::Value> = report
.tables
.iter()
.map(|table| serde_json::json!({ "table": table.table, "rows": table.rows }))
.collect();
println!(
"{}",
serde_json::json!({ "tables": tables, "total": report.total() })
);
} else {
for table in &report.tables {
println!(" {:<24} {}", table.table, table.rows);
}
println!(
"Copied {} row(s) into {} table(s).",
report.total(),
report.tables.len()
);
}
Ok(())
}
fn same_database(source: &str, target: &str) -> bool {
source.trim_end_matches('/') == target.trim_end_matches('/')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::CliErrorKind;
use acme_proxy_core::testutil::TempDir;
use acme_proxy_store::audit::{AuditEntry, AuditQuery};
use acme_proxy_store::testutil as fixtures;
const SOURCE_URL: &str = "sqlite://source.db";
async fn seeded_source() -> Arc<Database> {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
fixtures::seed_every_table(&database).await;
database
}
async fn migrated_target(dir: &TempDir, name: &str) -> String {
let url = format!("sqlite://{}", dir.join(name).display());
let database = Database::connect_and_migrate(&url)
.await
.expect("a fresh SQLite file migrates");
database.close().await;
url
}
async fn counts(url: &str) -> Vec<(&'static str, u64)> {
let database = Database::open(url).await.expect("the target reopens");
let counts = fixtures::row_counts(&database).await;
database.close().await;
counts
}
#[tokio::test]
async fn the_same_url_twice_is_refused_before_anything_is_opened() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let error =
run_transfer_command(SOURCE_URL, false, true, &mut &b""[..], SOURCE_URL, database)
.await
.expect_err("a database is not copied into itself");
assert_eq!(error.kind(), CliErrorKind::BadRequest);
assert!(error.message.contains("the same database"), "{error}");
}
#[tokio::test]
async fn an_unopenable_target_is_refused_by_name() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let error = run_transfer_command(
"mysql://acme@db.internal/acme",
false,
true,
&mut &b""[..],
SOURCE_URL,
database,
)
.await
.expect_err("a scheme this server does not speak");
assert_eq!(error.kind(), CliErrorKind::Failed);
assert!(error.message.contains("cannot open the target"), "{error}");
}
#[tokio::test]
async fn an_unmigrated_target_is_refused_and_says_what_to_run() {
let dir = TempDir::new("transfer-unmigrated");
let url = format!("sqlite://{}", dir.join("target.db").display());
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let error = run_transfer_command(&url, false, true, &mut &b""[..], SOURCE_URL, database)
.await
.expect_err("an empty file is not a migrated database");
assert_eq!(error.kind(), CliErrorKind::BadRequest);
assert!(error.message.contains("migration(s) behind"), "{error}");
assert!(error.message.contains("acme-proxy migrate"), "{error}");
}
#[tokio::test]
async fn a_target_that_holds_rows_is_refused_and_lists_them() {
let dir = TempDir::new("transfer-occupied");
let url = migrated_target(&dir, "target.db").await;
let occupied = Arc::new(Database::open(&url).await.unwrap());
fixtures::seed_every_table(&occupied).await;
occupied.close().await;
let error = run_transfer_command(
&url,
false,
true,
&mut &b""[..],
SOURCE_URL,
seeded_source().await,
)
.await
.expect_err("a copy into a populated database is not a merge");
assert_eq!(error.kind(), CliErrorKind::BadRequest);
assert!(error.message.contains("already holds rows"), "{error}");
assert!(error.message.contains("accounts"), "{error}");
}
#[tokio::test]
async fn an_empty_source_copies_nothing() {
let dir = TempDir::new("transfer-empty");
let url = migrated_target(&dir, "target.db").await;
let database = Arc::new(Database::connect_in_memory().await.unwrap());
run_transfer_command(&url, false, false, &mut &b""[..], SOURCE_URL, database)
.await
.expect("nothing to copy is not a failure");
assert!(
counts(&url).await.iter().all(|(_, rows)| *rows == 0),
"the target is untouched"
);
}
#[tokio::test]
async fn a_declined_prompt_copies_nothing_and_writes_no_audit_row() {
let dir = TempDir::new("transfer-declined");
let url = migrated_target(&dir, "target.db").await;
let source = seeded_source().await;
let before = AuditEntry::search(&AuditQuery::default(), &source)
.await
.unwrap()
.1;
run_transfer_command(
&url,
false,
false,
&mut b"n\n".as_slice(),
SOURCE_URL,
source.clone(),
)
.await
.expect("a decline is not a failure");
assert!(
counts(&url).await.iter().all(|(_, rows)| *rows == 0),
"a decline copies nothing"
);
assert_eq!(
AuditEntry::search(&AuditQuery::default(), &source)
.await
.unwrap()
.1,
before,
"a declined transfer is not an administrative action"
);
}
#[tokio::test]
async fn a_confirmed_transfer_copies_every_row_and_records_it_on_the_source() {
let dir = TempDir::new("transfer-confirmed");
let url = migrated_target(&dir, "target.db").await;
let source = seeded_source().await;
let before = fixtures::row_counts(&source).await;
run_transfer_command(
&url,
false,
false,
&mut b"y\n".as_slice(),
SOURCE_URL,
source.clone(),
)
.await
.expect("the copy should succeed");
assert_eq!(
counts(&url).await,
before,
"the target holds what was there"
);
let (rows, _) = AuditEntry::search(
&AuditQuery {
limit: 10,
..AuditQuery::default()
},
&source,
)
.await
.unwrap();
let recorded = rows
.iter()
.find(|row| row.event == "database_transferred")
.expect("moving every row is an administrative action");
assert_eq!(recorded.actor_kind, "cli");
}
#[tokio::test]
async fn both_output_modes_report_the_same_copy() {
let dir = TempDir::new("transfer-output");
for (index, json) in [true, false].into_iter().enumerate() {
let url = migrated_target(&dir, &format!("target-{index}.db")).await;
let source = seeded_source().await;
let before = fixtures::row_counts(&source).await;
run_transfer_command(&url, json, true, &mut &b""[..], SOURCE_URL, source)
.await
.expect("the copy should succeed");
assert_eq!(counts(&url).await, before, "json = {json}");
}
}
#[test]
fn a_url_pasted_twice_is_the_same_database() {
assert!(same_database("sqlite://acme.db", "sqlite://acme.db"));
assert!(same_database("postgres://a@h/db", "postgres://a@h/db/"));
}
#[test]
fn the_two_backends_are_not_the_same_database() {
assert!(!same_database(
"sqlite://acme.db",
"postgres://acme@db.internal/acme"
));
assert!(!same_database("sqlite://a.db", "sqlite://b.db"));
}
}