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