1use crate::{
2 censor::{CENSORED_PLACEHOLDER, Censor},
3 models::{InsertQueryBuilder, UpdateQueryBuilder},
4 prelude::*,
5};
6use garde::Validate;
7use serde::{Deserialize, Serialize};
8use sqlx::{Row, postgres::PgRow, prelude::Type};
9use std::{
10 collections::{BTreeMap, HashMap},
11 hash::Hash,
12 str::FromStr,
13 sync::{Arc, LazyLock},
14};
15use tokio::sync::Mutex;
16use utoipa::ToSchema;
17
18pub enum DatabaseTransaction<'a> {
19 Mysql(sqlx::Transaction<'a, sqlx::MySql>, sqlx::Pool<sqlx::MySql>),
20 Postgres(
21 sqlx::Transaction<'a, sqlx::Postgres>,
22 sqlx::Pool<sqlx::Postgres>,
23 ),
24 Mongodb(mongodb::Client),
25}
26
27#[derive(Clone)]
28pub enum DatabasePool {
29 Mysql(sqlx::Pool<sqlx::MySql>),
30 Postgres(sqlx::Pool<sqlx::Postgres>),
31 Mongodb(mongodb::Client),
32}
33
34impl DatabasePool {
35 pub async fn version(&self) -> Result<String, anyhow::Error> {
36 Ok(match self {
37 Self::Mysql(pool) => {
38 sqlx::query_scalar::<_, String>("SELECT VERSION()")
39 .fetch_one(pool)
40 .await?
41 }
42 Self::Postgres(pool) => {
43 sqlx::query_scalar::<_, String>("SHOW server_version")
44 .fetch_one(pool)
45 .await?
46 }
47 Self::Mongodb(client) => client
48 .database("admin")
49 .run_command(mongodb::bson::doc! { "buildInfo": 1 })
50 .await?
51 .get_str("version")?
52 .to_string(),
53 })
54 }
55}
56
57type DatabasePoolValue = (std::time::Instant, DatabasePool);
58static DATABASE_CLIENTS: LazyLock<Arc<Mutex<HashMap<uuid::Uuid, DatabasePoolValue>>>> =
59 LazyLock::new(|| {
60 let clients = Arc::new(Mutex::new(HashMap::<uuid::Uuid, DatabasePoolValue>::new()));
61
62 tokio::spawn({
63 let clients = Arc::clone(&clients);
64 async move {
65 loop {
66 tokio::time::sleep(std::time::Duration::from_mins(1)).await;
67
68 let mut clients = clients.lock().await;
69 let before_len = clients.len();
70 clients.retain(|_, &mut (last_used, _)| {
71 last_used.elapsed() < std::time::Duration::from_mins(5)
72 });
73
74 if clients.len() != before_len {
75 tracing::info!(
76 "cleaned up {} idle database connections",
77 before_len - clients.len()
78 );
79 }
80 }
81 }
82 });
83
84 clients
85 });
86
87#[derive(ToSchema, Serialize, Deserialize, Type, PartialEq, Eq, Hash, Clone, Copy)]
88#[serde(rename_all = "snake_case")]
89#[sqlx(type_name = "database_type", rename_all = "SCREAMING_SNAKE_CASE")]
90pub enum DatabaseType {
91 Mysql,
92 Postgres,
93 Mongodb,
94}
95
96impl DatabaseType {
97 pub fn from_url_scheme(scheme: &str) -> Result<Self, anyhow::Error> {
98 match scheme {
99 "mysql" | "mariadb" => Ok(Self::Mysql),
100 "postgres" | "postgresql" => Ok(Self::Postgres),
101 "mongodb" => Ok(Self::Mongodb),
102 _ => Err(anyhow::anyhow!("Unsupported database type: {}", scheme)),
103 }
104 }
105
106 pub const fn default_port(self) -> u16 {
107 match self {
108 DatabaseType::Mysql => 3306,
109 DatabaseType::Postgres => 5432,
110 DatabaseType::Mongodb => 27017,
111 }
112 }
113}
114
115fn validate_connection_string(connection_string: &str, _context: &()) -> Result<(), garde::Error> {
116 if connection_string.trim().is_empty() {
117 return Err(garde::Error::new("connection string cannot be empty"));
118 }
119
120 let url = reqwest::Url::parse(connection_string)
121 .map_err(|err| garde::Error::new(format!("Invalid connection string: {err}")))?;
122
123 DatabaseType::from_url_scheme(url.scheme())
124 .map_err(|err| garde::Error::new(format!("Invalid connection string: {err}")))?;
125
126 if url.host_str().is_none() {
127 return Err(garde::Error::new("Invalid connection string: missing host"));
128 }
129 if url.username().is_empty() {
130 return Err(garde::Error::new(
131 "Invalid connection string: missing username",
132 ));
133 }
134 if url.password().is_none() {
135 return Err(garde::Error::new(
136 "Invalid connection string: missing password",
137 ));
138 }
139
140 Ok(())
141}
142
143#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
144#[serde(tag = "type", rename_all = "snake_case")]
145pub enum DatabaseCredentials {
146 ConnectionString {
147 #[garde(length(chars, min = 1, max = 255), custom(validate_connection_string))]
148 #[schema(min_length = 1, max_length = 255)]
149 connection_string: compact_str::CompactString,
150 },
151 Details {
152 #[garde(length(chars, min = 1, max = 255))]
153 #[schema(min_length = 1, max_length = 255)]
154 host: compact_str::CompactString,
155 #[garde(range(min = 1))]
156 #[schema(minimum = 1)]
157 port: u16,
158 #[garde(length(chars, min = 1, max = 255))]
159 #[schema(min_length = 1, max_length = 255)]
160 username: compact_str::CompactString,
161 #[garde(length(chars, min = 1, max = 255))]
162 #[schema(min_length = 1, max_length = 255)]
163 password: compact_str::CompactString,
164 },
165}
166
167#[derive(ToSchema, Serialize)]
168pub struct ParsedConnectionDetails {
169 pub host: compact_str::CompactString,
170 pub port: u16,
171 pub username: compact_str::CompactString,
172}
173
174impl DatabaseCredentials {
175 pub async fn encrypt(
176 &mut self,
177 database: &crate::database::Database,
178 ) -> Result<(), anyhow::Error> {
179 match self {
180 DatabaseCredentials::ConnectionString { connection_string } => {
181 *connection_string = database.encrypt_base64(connection_string.clone()).await?;
182 }
183 DatabaseCredentials::Details { password, .. } => {
184 *password = database.encrypt_base64(password.clone()).await?;
185 }
186 }
187
188 Ok(())
189 }
190
191 pub async fn decrypt(
192 &mut self,
193 database: &crate::database::Database,
194 ) -> Result<(), anyhow::Error> {
195 match self {
196 DatabaseCredentials::ConnectionString { connection_string } => {
197 if let Some(decrypted) =
198 database.decrypt_base64_optional(&connection_string).await?
199 {
200 *connection_string = decrypted;
201 }
202 }
203 DatabaseCredentials::Details { password, .. } => {
204 if let Some(decrypted) = database.decrypt_base64_optional(&password).await? {
205 *password = decrypted;
206 }
207 }
208 }
209
210 Ok(())
211 }
212
213 pub async fn parse_connection_details(
214 &self,
215 database: &crate::database::Database,
216 ) -> Result<ParsedConnectionDetails, anyhow::Error> {
217 match self {
218 DatabaseCredentials::ConnectionString { connection_string } => {
219 let connection_string = database.decrypt_base64(connection_string).await?;
220 let url = reqwest::Url::parse(connection_string.as_str())?;
221 let database_type = DatabaseType::from_url_scheme(url.scheme())?;
222
223 let host = url
224 .host_str()
225 .ok_or_else(|| anyhow::anyhow!("Invalid host"))?
226 .into();
227 let port = url.port().unwrap_or_else(|| database_type.default_port());
228 let username = url.username().into();
229
230 Ok(ParsedConnectionDetails {
231 host,
232 port,
233 username,
234 })
235 }
236 DatabaseCredentials::Details {
237 host,
238 port,
239 username,
240 ..
241 } => Ok(ParsedConnectionDetails {
242 host: host.clone(),
243 port: *port,
244 username: username.clone(),
245 }),
246 }
247 }
248}
249
250impl Censor for DatabaseCredentials {
251 fn censor(&mut self) {
252 match self {
253 DatabaseCredentials::ConnectionString { connection_string } => {
254 *connection_string = CENSORED_PLACEHOLDER.into();
255 }
256 DatabaseCredentials::Details { password, .. } => {
257 *password = CENSORED_PLACEHOLDER.into();
258 }
259 }
260 }
261}
262
263#[derive(Serialize, Deserialize, Clone)]
264pub struct DatabaseHost {
265 pub uuid: uuid::Uuid,
266
267 pub name: compact_str::CompactString,
268 pub r#type: DatabaseType,
269
270 pub deployment_enabled: bool,
271 pub maintenance_enabled: bool,
272
273 pub public_host: Option<compact_str::CompactString>,
274 pub public_port: Option<i32>,
275
276 pub credentials: DatabaseCredentials,
277
278 pub created: chrono::NaiveDateTime,
279
280 extension_data: super::ModelExtensionData,
281}
282
283impl BaseModel for DatabaseHost {
284 const NAME: &'static str = "database_host";
285
286 fn get_extension_list() -> &'static super::ModelExtensionList {
287 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
288 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
289
290 &EXTENSIONS
291 }
292
293 fn get_extension_data(&self) -> &super::ModelExtensionData {
294 &self.extension_data
295 }
296
297 #[inline]
298 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
299 let prefix = prefix.unwrap_or_default();
300
301 BTreeMap::from([
302 (
303 "database_hosts.uuid",
304 compact_str::format_compact!("{prefix}uuid"),
305 ),
306 (
307 "database_hosts.name",
308 compact_str::format_compact!("{prefix}name"),
309 ),
310 (
311 "database_hosts.type",
312 compact_str::format_compact!("{prefix}type"),
313 ),
314 (
315 "database_hosts.deployment_enabled",
316 compact_str::format_compact!("{prefix}deployment_enabled"),
317 ),
318 (
319 "database_hosts.maintenance_enabled",
320 compact_str::format_compact!("{prefix}maintenance_enabled"),
321 ),
322 (
323 "database_hosts.public_host",
324 compact_str::format_compact!("{prefix}public_host"),
325 ),
326 (
327 "database_hosts.public_port",
328 compact_str::format_compact!("{prefix}public_port"),
329 ),
330 (
331 "database_hosts.credentials",
332 compact_str::format_compact!("{prefix}credentials"),
333 ),
334 (
335 "database_hosts.created",
336 compact_str::format_compact!("{prefix}created"),
337 ),
338 ])
339 }
340
341 #[inline]
342 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
343 let prefix = prefix.unwrap_or_default();
344
345 Ok(Self {
346 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
347 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
348 r#type: row.try_get(compact_str::format_compact!("{prefix}type").as_str())?,
349 deployment_enabled: row
350 .try_get(compact_str::format_compact!("{prefix}deployment_enabled").as_str())?,
351 maintenance_enabled: row
352 .try_get(compact_str::format_compact!("{prefix}maintenance_enabled").as_str())?,
353 public_host: row
354 .try_get(compact_str::format_compact!("{prefix}public_host").as_str())?,
355 public_port: row
356 .try_get(compact_str::format_compact!("{prefix}public_port").as_str())?,
357 credentials: serde_json::from_value(
358 row.try_get(compact_str::format_compact!("{prefix}credentials").as_str())?,
359 )?,
360 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
361 extension_data: Self::map_extensions(prefix, row)?,
362 })
363 }
364
365 fn cache_invalidation_keys(&self) -> Vec<compact_str::CompactString> {
366 vec![compact_str::format_compact!(
367 "{}::{}",
368 Self::NAME,
369 self.uuid
370 )]
371 }
372}
373
374impl DatabaseHost {
375 pub async fn get_connection(
376 &mut self,
377 database: &crate::database::Database,
378 ) -> Result<DatabasePool, crate::database::DatabaseError> {
379 let mut clients = DATABASE_CLIENTS.lock().await;
380
381 if let Some((last_used, pool)) = clients.get_mut(&self.uuid) {
382 *last_used = std::time::Instant::now();
383
384 return Ok(pool.clone());
385 }
386
387 drop(clients);
388
389 self.credentials.decrypt(database).await?;
390
391 let pool = match self.r#type {
392 DatabaseType::Mysql => {
393 let options = match &self.credentials {
394 DatabaseCredentials::ConnectionString { connection_string } => {
395 let options = sqlx::mysql::MySqlConnectOptions::from_str(connection_string)
396 .map_err(|err| {
397 anyhow::anyhow!("failed to parse MySQL connection string: {err}")
398 })?;
399 let host = crate::net::unbracket(options.get_host()).to_string();
400
401 options.host(&host)
402 }
403 DatabaseCredentials::Details {
404 host,
405 port,
406 username,
407 password,
408 } => sqlx::mysql::MySqlConnectOptions::new()
409 .host(host)
410 .port(*port)
411 .username(username)
412 .password(password),
413 };
414
415 let pool = sqlx::Pool::connect_with(options).await?;
416 DatabasePool::Mysql(pool)
417 }
418 DatabaseType::Postgres => {
419 let options = match &self.credentials {
420 DatabaseCredentials::ConnectionString { connection_string } => {
421 let options = sqlx::postgres::PgConnectOptions::from_str(connection_string)
422 .map_err(|err| {
423 anyhow::anyhow!("failed to parse Postgres connection string: {err}")
424 })?;
425 let host = crate::net::unbracket(options.get_host()).to_string();
426
427 options.host(&host)
428 }
429 DatabaseCredentials::Details {
430 host,
431 port,
432 username,
433 password,
434 } => sqlx::postgres::PgConnectOptions::new()
435 .host(host)
436 .port(*port)
437 .username(username)
438 .password(password)
439 .database("postgres"),
440 };
441
442 let pool = sqlx::Pool::connect_with(options).await?;
443 DatabasePool::Postgres(pool)
444 }
445 DatabaseType::Mongodb => {
446 let options = match &self.credentials {
447 DatabaseCredentials::ConnectionString { connection_string } => {
448 mongodb::options::ClientOptions::parse(connection_string.as_str())
449 .await
450 .map_err(|err| {
451 anyhow::anyhow!("failed to parse MongoDB connection string: {err}")
452 })?
453 }
454 DatabaseCredentials::Details {
455 host,
456 port,
457 username,
458 password,
459 } => {
460 let mut options = mongodb::options::ClientOptions::default();
461 options.hosts.push(mongodb::options::ServerAddress::Tcp {
462 host: host.to_string(),
463 port: Some(*port),
464 });
465 options.credential = Some(
466 mongodb::options::Credential::builder()
467 .username(username.to_string())
468 .password(password.to_string())
469 .build(),
470 );
471 options
472 }
473 };
474
475 let client = mongodb::Client::with_options(options)?;
476 DatabasePool::Mongodb(client)
477 }
478 };
479
480 DATABASE_CLIENTS
481 .lock()
482 .await
483 .insert(self.uuid, (std::time::Instant::now(), pool.clone()));
484 Ok(pool)
485 }
486
487 pub async fn all_with_pagination(
488 database: &crate::database::Database,
489 page: i64,
490 per_page: i64,
491 search: Option<&str>,
492 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
493 super::PaginatedQueryBuilder::new(
494 Self::columns_sql(None),
495 format!(
496 r#"
497 FROM database_hosts
498 WHERE {search}
499 "#,
500 search = super::search_sql(1, &["database_hosts.name"], &["database_hosts.uuid"]),
501 ),
502 "database_hosts.created, database_hosts.uuid",
503 )
504 .bind(search)
505 .fetch(database, page, per_page)
506 .await
507 }
508
509 pub async fn by_node_uuid(
510 database: &crate::database::Database,
511 node: &super::node::Node,
512 uuid: uuid::Uuid,
513 ) -> Result<Option<Self>, crate::database::DatabaseError> {
514 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
515 r#"
516 SELECT {}
517 FROM database_hosts
518 WHERE database_hosts.uuid = $3
519 AND (
520 EXISTS (
521 SELECT 1 FROM node_database_hosts
522 WHERE node_database_hosts.database_host_uuid = database_hosts.uuid AND node_database_hosts.node_uuid = $1
523 )
524 OR EXISTS (
525 SELECT 1 FROM location_database_hosts
526 WHERE location_database_hosts.database_host_uuid = database_hosts.uuid AND location_database_hosts.location_uuid = $2
527 )
528 )
529 "#,
530 Self::columns_sql(None)
531 )))
532 .bind(node.uuid)
533 .bind(node.location.uuid)
534 .bind(uuid)
535 .fetch_optional(database.read())
536 .await?;
537
538 row.try_map(|row| Self::map(None, &row))
539 }
540
541 pub async fn all_public_by_node(
542 database: &crate::database::Database,
543 node: &super::node::Node,
544 ) -> Result<Vec<Self>, crate::database::DatabaseError> {
545 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
546 r#"
547 SELECT {}
548 FROM database_hosts
549 WHERE database_hosts.deployment_enabled
550 AND (
551 EXISTS (
552 SELECT 1 FROM node_database_hosts
553 WHERE node_database_hosts.database_host_uuid = database_hosts.uuid AND node_database_hosts.node_uuid = $1
554 )
555 OR EXISTS (
556 SELECT 1 FROM location_database_hosts
557 WHERE location_database_hosts.database_host_uuid = database_hosts.uuid AND location_database_hosts.location_uuid = $2
558 )
559 )
560 ORDER BY database_hosts.created DESC, database_hosts.uuid
561 "#,
562 Self::columns_sql(None)
563 )))
564 .bind(node.uuid)
565 .bind(node.location.uuid)
566 .fetch_all(database.read())
567 .await?;
568
569 rows.into_iter()
570 .map(|row| Self::map(None, &row))
571 .try_collect_vec()
572 }
573}
574
575#[async_trait::async_trait]
576impl IntoAdminApiObject for DatabaseHost {
577 type AdminApiObject = AdminApiDatabaseHost;
578 type ExtraArgs<'a> = ();
579
580 async fn into_admin_api_object<'a>(
581 mut self,
582 state: &crate::State,
583 _args: Self::ExtraArgs<'a>,
584 ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
585 let api_object = AdminApiDatabaseHost::init_hooks(&self, state).await?;
586 self.credentials.censor();
587
588 let api_object = finish_extendible!(
589 AdminApiDatabaseHost {
590 uuid: self.uuid,
591 name: self.name,
592 r#type: self.r#type,
593 deployment_enabled: self.deployment_enabled,
594 maintenance_enabled: self.maintenance_enabled,
595 public_host: self.public_host,
596 public_port: self.public_port,
597 credentials: self.credentials,
598 created: self.created.and_utc(),
599 },
600 api_object,
601 state
602 )?;
603
604 Ok(api_object)
605 }
606}
607
608#[async_trait::async_trait]
609impl IntoApiObject for DatabaseHost {
610 type ApiObject = ApiDatabaseHost;
611 type ExtraArgs<'a> = ();
612
613 async fn into_api_object<'a>(
614 self,
615 state: &crate::State,
616 _args: Self::ExtraArgs<'a>,
617 ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
618 let api_object = ApiDatabaseHost::init_hooks(&self, state).await?;
619 let details = self
620 .credentials
621 .parse_connection_details(&state.database)
622 .await?;
623
624 let api_object = finish_extendible!(
625 ApiDatabaseHost {
626 uuid: self.uuid,
627 name: self.name,
628 maintenance_enabled: self.maintenance_enabled,
629 r#type: self.r#type,
630 host: self.public_host.unwrap_or(details.host),
631 port: self.public_port.unwrap_or(details.port as i32),
632 },
633 api_object,
634 state
635 )?;
636
637 Ok(api_object)
638 }
639}
640
641#[async_trait::async_trait]
642impl ByUuid for DatabaseHost {
643 async fn by_uuid(
644 database: &crate::database::Database,
645 uuid: uuid::Uuid,
646 ) -> Result<Self, crate::database::DatabaseError> {
647 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
648 r#"
649 SELECT {}
650 FROM database_hosts
651 WHERE database_hosts.uuid = $1
652 "#,
653 Self::columns_sql(None)
654 )))
655 .bind(uuid)
656 .fetch_one(database.read())
657 .await?;
658
659 Self::map(None, &row)
660 }
661
662 async fn by_uuid_with_transaction(
663 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
664 uuid: uuid::Uuid,
665 ) -> Result<Self, crate::database::DatabaseError> {
666 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
667 r#"
668 SELECT {}
669 FROM database_hosts
670 WHERE database_hosts.uuid = $1
671 "#,
672 Self::columns_sql(None)
673 )))
674 .bind(uuid)
675 .fetch_one(&mut **transaction)
676 .await?;
677
678 Self::map(None, &row)
679 }
680}
681
682#[derive(ToSchema, Deserialize, Validate)]
683pub struct CreateDatabaseHostOptions {
684 #[garde(length(chars, min = 1, max = 255))]
685 #[schema(min_length = 1, max_length = 255)]
686 pub name: compact_str::CompactString,
687 #[garde(skip)]
688 pub r#type: DatabaseType,
689
690 #[garde(skip)]
691 pub deployment_enabled: bool,
692 #[garde(skip)]
693 pub maintenance_enabled: bool,
694
695 #[garde(length(chars, min = 3, max = 255))]
696 #[schema(min_length = 3, max_length = 255)]
697 pub public_host: Option<compact_str::CompactString>,
698 #[garde(range(min = 1))]
699 #[schema(minimum = 1)]
700 pub public_port: Option<u16>,
701
702 #[garde(dive)]
703 pub credentials: DatabaseCredentials,
704}
705
706#[async_trait::async_trait]
707impl CreatableModel for DatabaseHost {
708 type CreateOptions<'a> = CreateDatabaseHostOptions;
709 type CreateResult = Self;
710
711 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
712 static CREATE_LISTENERS: LazyLock<CreateListenerList<DatabaseHost>> =
713 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
714
715 &CREATE_LISTENERS
716 }
717
718 async fn create_with_transaction(
719 state: &crate::State,
720 mut options: Self::CreateOptions<'_>,
721 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
722 ) -> Result<Self, crate::database::DatabaseError> {
723 options.validate()?;
724
725 let mut query_builder = InsertQueryBuilder::new("database_hosts");
726
727 Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
728
729 options.credentials.encrypt(&state.database).await?;
730
731 query_builder
732 .set("name", &options.name)
733 .set("type", options.r#type)
734 .set("deployment_enabled", options.deployment_enabled)
735 .set("maintenance_enabled", options.maintenance_enabled)
736 .set("public_host", &options.public_host)
737 .set("public_port", options.public_port.map(|p| p as i32))
738 .set("credentials", serde_json::to_value(&options.credentials)?);
739
740 let row = query_builder
741 .returning(&Self::columns_sql(None))
742 .fetch_one(&mut **transaction)
743 .await?;
744 let mut database_host = Self::map(None, &row)?;
745
746 Self::run_after_create_handlers(&mut database_host, &options, state, transaction).await?;
747
748 Ok(database_host)
749 }
750}
751
752#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
753pub struct UpdateDatabaseHostOptions {
754 #[garde(length(chars, min = 1, max = 255))]
755 #[schema(min_length = 1, max_length = 255)]
756 pub name: Option<compact_str::CompactString>,
757
758 #[garde(skip)]
759 pub deployment_enabled: Option<bool>,
760 #[garde(skip)]
761 pub maintenance_enabled: Option<bool>,
762
763 #[garde(length(max = 255))]
764 #[schema(max_length = 255)]
765 #[serde(
766 default,
767 skip_serializing_if = "Option::is_none",
768 with = "::serde_with::rust::double_option"
769 )]
770 pub public_host: Option<Option<compact_str::CompactString>>,
771 #[serde(
772 default,
773 skip_serializing_if = "Option::is_none",
774 with = "::serde_with::rust::double_option"
775 )]
776 #[garde(range(min = 1))]
777 #[schema(minimum = 1)]
778 pub public_port: Option<Option<u16>>,
779
780 #[garde(dive)]
781 pub credentials: Option<DatabaseCredentials>,
782}
783
784#[async_trait::async_trait]
785impl UpdatableModel for DatabaseHost {
786 type UpdateOptions = UpdateDatabaseHostOptions;
787
788 fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
789 static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<DatabaseHost>> =
790 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
791
792 &UPDATE_LISTENERS
793 }
794
795 async fn update_with_transaction(
796 &mut self,
797 state: &crate::State,
798 mut options: Self::UpdateOptions,
799 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
800 ) -> Result<(), crate::database::DatabaseError> {
801 options.validate()?;
802
803 let mut query_builder = UpdateQueryBuilder::new("database_hosts");
804
805 self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
806 .await?;
807
808 if let Some(credentials) = &mut options.credentials {
809 credentials.encrypt(&state.database).await?;
810 }
811
812 query_builder
813 .set("name", options.name.as_ref())
814 .set("deployment_enabled", options.deployment_enabled)
815 .set("maintenance_enabled", options.maintenance_enabled)
816 .set("public_host", options.public_host.as_ref())
817 .set(
818 "public_port",
819 options
820 .public_port
821 .as_ref()
822 .map(|p| p.as_ref().map(|port| *port as i32)),
823 )
824 .set(
825 "credentials",
826 options
827 .credentials
828 .as_ref()
829 .map(serde_json::to_value)
830 .transpose()?,
831 )
832 .where_eq("uuid", self.uuid);
833
834 query_builder.execute(&mut **transaction).await?;
835
836 if let Some(name) = options.name {
837 self.name = name;
838 }
839 if let Some(deployment_enabled) = options.deployment_enabled {
840 self.deployment_enabled = deployment_enabled;
841 }
842 if let Some(maintenance_enabled) = options.maintenance_enabled {
843 self.maintenance_enabled = maintenance_enabled;
844 }
845 if let Some(public_host) = options.public_host {
846 self.public_host = public_host;
847 }
848 if let Some(public_port) = options.public_port {
849 self.public_port = public_port.map(|port| port as i32);
850 }
851 if let Some(credentials) = options.credentials {
852 self.credentials = credentials;
853 }
854
855 self.run_after_update_handlers(state, transaction).await?;
856
857 Ok(())
858 }
859}
860
861#[async_trait::async_trait]
862impl DeletableModel for DatabaseHost {
863 type DeleteOptions = ();
864
865 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
866 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<DatabaseHost>> =
867 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
868
869 &DELETE_LISTENERS
870 }
871
872 async fn delete_with_transaction(
873 &self,
874 state: &crate::State,
875 options: Self::DeleteOptions,
876 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
877 ) -> Result<(), anyhow::Error> {
878 self.run_delete_handlers(&options, state, transaction)
879 .await?;
880
881 sqlx::query(
882 r#"
883 DELETE FROM database_hosts
884 WHERE database_hosts.uuid = $1
885 "#,
886 )
887 .bind(self.uuid)
888 .execute(&mut **transaction)
889 .await?;
890
891 self.run_after_delete_handlers(&options, state, transaction)
892 .await?;
893
894 Ok(())
895 }
896}
897
898#[schema_extension_derive::extendible]
899#[init_args(DatabaseHost, crate::State)]
900#[hook_args(crate::State)]
901#[derive(ToSchema, Serialize)]
902#[schema(title = "AdminDatabaseHost")]
903pub struct AdminApiDatabaseHost {
904 pub uuid: uuid::Uuid,
905
906 pub name: compact_str::CompactString,
907 pub deployment_enabled: bool,
908 pub maintenance_enabled: bool,
909 pub r#type: DatabaseType,
910
911 pub public_host: Option<compact_str::CompactString>,
912 pub public_port: Option<i32>,
913
914 pub credentials: DatabaseCredentials,
915
916 pub created: chrono::DateTime<chrono::Utc>,
917}
918
919#[schema_extension_derive::extendible]
920#[init_args(DatabaseHost, crate::State)]
921#[hook_args(crate::State)]
922#[derive(ToSchema, Serialize)]
923#[schema(title = "DatabaseHost")]
924pub struct ApiDatabaseHost {
925 pub uuid: uuid::Uuid,
926
927 pub name: compact_str::CompactString,
928 pub maintenance_enabled: bool,
929 pub r#type: DatabaseType,
930
931 pub host: compact_str::CompactString,
932 pub port: i32,
933}