1
//! A throwaway Postgres for the lifetime of one command.
2
//!
3
//! Replaces the `sqlx-cli` shell-out: the database is created and migrated
4
//! in-process through `sqlx`, using the *same* `migrations/` directory the
5
//! server embeds via `server::db::MIGRATOR`. One set of migrations, one
6
//! migrator, no second implementation that can drift.
7
//!
8
//! `initdb` and `pg_ctl` remain external programs because they are external
9
//! programs; there is no library form of them.
10

            
11
use std::path::{Path, PathBuf};
12
use std::process::Command;
13

            
14
use anyhow::{Context, Result, bail};
15
use sqlx::migrate::Migrator;
16

            
17
/// The project's migrations, embedded at compile time from the same directory
18
/// `server::db::MIGRATOR` reads. Embedding needs no database, which is what
19
/// lets this binary be built before the server it goes on to test.
20
static MIGRATOR: Migrator = sqlx::migrate!("../migrations");
21

            
22
/// Ports tried in order. No allocator to lean on, and `pg_ctl -w` exits
23
/// non-zero when the server fails to come up, so a busy port is self-correcting.
24
const PORT_RANGE: std::ops::Range<u16> = 54320..54340;
25

            
26
/// Bounds on how long the server will let a client misbehave.
27
///
28
/// NOT a fix for the hang seen on 2026-08-28, and worth saying so: there the
29
/// server sat `idle / ClientRead` — it had answered and was waiting for the
30
/// client, so there was no statement to time out. These bound the failures they
31
/// can bound: a runaway query, and a transaction abandoned mid-flight holding
32
/// locks. Both otherwise sit until the job's own deadline kills it, an hour
33
/// later, with no indication of which test was responsible.
34
const TIMEOUTS: &[&str] = &[
35
    "-c",
36
    "statement_timeout=120s",
37
    "-c",
38
    "idle_in_transaction_session_timeout=300s",
39
];
40

            
41
/// Durability is off on purpose. This cluster is initdb'd moments earlier and
42
/// dies with the container, so there is nothing for it to protect — every fsync
43
/// is pure cost, paid on cloud block storage, and 275 of the tests create and
44
/// migrate a database of their own. Measured worth ~2m30s of a test run.
45
const DURABILITY_OFF: &[&str] = &[
46
    "-c",
47
    "fsync=off",
48
    "-c",
49
    "synchronous_commit=off",
50
    "-c",
51
    "full_page_writes=off",
52
    "-c",
53
    "autovacuum=off",
54
];
55

            
56
/// A running cluster that stops itself when dropped.
57
pub struct Postgres {
58
    data_dir: PathBuf,
59
    port: u16,
60
    /// Kept so the temp dir outlives the cluster.
61
    _root: tempfile::TempDir,
62
}
63

            
64
impl Postgres {
65
    pub fn database_url(&self) -> String {
66
        format!("postgres://postgres@127.0.0.1:{}/nomisync", self.port)
67
    }
68
}
69

            
70
impl Drop for Postgres {
71
    fn drop(&mut self) {
72
        // Best effort: the container is about to vanish anyway, but leaving a
73
        // server holding a port breaks a second invocation in the same job.
74
        let _ = Command::new("pg_ctl")
75
            .args([
76
                "-D",
77
                &self.data_dir.to_string_lossy(),
78
                "-m",
79
                "immediate",
80
                "stop",
81
            ])
82
            .status();
83
    }
84
}
85

            
86
fn run(program: &str, args: &[&str]) -> Result<()> {
87
    let status = Command::new(program)
88
        .args(args)
89
        .status()
90
        .with_context(|| format!("failed to run {program}"))?;
91
    if !status.success() {
92
        bail!("{program} exited with {status}");
93
    }
94
    Ok(())
95
}
96

            
97
/// initdb, start, create the database, migrate it.
98
pub async fn start() -> Result<Postgres> {
99
    // initdb refuses to run as root, and under a uid with no passwd entry it
100
    // dies inside getpwuid — the builder image supplies a named non-root user.
101
    let root =
102
        tempfile::TempDir::with_prefix("nomisync-ci-pg").context("could not make a temp dir")?;
103
    let data_dir = root.path().join("pgdata");
104
    let socket_dir = data_dir.join("sockets");
105

            
106
    run(
107
        "initdb",
108
        &[
109
            "-D",
110
            &data_dir.to_string_lossy(),
111
            "-U",
112
            "postgres",
113
            "--auth=trust",
114
        ],
115
    )
116
    .context("initdb failed")?;
117
    std::fs::create_dir_all(&socket_dir).context("could not make the socket dir")?;
118

            
119
    let log = root.path().join("postgres.log");
120
    let port = start_on_free_port(&data_dir, &socket_dir, &log)?;
121
    let postgres = Postgres {
122
        data_dir,
123
        port,
124
        _root: root,
125
    };
126

            
127
    println!(">> postgres: listening on 127.0.0.1:{port}");
128
    create_and_migrate(&postgres).await?;
129
    Ok(postgres)
130
}
131

            
132
fn start_on_free_port(data_dir: &Path, socket_dir: &Path, log: &Path) -> Result<u16> {
133
    for port in PORT_RANGE {
134
        // The socket directory lives inside PGDATA: a shared /tmp is not
135
        // writable the same way in every container, and its lock file is where
136
        // that first shows up.
137
        let options = format!(
138
            "-p {port} -c listen_addresses=127.0.0.1 -c unix_socket_directories={} {}",
139
            socket_dir.display(),
140
            [DURABILITY_OFF, TIMEOUTS].concat().join(" ")
141
        );
142
        let status = Command::new("pg_ctl")
143
            .args([
144
                "-D",
145
                &data_dir.to_string_lossy(),
146
                "-l",
147
                &log.to_string_lossy(),
148
                "-w",
149
                "-o",
150
                &options,
151
                "start",
152
            ])
153
            .status()
154
            .context("failed to run pg_ctl")?;
155
        if status.success() {
156
            return Ok(port);
157
        }
158
    }
159
    bail!("no free port in {PORT_RANGE:?} accepted a postgres server")
160
}
161

            
162
async fn create_and_migrate(postgres: &Postgres) -> Result<()> {
163
    use sqlx::{Connection, Executor, PgConnection};
164

            
165
    let admin = format!("postgres://postgres@127.0.0.1:{}/postgres", postgres.port);
166
    let mut conn = PgConnection::connect(&admin)
167
        .await
168
        .context("could not connect to the new cluster")?;
169
    conn.execute("CREATE DATABASE nomisync")
170
        .await
171
        .context("could not create the nomisync database")?;
172
    conn.close().await.ok();
173

            
174
    let mut conn = PgConnection::connect(&postgres.database_url())
175
        .await
176
        .context("could not connect to the nomisync database")?;
177
    MIGRATOR.run(&mut conn).await.context("migrations failed")?;
178
    conn.close().await.ok();
179

            
180
    println!(
181
        ">> postgres: migrated ({} migrations)",
182
        MIGRATOR.iter().count()
183
    );
184
    Ok(())
185
}