Skip to main content

shared/models/
database_host.rs

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