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