Skip to main content

acme_proxy/cli/
transfer.rs

1//! `acme-proxy transfer --to <url>` — copy every row into the other backend.
2//!
3//! The move an operator makes once: a SQLite file that has outgrown one host,
4//! into the PostgreSQL cluster the three roles can then be spread across. The
5//! direction is not a flag — the configured `database.url` is the source, the
6//! `--to` URL is the target, and each one's scheme says which backend it is,
7//! so the reverse is the same command with the two swapped.
8//!
9//! Everything about *how* the copy works is
10//! [`acme_proxy_store::transfer`]'s business. What lives here is the four
11//! things that have to be true before it starts, each refused by name:
12//!
13//! - the target is reachable and its schema is current — this command does not
14//!   migrate it, because applying a schema belongs to `migrate`/`init` and the
15//!   `worker` role and nothing else ([ADR 0003]);
16//! - the target is empty, with **no override**: a copy into a database that
17//!   already holds rows is not a merge, and the `INSERT`s would collide
18//!   halfway through on whichever primary key happened to clash first;
19//! - the two URLs are not the same database;
20//! - and the operator has been told the one thing no check can establish —
21//!   **the source server must be stopped**. A worker mid-issuance writes rows
22//!   the copy has already walked past, and a torn snapshot looks exactly like
23//!   a good one.
24//!
25//! [ADR 0003]: ../../doc/src/dev/adr/0003-migrations-frozen-and-explicit.md
26
27use 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    // Not migrated here on purpose; see the module doc.
61    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
134/// Are these two URLs the same database?
135///
136/// A best-effort string comparison, and deliberately not more: resolving
137/// whether two DSNs reach one cluster means a connection and a round trip, and
138/// the case this actually catches is the operator who pasted the same URL
139/// twice. A copy into itself would otherwise fail on the first primary key,
140/// after the prompt has already said it was about to move every row.
141fn 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    /// The `database.url` the command is told the source is.
154    ///
155    /// Only [`same_database`] and the two messages read it — the source itself
156    /// arrives as an already-open handle — so it does not have to name the
157    /// in-memory database the tests actually pass, and cannot.
158    const SOURCE_URL: &str = "sqlite://source.db";
159
160    /// A source holding one row in every table the manifest names.
161    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    /// A migrated, empty SQLite file, and the URL that names it.
168    ///
169    /// A file rather than `sqlite::memory:`, because the command opens the
170    /// target itself from the string: an in-memory URL has no `://` for
171    /// `Database::open` to route on, and would hand every connection its own
172    /// empty database even if it had one.
173    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    /// Every table of the database at `url`, by name and row count.
183    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    /// The target is not migrated here, and the refusal says who does it.
223    ///
224    /// `Database::open` creates a missing SQLite file but applies nothing, so
225    /// a `--to` naming a path that does not exist yet lands here rather than
226    /// quietly gaining a schema from the command that was asked to copy rows
227    /// ([ADR 0003]).
228    ///
229    /// [ADR 0003]: ../../doc/src/dev/adr/0003-migrations-frozen-and-explicit.md
230    #[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    /// A target holding rows is refused, and the refusal names them.
246    ///
247    /// "The target is not empty" is not something an operator can act on;
248    /// "`accounts` (2 row(s))" is. There is no flag that overrides this.
249    #[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    /// An empty source stops before the prompt.
274    ///
275    /// Asserted through `yes = false` and a reader at EOF: `confirm` reads EOF
276    /// as a decline, so if this returned by way of the prompt it would still
277    /// be `Ok(())` — but the early return is what makes it one without ever
278    /// asking a question about zero rows.
279    #[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    /// The copy, and the row it leaves behind on the source.
331    ///
332    /// The counts are taken before the call on purpose: `record_cli_action`
333    /// writes `database_transferred` to the **source** once the copy is done,
334    /// so comparing the two databases afterwards would report `audit_log` off
335    /// by one and be right.
336    #[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    /// Both output modes copy the same thing, which is all a test can say
377    /// about them: nothing here captures stdout.
378    #[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    /// The case the whole command exists for, which must not be refused.
402    #[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}