Skip to main content

isb_apps/app/
database.rs

1//! Databases: apps whose source is an engine and a version.
2//!
3//! A database is an ordinary app (`source: {database: {engine, version}}`)
4//! rendered into its project environment's stack like any other, so other
5//! apps reach it by service name (`<db>.<project>-<env>`). What the engine
6//! adds over an image app: the official image pinned by version, a named
7//! volume for its data, a health check, credentials generated on create and
8//! kept as org secrets (delivered as `{secret: NAME}` environment), the
9//! native dump and restore commands backups run inside its instance, and
10//! connection details that name the password secret, never its value.
11//!
12//! Databases always roll out stop-first (they have a volume) and run one
13//! replica: two writers on one data directory corrupt it.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use super::{AppSpec, EnvValue};
21use crate::error::{Error, Result};
22
23/// A database engine.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Engine {
27    Postgres,
28    Mysql,
29    Mariadb,
30    #[serde(alias = "mongo")]
31    Mongodb,
32    Redis,
33}
34
35pub const ENGINES: [Engine; 5] = [
36    Engine::Postgres,
37    Engine::Mysql,
38    Engine::Mariadb,
39    Engine::Mongodb,
40    Engine::Redis,
41];
42
43/// The volume a database keeps its data in (`<stack>_<db>_data`).
44pub const DATA_VOLUME: &str = "data";
45
46impl Engine {
47    pub fn name(self) -> &'static str {
48        match self {
49            Engine::Postgres => "postgres",
50            Engine::Mysql => "mysql",
51            Engine::Mariadb => "mariadb",
52            Engine::Mongodb => "mongodb",
53            Engine::Redis => "redis",
54        }
55    }
56
57    pub fn parse(s: &str) -> Result<Engine> {
58        match s.to_ascii_lowercase().as_str() {
59            "postgres" | "postgresql" | "pg" => Ok(Engine::Postgres),
60            "mysql" => Ok(Engine::Mysql),
61            "mariadb" => Ok(Engine::Mariadb),
62            "mongodb" | "mongo" => Ok(Engine::Mongodb),
63            "redis" => Ok(Engine::Redis),
64            _ => Err(Error::invalid(format!(
65                "engine {s:?}: postgres, mysql, mariadb, mongodb or redis"
66            ))),
67        }
68    }
69
70    /// The version a new database gets.
71    pub fn default_version(self) -> &'static str {
72        match self {
73            Engine::Postgres => "17",
74            Engine::Mysql => "8.4",
75            Engine::Mariadb => "11.4",
76            Engine::Mongodb => "8.0",
77            Engine::Redis => "7.4",
78        }
79    }
80
81    /// The official image's repository on Docker Hub.
82    fn repository(self) -> &'static str {
83        match self {
84            Engine::Mongodb => "mongo",
85            e => e.name(),
86        }
87    }
88
89    pub fn port(self) -> u16 {
90        match self {
91            Engine::Postgres => 5432,
92            Engine::Mysql | Engine::Mariadb => 3306,
93            Engine::Mongodb => 27017,
94            Engine::Redis => 6379,
95        }
96    }
97
98    pub fn data_path(self) -> &'static str {
99        match self {
100            Engine::Postgres => "/var/lib/postgresql/data",
101            Engine::Mysql | Engine::Mariadb => "/var/lib/mysql",
102            Engine::Mongodb => "/data/db",
103            Engine::Redis => "/data",
104        }
105    }
106
107    /// Whether a root password is kept beside the user's.
108    pub fn has_root_password(self) -> bool {
109        matches!(self, Engine::Mysql | Engine::Mariadb)
110    }
111
112    /// Whether the engine has a database name and a user to set.
113    pub fn has_database(self) -> bool {
114        !matches!(self, Engine::Redis)
115    }
116
117    /// The URL scheme of a connection string.
118    fn scheme(self) -> &'static str {
119        match self {
120            Engine::Postgres => "postgres",
121            Engine::Mysql | Engine::Mariadb => "mysql",
122            Engine::Mongodb => "mongodb",
123            Engine::Redis => "redis",
124        }
125    }
126
127    /// The extension of a dump's object (before compression).
128    pub fn dump_extension(self) -> &'static str {
129        match self {
130            Engine::Postgres => "pgdump",
131            Engine::Mysql | Engine::Mariadb => "sql",
132            Engine::Mongodb => "archive",
133            Engine::Redis => "rdb",
134        }
135    }
136
137    /// Restore a dump of `from` into this engine?
138    pub fn restores_from(self, from: Engine) -> bool {
139        self == from
140            || matches!(
141                (self, from),
142                (Engine::Mysql, Engine::Mariadb) | (Engine::Mariadb, Engine::Mysql)
143            )
144    }
145
146    /// A shell line (run inside the database's instance, as root, with its
147    /// environment) that writes a dump to stdout. Credentials come from the
148    /// instance's own environment, never from argv the daemon builds.
149    pub fn dump_command(self) -> Vec<String> {
150        let line = match self {
151            Engine::Postgres => {
152                r#"PGPASSWORD="$POSTGRES_PASSWORD" exec pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom -Z0"#
153            }
154            Engine::Mysql => {
155                r#"MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -h 127.0.0.1 -uroot --single-transaction --routines --triggers --events --no-tablespaces --set-gtid-purged=OFF "$MYSQL_DATABASE""#
156            }
157            Engine::Mariadb => {
158                r#"MYSQL_PWD="$MARIADB_ROOT_PASSWORD" exec mariadb-dump -h 127.0.0.1 -uroot --single-transaction --routines --triggers --events "$MARIADB_DATABASE""#
159            }
160            Engine::Mongodb => {
161                r#"exec mongodump --quiet --archive --host 127.0.0.1 --username "$MONGO_INITDB_ROOT_USERNAME" --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --db "$MONGO_INITDB_DATABASE""#
162            }
163            // SAVE writes a consistent snapshot; then hand it over.
164            Engine::Redis => {
165                r#"redis-cli -h 127.0.0.1 --no-auth-warning SAVE >/dev/null && exec cat /data/dump.rdb"#
166            }
167        };
168        vec!["/bin/sh".into(), "-c".into(), line.into()]
169    }
170
171    /// The command that makes a new password take effect inside a running
172    /// database (`rotate`): the user's (`root: false`), or MySQL's and
173    /// MariaDB's root password. The new value arrives on stdin; the
174    /// instance's environment still holds the old one, which authenticates.
175    /// The engines read their password variables only when the data
176    /// directory is first made, so without this a new password would lock
177    /// the apps out.
178    pub fn rotate_command(self, root: bool) -> Vec<String> {
179        // MySQL string literal: backslashes and quotes doubled.
180        const MY_ESC: &str = r#"pw=$(cat | sed -e 's/\\/\\\\/g' -e "s/'/''/g")"#;
181        let line = match (self, root) {
182            (Engine::Postgres, _) => r#"pw=$(cat) && q="'" && printf 'ALTER ROLE :"u" PASSWORD :%spw%s;\n' "$q" "$q" | PGPASSWORD="$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -q -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v u="$POSTGRES_USER" -v pw="$pw""#.to_string(),
183            (Engine::Mysql, false) => format!(
184                r#"{MY_ESC} && printf "ALTER USER '%s'@'%%' IDENTIFIED BY '%s';\n" "$MYSQL_USER" "$pw" | MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -h 127.0.0.1 -uroot"#
185            ),
186            (Engine::Mysql, true) => format!(
187                r#"{MY_ESC} && printf "ALTER USER IF EXISTS 'root'@'%%' IDENTIFIED BY '%s'; ALTER USER IF EXISTS 'root'@'localhost' IDENTIFIED BY '%s';\n" "$pw" "$pw" | MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -h 127.0.0.1 -uroot"#
188            ),
189            (Engine::Mariadb, false) => format!(
190                r#"{MY_ESC} && printf "ALTER USER '%s'@'%%' IDENTIFIED BY '%s';\n" "$MARIADB_USER" "$pw" | MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb -h 127.0.0.1 -uroot"#
191            ),
192            (Engine::Mariadb, true) => format!(
193                r#"{MY_ESC} && printf "ALTER USER IF EXISTS 'root'@'%%' IDENTIFIED BY '%s'; ALTER USER IF EXISTS 'root'@'localhost' IDENTIFIED BY '%s';\n" "$pw" "$pw" | MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb -h 127.0.0.1 -uroot"#
194            ),
195            (Engine::Mongodb, _) => r#"NEW=$(cat) mongosh --quiet --host 127.0.0.1 admin -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --eval 'db.changeUserPassword(process.env.MONGO_INITDB_ROOT_USERNAME, process.env.NEW)'"#.to_string(),
196            (Engine::Redis, _) => r#"NEW=$(cat) && redis-cli -h 127.0.0.1 --no-auth-warning CONFIG SET requirepass "$NEW" | grep -q OK"#.to_string(),
197        };
198        vec!["/bin/sh".into(), "-c".into(), line]
199    }
200
201    /// A shell line that restores a dump read from stdin, replacing what
202    /// the dump holds. `ISB_SOURCE_DB` names the dumped database (MongoDB
203    /// renames it to this one's).
204    pub fn restore_command(self) -> Vec<String> {
205        let line = match self {
206            Engine::Postgres => {
207                r#"PGPASSWORD="$POSTGRES_PASSWORD" exec pg_restore -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges"#
208            }
209            Engine::Mysql => {
210                r#"MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -h 127.0.0.1 -uroot "$MYSQL_DATABASE""#
211            }
212            Engine::Mariadb => {
213                r#"MYSQL_PWD="$MARIADB_ROOT_PASSWORD" exec mariadb -h 127.0.0.1 -uroot "$MARIADB_DATABASE""#
214            }
215            Engine::Mongodb => {
216                r#"exec mongorestore --quiet --archive --drop --host 127.0.0.1 --username "$MONGO_INITDB_ROOT_USERNAME" --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --nsFrom "$ISB_SOURCE_DB.*" --nsTo "$MONGO_INITDB_DATABASE.*""#
217            }
218            // Replace the snapshot and stop the server without saving over
219            // it; the stack controller starts it again, loading the dump.
220            Engine::Redis => {
221                r#"cat > /data/dump.rdb.isb-restore && mv /data/dump.rdb.isb-restore /data/dump.rdb && chown redis:redis /data/dump.rdb 2>/dev/null; redis-cli -h 127.0.0.1 --no-auth-warning SHUTDOWN NOSAVE >/dev/null 2>&1; exit 0"#
222            }
223        };
224        vec!["/bin/sh".into(), "-c".into(), line.into()]
225    }
226}
227
228impl std::fmt::Display for Engine {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.write_str(self.name())
231    }
232}
233
234/// `source: {database: ...}`.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct DatabaseSource {
238    pub engine: Engine,
239    /// The image tag (`17`, `8.4`); default per engine. Changing it is a
240    /// deploy of the new image over the same data: minor versions only for
241    /// engines that cannot upgrade data in place (Postgres majors).
242    #[serde(default, skip_serializing_if = "String::is_empty")]
243    pub version: String,
244    /// The database created on first start (not Redis). Default: the app
245    /// name with `-` as `_`.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub database: Option<String>,
248    /// The user created on first start (not Redis; MongoDB's root user).
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub user: Option<String>,
251    /// More secrets isb keeps holding the internal URL, each with its own
252    /// query string (`{"dsn.chat-postgres.mattermost": "sslmode=disable"}`,
253    /// `""` for none): written at deploy and again when the password
254    /// changes, so an app that needs driver options never holds a copy of
255    /// the password that goes stale.
256    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
257    pub urls: BTreeMap<String, String>,
258}
259
260/// A database or user name: an identifier every engine takes unquoted.
261fn validate_ident(what: &str, s: &str) -> Result<()> {
262    let ok = !s.is_empty()
263        && s.len() <= 63
264        && s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
265        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
266    if ok {
267        Ok(())
268    } else {
269        Err(Error::invalid(format!(
270            "{what} {s:?}: up to 63 characters of [A-Za-z0-9_], not starting with a digit"
271        )))
272    }
273}
274
275fn validate_version(v: &str) -> Result<()> {
276    let ok = !v.is_empty()
277        && v.len() <= 64
278        && v.starts_with(|c: char| c.is_ascii_alphanumeric())
279        && v.chars()
280            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
281    if ok {
282        Ok(())
283    } else {
284        Err(Error::invalid(format!(
285            "version {v:?}: an image tag such as 17 or 8.4"
286        )))
287    }
288}
289
290impl DatabaseSource {
291    pub fn version(&self) -> &str {
292        if self.version.is_empty() {
293            self.engine.default_version()
294        } else {
295            &self.version
296        }
297    }
298
299    /// The image this database runs.
300    pub fn image(&self) -> String {
301        format!("docker:{}:{}", self.engine.repository(), self.version())
302    }
303
304    /// Fill the defaults in, so the stored record says what runs.
305    pub fn normalize(&mut self, app: &str) {
306        if self.version.is_empty() {
307            self.version = self.engine.default_version().into();
308        }
309        if self.engine.has_database() {
310            if self.database.is_none() {
311                self.database = Some(app.replace('-', "_"));
312            }
313            if self.user.is_none() {
314                self.user = Some(default_user(app));
315            }
316        }
317    }
318
319    pub fn database_name(&self, app: &str) -> String {
320        self.database
321            .clone()
322            .unwrap_or_else(|| app.replace('-', "_"))
323    }
324
325    pub fn user_name(&self, app: &str) -> String {
326        self.user.clone().unwrap_or_else(|| default_user(app))
327    }
328
329    pub fn validate(&self, app: &str) -> Result<()> {
330        validate_version(self.version())?;
331        if self.engine.has_database() {
332            validate_ident("database", &self.database_name(app))?;
333            validate_ident("user", &self.user_name(app))?;
334            if self.engine == Engine::Postgres && self.user_name(app).starts_with("pg_") {
335                return Err(Error::invalid(format!(
336                    "user {:?}: Postgres reserves role names starting with pg_",
337                    self.user_name(app)
338                )));
339            }
340            if self.engine.has_root_password() && self.user_name(app) == "root" {
341                return Err(Error::invalid(
342                    "user: root is the engine's own administrator; pick another name",
343                ));
344            }
345        }
346        self.validate_urls(app)?;
347        if !self.engine.has_database() && (self.database.is_some() || self.user.is_some()) {
348            return Err(Error::invalid(format!(
349                "{} has no database or user to set",
350                self.engine
351            )));
352        }
353        Ok(())
354    }
355}
356
357/// The secret holding a database's password.
358pub fn password_secret(app: &str) -> String {
359    format!("db.{app}.password")
360}
361
362impl DatabaseSource {
363    fn validate_urls(&self, app: &str) -> Result<()> {
364        for (name, query) in &self.urls {
365            crate::secrets::validate_name(name)?;
366            if name.contains('/') || name.starts_with(&format!("db.{app}.")) {
367                return Err(Error::invalid(format!(
368                    "urls: {name:?} is not a secret isb can keep (db.{app}.* is the database's own, and a name with / is an external reference)"
369                )));
370            }
371            let ok = query.chars().all(|c| {
372                c.is_ascii_alphanumeric()
373                    || matches!(
374                        c,
375                        '=' | '&' | '_' | '-' | '.' | '~' | '%' | '+' | ',' | ':' | '/'
376                    )
377            });
378            if !ok || query.starts_with('?') {
379                return Err(Error::invalid(format!(
380                    "urls: {name:?}: {query:?} is a query string without its ?, such as sslmode=disable&connect_timeout=10"
381                )));
382            }
383        }
384        Ok(())
385    }
386}
387
388/// The internal URL with `query` appended (`""` leaves it as it is).
389pub fn with_query(url: &str, query: &str) -> String {
390    match (query.is_empty(), url.contains('?')) {
391        (true, _) => url.to_string(),
392        (false, true) => format!("{url}&{query}"),
393        (false, false) => format!("{url}?{query}"),
394    }
395}
396
397/// The secret holding a MySQL or MariaDB root password.
398pub fn root_password_secret(app: &str) -> String {
399    format!("db.{app}.root-password")
400}
401
402/// The secret holding the internal connection URL (password included), for
403/// apps: `DATABASE_URL=${{secret.db.<app>.url}}`.
404pub fn url_secret(app: &str) -> String {
405    format!("db.{app}.url")
406}
407
408/// The user a database gets when none is given: the app's name with `_`
409/// for `-`, and `app_` in front of a name Postgres reserves (`pg_...`: an
410/// app named `pg-main` would otherwise never initialize).
411fn default_user(app: &str) -> String {
412    let base = app.replace('-', "_");
413    if base.starts_with("pg_") {
414        format!("app_{base}")
415    } else {
416        base
417    }
418}
419
420/// The secrets isb made for a database (and removes with it).
421pub fn secrets(app: &str, engine: Engine) -> Vec<String> {
422    let mut v = vec![password_secret(app), url_secret(app)];
423    if engine.has_root_password() {
424        v.push(root_password_secret(app));
425    }
426    v
427}
428
429/// The environment a database runs with, ahead of the app's own.
430fn engine_env(app: &str, db: &DatabaseSource) -> Vec<(String, EnvValue)> {
431    let s = |n: String| EnvValue::Secret { secret: n };
432    let p = |v: &str| EnvValue::Plain(v.to_string());
433    let (name, user) = (db.database_name(app), db.user_name(app));
434    match db.engine {
435        Engine::Postgres => vec![
436            ("POSTGRES_DB".into(), p(&name)),
437            ("POSTGRES_USER".into(), p(&user)),
438            ("POSTGRES_PASSWORD".into(), s(password_secret(app))),
439            // A subdirectory: the volume's root may hold lost+found.
440            ("PGDATA".into(), p("/var/lib/postgresql/data/pgdata")),
441        ],
442        Engine::Mysql => vec![
443            ("MYSQL_DATABASE".into(), p(&name)),
444            ("MYSQL_USER".into(), p(&user)),
445            ("MYSQL_PASSWORD".into(), s(password_secret(app))),
446            ("MYSQL_ROOT_PASSWORD".into(), s(root_password_secret(app))),
447        ],
448        Engine::Mariadb => vec![
449            ("MARIADB_DATABASE".into(), p(&name)),
450            ("MARIADB_USER".into(), p(&user)),
451            ("MARIADB_PASSWORD".into(), s(password_secret(app))),
452            ("MARIADB_ROOT_PASSWORD".into(), s(root_password_secret(app))),
453        ],
454        Engine::Mongodb => vec![
455            ("MONGO_INITDB_DATABASE".into(), p(&name)),
456            ("MONGO_INITDB_ROOT_USERNAME".into(), p(&user)),
457            ("MONGO_INITDB_ROOT_PASSWORD".into(), s(password_secret(app))),
458        ],
459        Engine::Redis => vec![
460            ("REDIS_PASSWORD".into(), s(password_secret(app))),
461            // redis-cli reads it, so health checks and dumps need no argv.
462            ("REDISCLI_AUTH".into(), s(password_secret(app))),
463        ],
464    }
465}
466
467fn healthcheck(engine: Engine) -> Value {
468    let test = match engine {
469        Engine::Postgres => r#"pg_isready -q -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB""#,
470        Engine::Mysql => {
471            r#"MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqladmin ping -h 127.0.0.1 -uroot --silent"#
472        }
473        Engine::Mariadb => {
474            r#"MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb-admin ping -h 127.0.0.1 -uroot --silent"#
475        }
476        Engine::Mongodb => {
477            r#"mongosh --quiet --host 127.0.0.1 --eval "db.adminCommand('ping').ok""#
478        }
479        Engine::Redis => r#"redis-cli -h 127.0.0.1 --no-auth-warning ping | grep -q PONG"#,
480    };
481    json!({
482        "test": ["CMD-SHELL", test],
483        "interval": "5s",
484        "timeout": "5s",
485        "retries": 6,
486        // First start initializes the data directory.
487        "start_period": "120s",
488    })
489}
490
491/// The spec a database app renders as: the engine's image, environment
492/// (ahead of the app's own variables, which may add to it), data volume,
493/// health check and, for Redis, command. One replica.
494pub fn effective(spec: &AppSpec, db: &DatabaseSource) -> AppSpec {
495    let mut s = spec.clone();
496    let mut env = super::EnvFile::default();
497    for (k, v) in engine_env(&spec.name, db) {
498        env.set(&k, v);
499    }
500    for (k, v) in spec.env.vars() {
501        env.set(k, v.clone());
502    }
503    s.env = env;
504    s.volumes
505        .insert(0, format!("{DATA_VOLUME}:{}", db.engine.data_path()));
506    if s.healthcheck.is_none() {
507        s.healthcheck = Some(healthcheck(db.engine));
508    }
509    if s.port.is_none() {
510        s.port = Some(db.engine.port());
511    }
512    if db.engine == Engine::Redis && s.command.is_none() {
513        s.command = Some(json!([
514            "/bin/sh",
515            "-c",
516            r#"exec docker-entrypoint.sh redis-server --requirepass "$REDIS_PASSWORD" --save "60 1" --appendonly no"#
517        ]));
518    }
519    s.replicas = s.replicas.min(1);
520    s
521}
522
523/// Check what only databases restrict.
524pub fn validate(spec: &AppSpec, db: &DatabaseSource) -> Result<()> {
525    db.validate(&spec.name)?;
526    if spec.build.is_some() {
527        return Err(Error::invalid("a database is not built; drop `build`"));
528    }
529    if spec.replicas > 1 {
530        return Err(Error::invalid(
531            "a database runs one replica (two writers on one data volume corrupt it)",
532        ));
533    }
534    for v in &spec.volumes {
535        if v.split(':').next() == Some(DATA_VOLUME) {
536            return Err(Error::invalid(format!(
537                "volume name {DATA_VOLUME:?} is the database's own"
538            )));
539        }
540    }
541    Ok(())
542}
543
544/// How to reach a database, with the password as a secret reference. With
545/// `password`, its value too.
546pub fn connection(
547    spec: &AppSpec,
548    db: &DatabaseSource,
549    org: &crate::org::OrgId,
550    password: Option<&str>,
551) -> Value {
552    let stack = spec.stack().unwrap_or_default();
553    let host = format!("{}.{stack}", spec.name);
554    let fqdn = format!("{host}.{org}.isb");
555    let port = db.engine.port();
556    let (user, name) = if db.engine.has_database() {
557        (
558            Some(db.user_name(&spec.name)),
559            Some(db.database_name(&spec.name)),
560        )
561    } else {
562        (None, None)
563    };
564    let url = |pw: &str, host: &str, port: u16| -> String {
565        let auth = match &user {
566            Some(u) => format!("{u}:{pw}@"),
567            None => format!("default:{pw}@"),
568        };
569        let mut u = format!("{}://{auth}{host}:{port}", db.engine.scheme());
570        if let Some(n) = &name {
571            u.push_str(&format!("/{n}"));
572        }
573        if db.engine == Engine::Mongodb {
574            u.push_str("?authSource=admin");
575        }
576        u
577    };
578    let reference = format!("${{{{secret.{}}}}}", password_secret(&spec.name));
579    // Published ports, as host:port pairs reachable from outside the org.
580    let external: Vec<String> = spec
581        .ports
582        .iter()
583        .filter_map(|p| {
584            let parts: Vec<&str> = p.split(':').collect();
585            match parts.as_slice() {
586                [ip, host_port, _] => Some(format!("{ip}:{host_port}")),
587                [host_port, _] => Some(format!("127.0.0.1:{host_port}")),
588                _ => None,
589            }
590        })
591        .collect();
592    let mut v = json!({
593        "engine": db.engine,
594        "version": db.version(),
595        "image": db.image(),
596        "host": host,
597        "fqdn": fqdn,
598        "port": port,
599        "password": {"secret": password_secret(&spec.name)},
600        "url": url(&reference, &host, port),
601        "url_secret": url_secret(&spec.name),
602        "url_secrets": db.urls.keys().collect::<Vec<_>>(),
603        "volume": format!("{stack}_{}_{DATA_VOLUME}", spec.name),
604    });
605    if let Some(u) = &user {
606        v["user"] = json!(u);
607    }
608    if let Some(n) = &name {
609        v["database"] = json!(n);
610    }
611    if db.engine.has_root_password() {
612        v["root_password"] = json!({"secret": root_password_secret(&spec.name)});
613    }
614    if !external.is_empty() {
615        v["external"] = json!(
616            external
617                .iter()
618                .map(|hp| {
619                    let (h, p) = hp.rsplit_once(':').unwrap_or((hp, ""));
620                    url(&reference, h, p.parse().unwrap_or(port))
621                })
622                .collect::<Vec<_>>()
623        );
624    }
625    if let Some(pw) = password {
626        v["password_value"] = json!(pw);
627        v["url_value"] = json!(url(pw, &host, port));
628    }
629    v
630}
631
632/// The internal URL with the real password, stored as [`url_secret`].
633pub fn internal_url(spec: &AppSpec, db: &DatabaseSource, password: &str) -> String {
634    connection(spec, db, &crate::org::OrgId::default_org(), Some(password))["url_value"]
635        .as_str()
636        .unwrap_or_default()
637        .to_string()
638}
639
640#[cfg(test)]
641mod tests {
642
643    #[test]
644    fn postgres_users_never_start_with_pg() {
645        let mut d: DatabaseSource =
646            serde_json::from_value(serde_json::json!({"engine": "postgres"})).unwrap();
647        assert_eq!(d.user_name("pg-main"), "app_pg_main");
648        assert_eq!(
649            d.database_name("pg-main"),
650            "pg_main",
651            "databases may start with pg_"
652        );
653        assert_eq!(d.user_name("pgcopy"), "pgcopy");
654        assert!(d.validate("pg-main").is_ok());
655        d.user = Some("pg_admin".into());
656        assert!(d.validate("x").is_err());
657    }
658    use super::*;
659    use crate::app::{Rendered, Source, render};
660
661    fn db_spec(engine: &str) -> AppSpec {
662        serde_json::from_value(json!({
663            "name": "main-db", "project": "shop",
664            "source": {"database": {"engine": engine}},
665        }))
666        .unwrap()
667    }
668
669    fn render_db(spec: &AppSpec) -> Rendered {
670        let Source::Database(db) = &spec.source else {
671            panic!()
672        };
673        let mut notes = vec![];
674        render(spec, &db.image(), &mut notes).unwrap()
675    }
676
677    #[test]
678    fn defaults_and_validation() {
679        let mut s = db_spec("postgres");
680        let Source::Database(db) = &mut s.source else {
681            panic!()
682        };
683        db.normalize("main-db");
684        assert_eq!(db.version, "17");
685        assert_eq!(db.database.as_deref(), Some("main_db"));
686        assert_eq!(db.user.as_deref(), Some("main_db"));
687        assert_eq!(db.image(), "docker:postgres:17");
688        s.validate().unwrap();
689        let mut bad = s.clone();
690        bad.replicas = 2;
691        assert!(bad.validate().is_err(), "one replica");
692        let mut bad = s.clone();
693        bad.volumes = vec!["data:/x".into()];
694        assert!(bad.validate().is_err(), "data is the database's");
695        let mut bad = s.clone();
696        if let Source::Database(d) = &mut bad.source {
697            d.database = Some("x; drop".into());
698        }
699        assert!(bad.validate().is_err());
700        let mut bad = s.clone();
701        if let Source::Database(d) = &mut bad.source {
702            d.version = "17 && rm".into();
703        }
704        assert!(bad.validate().is_err());
705        let r = db_spec("redis");
706        r.validate().unwrap();
707        let mut bad = r.clone();
708        if let Source::Database(d) = &mut bad.source {
709            d.user = Some("u".into());
710        }
711        assert!(bad.validate().is_err(), "redis has no user");
712        let m: AppSpec = serde_json::from_value(json!({
713            "name": "m", "project": "p", "source": {"database": {"engine": "mysql", "user": "root"}},
714        }))
715        .unwrap();
716        assert!(m.validate().is_err(), "root is mysql's own");
717        assert_eq!(Engine::parse("mongo").unwrap(), Engine::Mongodb);
718        assert!(Engine::parse("oracle").is_err());
719        let e: Engine = serde_json::from_value(json!("mongo")).unwrap();
720        assert_eq!(e, Engine::Mongodb);
721    }
722
723    /// A database's passwords change inside it before its replica gets
724    /// them: `rotate` on exactly those secrets, and only on the database.
725    #[test]
726    fn passwords_rotate_inside_the_database() {
727        for engine in ENGINES {
728            let s = db_spec(engine.name());
729            let r = render_db(&s);
730            let rotating: Vec<&str> = r
731                .secrets
732                .iter()
733                .filter(|(_, d)| d.rotate.is_some())
734                .map(|(k, _)| k.as_str())
735                .collect();
736            let mut want = vec![format!("main-db.{}", password_secret("main-db"))];
737            if engine.has_root_password() {
738                want.push(format!("main-db.{}", root_password_secret("main-db")));
739            }
740            want.sort();
741            assert_eq!(rotating, want, "{engine}");
742            for root in [false, true] {
743                let argv = engine.rotate_command(root);
744                // The line parses as shell.
745                let ok = std::process::Command::new("sh")
746                    .args(["-n", "-c", &argv[2]])
747                    .status()
748                    .unwrap()
749                    .success();
750                assert!(ok, "{engine} root={root}: {}", argv[2]);
751                // The new value only ever arrives on stdin.
752                assert!(argv[2].contains("$(cat"), "{engine}");
753            }
754        }
755    }
756
757    #[test]
758    fn credentials_render_as_secret_references() {
759        for engine in ENGINES {
760            let s = db_spec(engine.name());
761            let r = render_db(&s);
762            let svc = &r.service;
763            assert_eq!(
764                svc.image,
765                format!(
766                    "docker:{}:{}",
767                    engine.repository(),
768                    engine.default_version()
769                )
770            );
771            // The password is a reference to the org secret, never a value.
772            let pw = password_secret("main-db");
773            assert!(
774                svc.env
775                    .secrets
776                    .values()
777                    .any(|k| k == &format!("main-db.{pw}")),
778                "{engine}: {:?}",
779                svc.env.secrets
780            );
781            assert_eq!(
782                r.secrets[&format!("main-db.{pw}")].name.as_deref(),
783                Some(pw.as_str())
784            );
785            assert!(r.secrets.values().all(|d| d.external));
786            for v in svc.env.vars.values() {
787                assert!(!v.contains("password"), "{engine}: {v}");
788            }
789            // Data on a volume; stop-first; one replica; a health check.
790            assert_eq!(svc.volumes[0].source, "main-db_data");
791            assert_eq!(svc.volumes[0].target, engine.data_path());
792            assert!(r.volumes.contains_key("main-db_data"));
793            let v = serde_json::to_value(svc).unwrap();
794            assert_eq!(
795                v["deploy"]["update_config"]["order"], "stop-first",
796                "{engine}"
797            );
798            assert_eq!(svc.replicas(), 1);
799            assert!(svc.healthcheck.is_some());
800            assert!(svc.ports.is_empty(), "not published by default");
801            if engine.has_root_password() {
802                assert_eq!(svc.env.secrets.len(), 2, "{engine}");
803            }
804        }
805        let pg = render_db(&db_spec("postgres"));
806        assert_eq!(pg.service.env["POSTGRES_DB"], "main_db");
807        assert_eq!(pg.service.env["POSTGRES_USER"], "main_db");
808        assert_eq!(
809            pg.service.env.secrets["POSTGRES_PASSWORD"],
810            "main-db.db.main-db.password"
811        );
812        let redis = render_db(&db_spec("redis"));
813        assert!(redis.service.command.is_some());
814        assert_eq!(redis.service.env.secrets.len(), 2);
815    }
816
817    #[test]
818    fn app_env_adds_to_the_engine_env() {
819        let mut s = db_spec("postgres");
820        s.env.set(
821            "POSTGRES_INITDB_ARGS",
822            EnvValue::Plain("--data-checksums".into()),
823        );
824        s.ports = vec!["127.0.0.1:15432:5432".into()];
825        let r = render_db(&s);
826        assert_eq!(r.service.env["POSTGRES_INITDB_ARGS"], "--data-checksums");
827        assert_eq!(r.service.ports.len(), 1);
828    }
829
830    #[test]
831    fn connection_names_the_secret() {
832        let mut s = db_spec("postgres");
833        s.ports = vec!["127.0.0.1:15432:5432".into()];
834        let Source::Database(db) = &s.source else {
835            panic!()
836        };
837        let org = crate::org::OrgId::new("acme").unwrap();
838        let c = connection(&s, db, &org, None);
839        assert_eq!(c["host"], "main-db.shop-production");
840        assert_eq!(c["fqdn"], "main-db.shop-production.acme.isb");
841        assert_eq!(c["port"], 5432);
842        assert_eq!(c["password"]["secret"], "db.main-db.password");
843        assert_eq!(
844            c["url"],
845            "postgres://main_db:${{secret.db.main-db.password}}@main-db.shop-production:5432/main_db"
846        );
847        assert_eq!(
848            c["external"][0],
849            "postgres://main_db:${{secret.db.main-db.password}}@127.0.0.1:15432/main_db"
850        );
851        assert!(c.get("password_value").is_none());
852        let shown = connection(&s, db, &org, Some("pw1"));
853        assert_eq!(shown["password_value"], "pw1");
854        assert_eq!(
855            internal_url(&s, db, "pw1"),
856            "postgres://main_db:pw1@main-db.shop-production:5432/main_db"
857        );
858        let r = db_spec("redis");
859        let Source::Database(rdb) = &r.source else {
860            panic!()
861        };
862        assert_eq!(
863            internal_url(&r, rdb, "x"),
864            "redis://default:x@main-db.shop-production:6379"
865        );
866        let m = db_spec("mongodb");
867        let Source::Database(mdb) = &m.source else {
868            panic!()
869        };
870        assert!(internal_url(&m, mdb, "x").ends_with("/main_db?authSource=admin"));
871    }
872
873    #[test]
874    fn dump_and_restore_take_credentials_from_the_instance() {
875        for e in ENGINES {
876            for argv in [e.dump_command(), e.restore_command()] {
877                assert_eq!(argv.len(), 3);
878                assert_eq!(argv[0], "/bin/sh");
879                // Credentials are the instance's variables, expanded inside.
880                if e != Engine::Redis {
881                    assert!(argv[2].contains("PASSWORD\""), "{e}: {}", argv[2]);
882                }
883            }
884        }
885        assert!(Engine::Mysql.restores_from(Engine::Mariadb));
886        assert!(!Engine::Postgres.restores_from(Engine::Mysql));
887    }
888
889    #[test]
890    fn urls_take_a_query_string_and_never_the_databases_own_secrets() {
891        assert_eq!(
892            with_query("postgres://u:p@h:5432/d", ""),
893            "postgres://u:p@h:5432/d"
894        );
895        assert_eq!(
896            with_query(
897                "postgres://u:p@h:5432/d",
898                "sslmode=disable&connect_timeout=10"
899            ),
900            "postgres://u:p@h:5432/d?sslmode=disable&connect_timeout=10"
901        );
902        assert_eq!(
903            with_query("mongodb://u:p@h:27017/d?authSource=admin", "tls=false"),
904            "mongodb://u:p@h:27017/d?authSource=admin&tls=false"
905        );
906        let db = |urls: Value| -> DatabaseSource {
907            serde_json::from_value(json!({"engine": "postgres", "urls": urls})).unwrap()
908        };
909        assert!(
910            db(json!({"dsn.main-db.web": "sslmode=disable", "dsn.x": ""}))
911                .validate("main-db")
912                .is_ok()
913        );
914        for bad in [
915            json!({"db.main-db.url": ""}),
916            json!({"db.main-db.password": ""}),
917            json!({"op/vault/item": ""}),
918            json!({"dsn.x": "?sslmode=disable"}),
919            json!({"dsn.x": "a=b c"}),
920            json!({"dsn.x": "a=b#frag"}),
921        ] {
922            assert!(db(bad.clone()).validate("main-db").is_err(), "{bad}");
923        }
924    }
925}