1use std::io::BufRead;
28use std::sync::Arc;
29
30use acme_proxy_admin::admin;
31use acme_proxy_core::logfields::redact_url;
32use acme_proxy_jobs::auditor::admin as audit_admin;
33use acme_proxy_store::db::Database;
34use acme_proxy_store::transfer;
35
36use crate::cli::CliError;
37
38pub async fn run_transfer_command(
39 to: &str,
40 json: bool,
41 yes: bool,
42 reader: &mut impl BufRead,
43 source_url: &str,
44 database: Arc<Database>,
45) -> Result<(), CliError> {
46 if same_database(source_url, to) {
47 return Err(CliError::bad_request(format!(
48 "the source and the target are the same database ({})",
49 redact_url(source_url)
50 )));
51 }
52
53 let target = Database::open(to).await.map_err(|error| {
54 CliError::failed(format!(
55 "cannot open the target {}: {error}",
56 redact_url(to)
57 ))
58 })?;
59
60 let pending = target.pending_migrations().await?;
62 if !pending.is_empty() {
63 return Err(CliError::bad_request(format!(
64 "the target's schema is {} migration(s) behind; run \
65 `ACME_PROXY_DATABASE__URL={} acme-proxy migrate` first",
66 pending.len(),
67 redact_url(to)
68 )));
69 }
70
71 let occupied = transfer::non_empty_tables(&target).await?;
72 if !occupied.is_empty() {
73 let held = occupied
74 .iter()
75 .map(|table| format!("{} ({} row(s))", table.table, table.rows))
76 .collect::<Vec<_>>()
77 .join(", ");
78 return Err(CliError::bad_request(format!(
79 "the target already holds rows, and a transfer is a copy rather than \
80 a merge: {held}. Start from an empty database — create one and run \
81 `acme-proxy migrate` against it"
82 )));
83 }
84
85 let counts = transfer::non_empty_tables(&database).await?;
86 let total: u64 = counts.iter().map(|table| table.rows).sum();
87 if total == 0 {
88 println!("The source holds no rows; there is nothing to copy.");
89 return Ok(());
90 }
91
92 let prompt = format!(
93 "Copy {total} row(s) from {} to {}?\n\
94 The source server must be stopped, or the copy is a torn snapshot.\n\
95 Continue?",
96 redact_url(source_url),
97 redact_url(to)
98 );
99 if !admin::confirm(&prompt, yes, reader) {
100 println!("Cancelled.");
101 return Ok(());
102 }
103
104 let report = database.transfer_to(&target).await?;
105
106 audit_admin::record_cli_action(&database, |actor, client| {
107 audit_admin::database_transferred(actor, client, report.total())
108 })
109 .await;
110
111 if json {
112 let tables: Vec<serde_json::Value> = report
113 .tables
114 .iter()
115 .map(|table| serde_json::json!({ "table": table.table, "rows": table.rows }))
116 .collect();
117 println!(
118 "{}",
119 serde_json::json!({ "tables": tables, "total": report.total() })
120 );
121 } else {
122 for table in &report.tables {
123 println!(" {:<24} {}", table.table, table.rows);
124 }
125 println!(
126 "Copied {} row(s) into {} table(s).",
127 report.total(),
128 report.tables.len()
129 );
130 }
131 Ok(())
132}
133
134fn same_database(source: &str, target: &str) -> bool {
142 source.trim_end_matches('/') == target.trim_end_matches('/')
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::cli::CliErrorKind;
149 use acme_proxy_core::testutil::TempDir;
150 use acme_proxy_store::audit::{AuditEntry, AuditQuery};
151 use acme_proxy_store::testutil as fixtures;
152
153 const SOURCE_URL: &str = "sqlite://source.db";
159
160 async fn seeded_source() -> Arc<Database> {
162 let database = Arc::new(Database::connect_in_memory().await.unwrap());
163 fixtures::seed_every_table(&database).await;
164 database
165 }
166
167 async fn migrated_target(dir: &TempDir, name: &str) -> String {
174 let url = format!("sqlite://{}", dir.join(name).display());
175 let database = Database::connect_and_migrate(&url)
176 .await
177 .expect("a fresh SQLite file migrates");
178 database.close().await;
179 url
180 }
181
182 async fn counts(url: &str) -> Vec<(&'static str, u64)> {
184 let database = Database::open(url).await.expect("the target reopens");
185 let counts = fixtures::row_counts(&database).await;
186 database.close().await;
187 counts
188 }
189
190 #[tokio::test]
191 async fn the_same_url_twice_is_refused_before_anything_is_opened() {
192 let database = Arc::new(Database::connect_in_memory().await.unwrap());
193
194 let error =
195 run_transfer_command(SOURCE_URL, false, true, &mut &b""[..], SOURCE_URL, database)
196 .await
197 .expect_err("a database is not copied into itself");
198
199 assert_eq!(error.kind(), CliErrorKind::BadRequest);
200 assert!(error.message.contains("the same database"), "{error}");
201 }
202
203 #[tokio::test]
204 async fn an_unopenable_target_is_refused_by_name() {
205 let database = Arc::new(Database::connect_in_memory().await.unwrap());
206
207 let error = run_transfer_command(
208 "mysql://acme@db.internal/acme",
209 false,
210 true,
211 &mut &b""[..],
212 SOURCE_URL,
213 database,
214 )
215 .await
216 .expect_err("a scheme this server does not speak");
217
218 assert_eq!(error.kind(), CliErrorKind::Failed);
219 assert!(error.message.contains("cannot open the target"), "{error}");
220 }
221
222 #[tokio::test]
231 async fn an_unmigrated_target_is_refused_and_says_what_to_run() {
232 let dir = TempDir::new("transfer-unmigrated");
233 let url = format!("sqlite://{}", dir.join("target.db").display());
234 let database = Arc::new(Database::connect_in_memory().await.unwrap());
235
236 let error = run_transfer_command(&url, false, true, &mut &b""[..], SOURCE_URL, database)
237 .await
238 .expect_err("an empty file is not a migrated database");
239
240 assert_eq!(error.kind(), CliErrorKind::BadRequest);
241 assert!(error.message.contains("migration(s) behind"), "{error}");
242 assert!(error.message.contains("acme-proxy migrate"), "{error}");
243 }
244
245 #[tokio::test]
250 async fn a_target_that_holds_rows_is_refused_and_lists_them() {
251 let dir = TempDir::new("transfer-occupied");
252 let url = migrated_target(&dir, "target.db").await;
253 let occupied = Arc::new(Database::open(&url).await.unwrap());
254 fixtures::seed_every_table(&occupied).await;
255 occupied.close().await;
256
257 let error = run_transfer_command(
258 &url,
259 false,
260 true,
261 &mut &b""[..],
262 SOURCE_URL,
263 seeded_source().await,
264 )
265 .await
266 .expect_err("a copy into a populated database is not a merge");
267
268 assert_eq!(error.kind(), CliErrorKind::BadRequest);
269 assert!(error.message.contains("already holds rows"), "{error}");
270 assert!(error.message.contains("accounts"), "{error}");
271 }
272
273 #[tokio::test]
280 async fn an_empty_source_copies_nothing() {
281 let dir = TempDir::new("transfer-empty");
282 let url = migrated_target(&dir, "target.db").await;
283 let database = Arc::new(Database::connect_in_memory().await.unwrap());
284
285 run_transfer_command(&url, false, false, &mut &b""[..], SOURCE_URL, database)
286 .await
287 .expect("nothing to copy is not a failure");
288
289 assert!(
290 counts(&url).await.iter().all(|(_, rows)| *rows == 0),
291 "the target is untouched"
292 );
293 }
294
295 #[tokio::test]
296 async fn a_declined_prompt_copies_nothing_and_writes_no_audit_row() {
297 let dir = TempDir::new("transfer-declined");
298 let url = migrated_target(&dir, "target.db").await;
299 let source = seeded_source().await;
300 let before = AuditEntry::search(&AuditQuery::default(), &source)
301 .await
302 .unwrap()
303 .1;
304
305 run_transfer_command(
306 &url,
307 false,
308 false,
309 &mut b"n\n".as_slice(),
310 SOURCE_URL,
311 source.clone(),
312 )
313 .await
314 .expect("a decline is not a failure");
315
316 assert!(
317 counts(&url).await.iter().all(|(_, rows)| *rows == 0),
318 "a decline copies nothing"
319 );
320 assert_eq!(
321 AuditEntry::search(&AuditQuery::default(), &source)
322 .await
323 .unwrap()
324 .1,
325 before,
326 "a declined transfer is not an administrative action"
327 );
328 }
329
330 #[tokio::test]
337 async fn a_confirmed_transfer_copies_every_row_and_records_it_on_the_source() {
338 let dir = TempDir::new("transfer-confirmed");
339 let url = migrated_target(&dir, "target.db").await;
340 let source = seeded_source().await;
341 let before = fixtures::row_counts(&source).await;
342
343 run_transfer_command(
344 &url,
345 false,
346 false,
347 &mut b"y\n".as_slice(),
348 SOURCE_URL,
349 source.clone(),
350 )
351 .await
352 .expect("the copy should succeed");
353
354 assert_eq!(
355 counts(&url).await,
356 before,
357 "the target holds what was there"
358 );
359
360 let (rows, _) = AuditEntry::search(
361 &AuditQuery {
362 limit: 10,
363 ..AuditQuery::default()
364 },
365 &source,
366 )
367 .await
368 .unwrap();
369 let recorded = rows
370 .iter()
371 .find(|row| row.event == "database_transferred")
372 .expect("moving every row is an administrative action");
373 assert_eq!(recorded.actor_kind, "cli");
374 }
375
376 #[tokio::test]
379 async fn both_output_modes_report_the_same_copy() {
380 let dir = TempDir::new("transfer-output");
381
382 for (index, json) in [true, false].into_iter().enumerate() {
383 let url = migrated_target(&dir, &format!("target-{index}.db")).await;
384 let source = seeded_source().await;
385 let before = fixtures::row_counts(&source).await;
386
387 run_transfer_command(&url, json, true, &mut &b""[..], SOURCE_URL, source)
388 .await
389 .expect("the copy should succeed");
390
391 assert_eq!(counts(&url).await, before, "json = {json}");
392 }
393 }
394
395 #[test]
396 fn a_url_pasted_twice_is_the_same_database() {
397 assert!(same_database("sqlite://acme.db", "sqlite://acme.db"));
398 assert!(same_database("postgres://a@h/db", "postgres://a@h/db/"));
399 }
400
401 #[test]
403 fn the_two_backends_are_not_the_same_database() {
404 assert!(!same_database(
405 "sqlite://acme.db",
406 "postgres://acme@db.internal/acme"
407 ));
408 assert!(!same_database("sqlite://a.db", "sqlite://b.db"));
409 }
410}