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}