Skip to main content

shared/models/
database_host.rs

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