Skip to main content

boatramp_node/
pg_client.rs

1//! Pure builders for the **SQL export/import client invocation** (Postgres + MySQL) that
2//! the driver runs inside
3//! [`run_ephemeral`](boatramp_core::compute::ComputeBackend::run_ephemeral): the `argv`
4//! and the process environment.
5//!
6//! ## The credential non-leak rule lives here
7//! The password rides in an **environment variable** (`PGPASSWORD` for Postgres,
8//! `MYSQL_PWD` for MySQL), NEVER in argv. Both argv and the container environment are
9//! visible to a privileged `docker inspect`, but argv leaks far more broadly — it shows
10//! in every process listing (`ps`, `/proc/<pid>/cmdline`) inside and outside the
11//! container, to any local observer, no daemon privilege required — whereas the
12//! container environment is only reachable with daemon/inspect access. Env is therefore
13//! the smaller exposure (not zero), and it is the right choice over argv. No DSN string
14//! is assembled at all: the discrete `PG*` / `MYSQL*` connection variables connect the
15//! client, so there is no connection string to accidentally `format!` into a log. The
16//! only secret is the password env var.
17//!
18//! ## `--data-only` vs full
19//! `--data-only` emits/restores ONLY the table data; the target keeps its OWN migrated
20//! schema (Phase 1 is data-only: app data, no identity/RBAC/routing). A full export
21//! carries schema + data. `--no-owner`/`--no-privileges` keep a restore from trying to
22//! reassign ownership/ACLs across environments, and `pg_restore --exit-on-error` makes a
23//! failed object abort loudly rather than leave a half-restored database.
24//!
25//! Kept pure so the data-only divergence and the cred-placement invariant are
26//! unit-mutation-gated in the merge gate.
27
28/// A resolved Postgres connection target for the client. Assembled by the driver from
29/// the sealed-credential unseal + the resolved replica endpoint; consumed only to emit
30/// the `PG*` environment.
31#[derive(Debug, Clone)]
32pub struct PgConnection {
33    /// The reachable host (an external DSN host, or a colocated DB's container bridge IP).
34    pub host: String,
35    /// The server port.
36    pub port: u16,
37    /// The role to connect as.
38    pub user: String,
39    /// The password — the ONLY secret; it goes in `PGPASSWORD`, never argv.
40    pub password: String,
41    /// The physical database to dump/restore.
42    pub database: String,
43    /// A bounded connect timeout (seconds) so a dump/restore against an unreachable DSN
44    /// fails fast instead of pinning the ephemeral container until the backstop.
45    pub connect_timeout_secs: u32,
46}
47
48impl PgConnection {
49    /// The discrete `PG*` connection environment. The password is here (in the
50    /// environment), never in argv.
51    pub fn env(&self) -> Vec<(String, String)> {
52        vec![
53            ("PGHOST".to_string(), self.host.clone()),
54            ("PGPORT".to_string(), self.port.to_string()),
55            ("PGUSER".to_string(), self.user.clone()),
56            ("PGPASSWORD".to_string(), self.password.clone()),
57            ("PGDATABASE".to_string(), self.database.clone()),
58            (
59                "PGCONNECT_TIMEOUT".to_string(),
60                self.connect_timeout_secs.to_string(),
61            ),
62        ]
63    }
64}
65
66/// A fully-built client invocation: the `argv` (no credential) + the environment (the
67/// credential), ready to hand to `run_ephemeral(image, argv, env, …)`.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct SqlInvocation {
70    /// The command + args — NEVER carries a credential.
71    pub argv: Vec<String>,
72    /// The process environment — carries `PGPASSWORD`.
73    pub env: Vec<(String, String)>,
74}
75
76/// Build the `pg_dump` invocation for an EXPORT. Custom format (`-Fc`): compressed and
77/// the natural input to `pg_restore`. `--data-only` emits only the data AND EXCLUDES the
78/// host-owned migration ledger's rows (`boatramp_migrations.*`): the ledger is a schema
79/// INSIDE the tenant database, so a plain `--data-only` dump would carry its rows and a
80/// `pg_restore --data-only --exit-on-error` into the already-migrated target (the
81/// matching-fingerprint case — the PRIMARY use) would hit a primary-key conflict on the
82/// ledger and abort. Excluding it keeps the target's own `boatramp_migrations` ledger
83/// intact (the stated property).
84pub fn pg_dump_invocation(conn: &PgConnection, data_only: bool) -> SqlInvocation {
85    let mut argv = vec![
86        "pg_dump".to_string(),
87        "--no-owner".to_string(),
88        "--no-privileges".to_string(),
89        "-Fc".to_string(),
90    ];
91    if data_only {
92        argv.push("--data-only".to_string());
93        // Exclude the host-owned migration ledger's DATA (schema-qualified pattern) so
94        // the restore does not collide with the target's existing ledger rows.
95        argv.push("--exclude-table-data=boatramp_migrations.*".to_string());
96    }
97    SqlInvocation {
98        argv,
99        env: conn.env(),
100    }
101}
102
103/// Build the `pg_restore` invocation for an IMPORT (reads the custom-format dump from
104/// stdin). `--data-only` restores only the data into the target's existing migrated
105/// schema; `--exit-on-error` aborts loudly on the first failed object. Unlike `psql`/
106/// `pg_dump`, `pg_restore` does NOT infer the target from `PGDATABASE` — without `-d`
107/// (restore into a database) or `-f` (emit a script) it refuses with "one of -d/--dbname
108/// and -f/--file must be specified"; so the target database is named with `-d` (the host/
109/// port/user/password still come from the env, and the db name is not a credential).
110pub fn pg_restore_invocation(conn: &PgConnection, data_only: bool) -> SqlInvocation {
111    let mut argv = vec![
112        "pg_restore".to_string(),
113        "--no-owner".to_string(),
114        "--no-privileges".to_string(),
115        "--exit-on-error".to_string(),
116        "-d".to_string(),
117        conn.database.clone(),
118    ];
119    if data_only {
120        argv.push("--data-only".to_string());
121    }
122    SqlInvocation {
123        argv,
124        env: conn.env(),
125    }
126}
127
128/// A resolved MySQL connection target for the client. The password goes in `MYSQL_PWD`
129/// (the env the mysql/mysqldump clients read), never argv.
130#[derive(Debug, Clone)]
131pub struct MysqlConnection {
132    /// The reachable host.
133    pub host: String,
134    /// The server port.
135    pub port: u16,
136    /// The role to connect as.
137    pub user: String,
138    /// The password — the ONLY secret; it goes in `MYSQL_PWD`, never argv.
139    pub password: String,
140    /// The physical database to dump/restore.
141    pub database: String,
142    /// A bounded connect timeout (seconds).
143    pub connect_timeout_secs: u32,
144}
145
146impl MysqlConnection {
147    /// The connection ENVIRONMENT: `MYSQL_PWD` (the password) ONLY. The password is the
148    /// sole secret and goes in the env every MySQL client reads, never argv. Host/port/
149    /// user are NOT placed here — unlike libpq's `PGHOST` (honored by all libpq tools),
150    /// `MYSQL_HOST` is read by the `mysql` CLI ALONE; `mysqldump` ignores it and defaults
151    /// to the local unix socket ("Can't connect to local MySQL server through socket"),
152    /// so the host/port/user travel in argv via [`conn_args`](Self::conn_args).
153    pub fn env(&self) -> Vec<(String, String)> {
154        vec![("MYSQL_PWD".to_string(), self.password.clone())]
155    }
156
157    /// The non-secret connection args every MySQL client (both `mysql` and `mysqldump`)
158    /// honors: `--host`, `--port`, `--user`. A real host IP (not "localhost") forces TCP
159    /// rather than the local socket. None of these is a credential, so argv is correct;
160    /// no DSN string is assembled.
161    pub fn conn_args(&self) -> Vec<String> {
162        vec![
163            format!("--host={}", self.host),
164            format!("--port={}", self.port),
165            format!("--user={}", self.user),
166        ]
167    }
168
169    /// The `--connect-timeout=<secs>` arg the `mysql` client accepts (there is no env for
170    /// it). NOTE: `mysqldump` does NOT register this option — passing it there fails with
171    /// "[ERROR] unknown variable 'connect-timeout=10'" — so only `mysql_restore_invocation`
172    /// uses it, never `mysqldump_invocation`.
173    fn connect_timeout_arg(&self) -> String {
174        format!("--connect-timeout={}", self.connect_timeout_secs)
175    }
176}
177
178/// Build the `mysqldump` invocation for an EXPORT. `--single-transaction` takes a
179/// consistent snapshot without locking (InnoDB); `--no-tablespaces` avoids the `PROCESS`
180/// privilege requirement. Host/port/user are explicit ([`conn_args`](MysqlConnection::conn_args))
181/// because `mysqldump` ignores `MYSQL_HOST`. A FULL dump emits schema + data under
182/// `--databases <db>` (so the stream carries `CREATE DATABASE`/`USE`); `--data-only` emits
183/// only the rows for `<db>` (`--no-create-info`), restored into the target's existing
184/// schema. No `--connect-timeout`: `mysqldump` does not register it (unknown variable);
185/// the overall ephemeral-run timeout bounds a stuck dump instead.
186pub fn mysqldump_invocation(conn: &MysqlConnection, data_only: bool) -> SqlInvocation {
187    let mut argv = vec![
188        "mysqldump".to_string(),
189        "--single-transaction".to_string(),
190        "--no-tablespaces".to_string(),
191    ];
192    argv.extend(conn.conn_args());
193    if data_only {
194        argv.push("--no-create-info".to_string());
195        argv.push(conn.database.clone());
196    } else {
197        argv.push("--databases".to_string());
198        argv.push(conn.database.clone());
199    }
200    SqlInvocation {
201        argv,
202        env: conn.env(),
203    }
204}
205
206/// Build the `mysql` invocation for an IMPORT (reads the SQL dump from stdin). Host/port/
207/// user are explicit ([`conn_args`](MysqlConnection::conn_args)) so the client connects
208/// over TCP, not the local socket. A FULL bundle's dump carries its own `USE <db>` (from
209/// `--databases`), so no positional db is needed; a `--data-only` dump has none, so the
210/// target `<db>` is named on the command line. The database name is NOT a credential, so
211/// passing it in argv is fine.
212pub fn mysql_restore_invocation(conn: &MysqlConnection, data_only: bool) -> SqlInvocation {
213    let mut argv = vec!["mysql".to_string()];
214    argv.extend(conn.conn_args());
215    argv.push(conn.connect_timeout_arg());
216    if data_only {
217        argv.push(conn.database.clone());
218    }
219    SqlInvocation {
220        argv,
221        env: conn.env(),
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn conn() -> PgConnection {
230        PgConnection {
231            host: "10.0.0.5".to_string(),
232            port: 5432,
233            user: "svc".to_string(),
234            password: "sup3r-s3cret-pw".to_string(),
235            database: "app".to_string(),
236            connect_timeout_secs: 10,
237        }
238    }
239
240    #[test]
241    fn data_only_toggles_the_flag_on_both_sides() {
242        // The data-only path MUST diverge from the full path (anti-hollow mutation (b):
243        // drop the data_only honoring and this divergence vanishes).
244        let dump_do = pg_dump_invocation(&conn(), true);
245        let dump_full = pg_dump_invocation(&conn(), false);
246        assert!(dump_do.argv.iter().any(|a| a == "--data-only"));
247        assert!(!dump_full.argv.iter().any(|a| a == "--data-only"));
248        // A --data-only dump EXCLUDES the migration ledger's rows so a restore into an
249        // already-migrated target does not PK-conflict on it; a full dump does not.
250        assert!(
251            dump_do
252                .argv
253                .iter()
254                .any(|a| a == "--exclude-table-data=boatramp_migrations.*")
255        );
256        assert!(
257            !dump_full
258                .argv
259                .iter()
260                .any(|a| a == "--exclude-table-data=boatramp_migrations.*")
261        );
262
263        let restore_do = pg_restore_invocation(&conn(), true);
264        let restore_full = pg_restore_invocation(&conn(), false);
265        assert!(restore_do.argv.iter().any(|a| a == "--data-only"));
266        assert!(!restore_full.argv.iter().any(|a| a == "--data-only"));
267        // pg_restore needs an explicit `-d <db>` target (it does NOT read PGDATABASE);
268        // without it the restore aborts "one of -d/--dbname and -f/--file must be
269        // specified". The db name follows the `-d` flag as its own argv element.
270        for inv in [&restore_do, &restore_full] {
271            let pos = inv
272                .argv
273                .iter()
274                .position(|a| a == "-d")
275                .expect("pg_restore argv must carry -d");
276            assert_eq!(
277                inv.argv.get(pos + 1).map(String::as_str),
278                Some(conn().database.as_str()),
279                "the target database must follow -d"
280            );
281        }
282    }
283
284    #[test]
285    fn credential_is_in_the_env_never_in_argv() {
286        let c = conn();
287        for inv in [
288            pg_dump_invocation(&c, true),
289            pg_restore_invocation(&c, false),
290        ] {
291            // The password is present in the environment...
292            assert!(
293                inv.env
294                    .iter()
295                    .any(|(k, v)| k == "PGPASSWORD" && v == &c.password),
296                "PGPASSWORD must be set in the environment"
297            );
298            // ...and NEVER appears in argv (which shows in ps/inspect). This is the
299            // non-leak invariant; a regression that put the password on the command
300            // line fails here.
301            for a in &inv.argv {
302                assert!(
303                    !a.contains(&c.password),
304                    "credential leaked into argv: {a:?}"
305                );
306            }
307            // No connection string is assembled at all — no `postgres://` in argv.
308            for a in &inv.argv {
309                assert!(
310                    !a.contains("://"),
311                    "a DSN must never be built into argv: {a:?}"
312                );
313            }
314        }
315    }
316
317    #[test]
318    fn env_carries_the_discrete_connection_and_a_bounded_timeout() {
319        let e = conn().env();
320        let get = |k: &str| e.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
321        assert_eq!(get("PGHOST").as_deref(), Some("10.0.0.5"));
322        assert_eq!(get("PGPORT").as_deref(), Some("5432"));
323        assert_eq!(get("PGUSER").as_deref(), Some("svc"));
324        assert_eq!(get("PGDATABASE").as_deref(), Some("app"));
325        // A bounded connect timeout so an unreachable DSN fails fast.
326        assert_eq!(get("PGCONNECT_TIMEOUT").as_deref(), Some("10"));
327    }
328
329    fn myconn() -> MysqlConnection {
330        MysqlConnection {
331            host: "10.0.0.6".to_string(),
332            port: 3306,
333            user: "svc".to_string(),
334            password: "sup3r-s3cret-pw".to_string(),
335            database: "app".to_string(),
336            connect_timeout_secs: 10,
337        }
338    }
339
340    #[test]
341    fn mysql_data_only_toggles_and_credential_stays_in_env() {
342        // data-only diverges: dump uses --no-create-info (no --databases); full uses
343        // --databases; restore names the db only for data-only.
344        let dump_do = mysqldump_invocation(&myconn(), true);
345        let dump_full = mysqldump_invocation(&myconn(), false);
346        assert!(dump_do.argv.iter().any(|a| a == "--no-create-info"));
347        assert!(!dump_full.argv.iter().any(|a| a == "--no-create-info"));
348        assert!(dump_full.argv.iter().any(|a| a == "--databases"));
349        assert!(!dump_do.argv.iter().any(|a| a == "--databases"));
350        // mysqldump does NOT register --connect-timeout (it fails with "unknown variable");
351        // it must never appear on a dump invocation, but the mysql restore client DOES
352        // accept it.
353        for d in [&dump_do, &dump_full] {
354            assert!(
355                !d.argv.iter().any(|a| a.starts_with("--connect-timeout")),
356                "mysqldump must not carry --connect-timeout"
357            );
358        }
359
360        let restore_do = mysql_restore_invocation(&myconn(), true);
361        let restore_full = mysql_restore_invocation(&myconn(), false);
362        assert!(
363            restore_full
364                .argv
365                .iter()
366                .any(|a| a.starts_with("--connect-timeout")),
367            "the mysql restore client should carry --connect-timeout"
368        );
369        assert!(restore_do.argv.iter().any(|a| a == "app"));
370        assert!(!restore_full.argv.iter().any(|a| a == "app"));
371
372        // The password is in MYSQL_PWD (env), never argv; no DSN in argv.
373        let c = myconn();
374        for inv in [
375            mysqldump_invocation(&c, true),
376            mysql_restore_invocation(&c, false),
377        ] {
378            assert!(
379                inv.env
380                    .iter()
381                    .any(|(k, v)| k == "MYSQL_PWD" && v == &c.password),
382                "MYSQL_PWD must be set in the environment"
383            );
384            // Host must be explicit in argv: mysqldump ignores MYSQL_HOST and would hit
385            // the local socket (error 2002) without it.
386            assert!(
387                inv.argv.iter().any(|a| a == &format!("--host={}", c.host)),
388                "the connection host must be explicit in argv: {:?}",
389                inv.argv
390            );
391            for a in &inv.argv {
392                assert!(
393                    !a.contains(&c.password),
394                    "credential leaked into argv: {a:?}"
395                );
396                assert!(
397                    !a.contains("://"),
398                    "a DSN must never be built into argv: {a:?}"
399                );
400            }
401        }
402    }
403
404    #[test]
405    fn mysql_env_carries_the_discrete_connection() {
406        let c = myconn();
407        // The ENV carries ONLY the password (the sole secret) — mysqldump ignores
408        // MYSQL_HOST, so host/port/user must NOT be relied on via env.
409        let e = c.env();
410        assert_eq!(e.len(), 1, "env carries only MYSQL_PWD");
411        assert_eq!(e[0].0, "MYSQL_PWD");
412        assert_eq!(e[0].1, "sup3r-s3cret-pw");
413        // Host/port/user travel in argv (so both mysql and mysqldump connect over TCP),
414        // and never the password.
415        let a = c.conn_args();
416        assert!(a.iter().any(|x| x == "--host=10.0.0.6"));
417        assert!(a.iter().any(|x| x == "--port=3306"));
418        assert!(a.iter().any(|x| x == "--user=svc"));
419        assert!(
420            !a.iter().any(|x| x.contains("sup3r-s3cret-pw")),
421            "password never in conn_args"
422        );
423    }
424}