1use 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#[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
43pub 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 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 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 pub fn has_root_password(self) -> bool {
109 matches!(self, Engine::Mysql | Engine::Mariadb)
110 }
111
112 pub fn has_database(self) -> bool {
114 !matches!(self, Engine::Redis)
115 }
116
117 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 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 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 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 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 pub fn rotate_command(self, root: bool) -> Vec<String> {
179 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct DatabaseSource {
238 pub engine: Engine,
239 #[serde(default, skip_serializing_if = "String::is_empty")]
243 pub version: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub database: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub user: Option<String>,
251 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
257 pub urls: BTreeMap<String, String>,
258}
259
260fn 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 pub fn image(&self) -> String {
301 format!("docker:{}:{}", self.engine.repository(), self.version())
302 }
303
304 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
357pub 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
388pub 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
397pub fn root_password_secret(app: &str) -> String {
399 format!("db.{app}.root-password")
400}
401
402pub fn url_secret(app: &str) -> String {
405 format!("db.{app}.url")
406}
407
408fn 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
420pub 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
429fn 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 ("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 ("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 "start_period": "120s",
488 })
489}
490
491pub 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
523pub 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
544pub 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 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
632pub 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 #[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 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 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 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 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 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}