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