Skip to main content

shared/models/node/
mod.rs

1use crate::{
2    models::{
3        CreatableModel, CreateListenerList, InsertQueryBuilder, UpdatableModel, UpdateHandlerList,
4        UpdateQueryBuilder,
5    },
6    prelude::*,
7};
8use compact_str::ToCompactString;
9use garde::Validate;
10use rand::distr::SampleString;
11use serde::{Deserialize, Serialize};
12use sqlx::{Row, postgres::PgRow};
13use std::{
14    collections::{BTreeMap, HashMap},
15    sync::{Arc, LazyLock},
16};
17use utoipa::ToSchema;
18
19mod events;
20pub use events::NodeEvent;
21
22pub type GetNode = crate::extract::ConsumingExtension<Node>;
23
24#[derive(Serialize, Deserialize, Clone)]
25pub struct Node {
26    pub uuid: uuid::Uuid,
27    pub location: super::location::Location,
28    pub backup_configuration: Option<Fetchable<super::backup_configuration::BackupConfiguration>>,
29
30    pub name: compact_str::CompactString,
31    pub description: Option<compact_str::CompactString>,
32
33    pub deployment_enabled: bool,
34    pub maintenance_enabled: bool,
35
36    pub public_url: Option<reqwest::Url>,
37    pub url: reqwest::Url,
38    pub sftp_host: Option<compact_str::CompactString>,
39    pub sftp_port: i32,
40
41    pub memory: i64,
42    pub disk: i64,
43
44    pub token_id: compact_str::CompactString,
45    pub token: Vec<u8>,
46
47    pub created: chrono::NaiveDateTime,
48
49    extension_data: super::ModelExtensionData,
50}
51
52impl BaseModel for Node {
53    const NAME: &'static str = "node";
54
55    fn get_extension_list() -> &'static super::ModelExtensionList {
56        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
57            LazyLock::new(|| std::sync::RwLock::new(Vec::new()));
58
59        &EXTENSIONS
60    }
61
62    fn get_extension_data(&self) -> &super::ModelExtensionData {
63        &self.extension_data
64    }
65
66    #[inline]
67    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
68        let prefix = prefix.unwrap_or_default();
69
70        let mut columns = BTreeMap::from([
71            ("nodes.uuid", compact_str::format_compact!("{prefix}uuid")),
72            (
73                "nodes.backup_configuration_uuid",
74                compact_str::format_compact!("{prefix}node_backup_configuration_uuid"),
75            ),
76            ("nodes.name", compact_str::format_compact!("{prefix}name")),
77            (
78                "nodes.description",
79                compact_str::format_compact!("{prefix}description"),
80            ),
81            (
82                "nodes.deployment_enabled",
83                compact_str::format_compact!("{prefix}deployment_enabled"),
84            ),
85            (
86                "nodes.maintenance_enabled",
87                compact_str::format_compact!("{prefix}maintenance_enabled"),
88            ),
89            (
90                "nodes.public_url",
91                compact_str::format_compact!("{prefix}public_url"),
92            ),
93            ("nodes.url", compact_str::format_compact!("{prefix}url")),
94            (
95                "nodes.sftp_host",
96                compact_str::format_compact!("{prefix}sftp_host"),
97            ),
98            (
99                "nodes.sftp_port",
100                compact_str::format_compact!("{prefix}sftp_port"),
101            ),
102            (
103                "nodes.memory",
104                compact_str::format_compact!("{prefix}memory"),
105            ),
106            ("nodes.disk", compact_str::format_compact!("{prefix}disk")),
107            (
108                "nodes.token_id",
109                compact_str::format_compact!("{prefix}token_id"),
110            ),
111            ("nodes.token", compact_str::format_compact!("{prefix}token")),
112            (
113                "nodes.created",
114                compact_str::format_compact!("{prefix}created"),
115            ),
116        ]);
117
118        columns.extend(super::location::Location::base_columns(Some("location_")));
119
120        columns
121    }
122
123    #[inline]
124    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
125        let prefix = prefix.unwrap_or_default();
126
127        Ok(Self {
128            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
129            location: super::location::Location::map(Some("location_"), row)?,
130            backup_configuration:
131                super::backup_configuration::BackupConfiguration::get_fetchable_from_row(
132                    row,
133                    compact_str::format_compact!("{prefix}node_backup_configuration_uuid"),
134                ),
135            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
136            description: row
137                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
138            deployment_enabled: row
139                .try_get(compact_str::format_compact!("{prefix}deployment_enabled").as_str())?,
140            maintenance_enabled: row
141                .try_get(compact_str::format_compact!("{prefix}maintenance_enabled").as_str())?,
142            public_url: row
143                .try_get::<Option<String>, _>(
144                    compact_str::format_compact!("{prefix}public_url").as_str(),
145                )?
146                .try_map(|url| url.parse())
147                .map_err(anyhow::Error::new)?,
148            url: row
149                .try_get::<String, _>(compact_str::format_compact!("{prefix}url").as_str())?
150                .parse()
151                .map_err(anyhow::Error::new)?,
152            sftp_host: row.try_get(compact_str::format_compact!("{prefix}sftp_host").as_str())?,
153            sftp_port: row.try_get(compact_str::format_compact!("{prefix}sftp_port").as_str())?,
154            memory: row.try_get(compact_str::format_compact!("{prefix}memory").as_str())?,
155            disk: row.try_get(compact_str::format_compact!("{prefix}disk").as_str())?,
156            token_id: row.try_get(compact_str::format_compact!("{prefix}token_id").as_str())?,
157            token: row.try_get(compact_str::format_compact!("{prefix}token").as_str())?,
158            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
159            extension_data: Self::map_extensions(prefix, row)?,
160        })
161    }
162}
163
164impl Node {
165    pub const AIO_NODE_UUID: uuid::Uuid = uuid::uuid!("7dbbbb63-1734-48c4-e1de-d1a65f62cada");
166
167    pub async fn by_token_id_token_cached(
168        database: &crate::database::Database,
169        token_id: &str,
170        token: &str,
171    ) -> Result<Option<Self>, anyhow::Error> {
172        database
173            .cache
174            .cached(&format!("node::token::{token_id}.{token}"), 10, || async {
175                let row = sqlx::query(&format!(
176                    r#"
177                    SELECT {}
178                    FROM nodes
179                    JOIN locations ON locations.uuid = nodes.location_uuid
180                    WHERE nodes.token_id = $1
181                    "#,
182                    Self::columns_sql(None)
183                ))
184                .bind(token_id)
185                .fetch_optional(database.read())
186                .await?;
187
188                Ok::<_, anyhow::Error>(
189                    if let Some(node) = row.try_map(|row| Self::map(None, &row))? {
190                        if constant_time_eq::constant_time_eq(
191                            database.decrypt(node.token.clone()).await?.as_bytes(),
192                            token.as_bytes(),
193                        ) {
194                            Some(node)
195                        } else {
196                            None
197                        }
198                    } else {
199                        None
200                    },
201                )
202            })
203            .await
204    }
205
206    pub async fn by_location_uuid_with_pagination(
207        database: &crate::database::Database,
208        location_uuid: uuid::Uuid,
209        page: i64,
210        per_page: i64,
211        search: Option<&str>,
212    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
213        let offset = (page - 1) * per_page;
214
215        let rows = sqlx::query(&format!(
216            r#"
217            SELECT {}, COUNT(*) OVER() AS total_count
218            FROM nodes
219            JOIN locations ON locations.uuid = nodes.location_uuid
220            WHERE nodes.location_uuid = $1 AND ($2 IS NULL OR nodes.name ILIKE '%' || $2 || '%')
221            ORDER BY nodes.created
222            LIMIT $3 OFFSET $4
223            "#,
224            Self::columns_sql(None)
225        ))
226        .bind(location_uuid)
227        .bind(search)
228        .bind(per_page)
229        .bind(offset)
230        .fetch_all(database.read())
231        .await?;
232
233        Ok(super::Pagination {
234            total: rows
235                .first()
236                .map_or(Ok(0), |row| row.try_get("total_count"))?,
237            per_page,
238            page,
239            data: rows
240                .into_iter()
241                .map(|row| Self::map(None, &row))
242                .try_collect_vec()?,
243        })
244    }
245
246    pub async fn by_backup_configuration_uuid_with_pagination(
247        database: &crate::database::Database,
248        backup_configuration_uuid: uuid::Uuid,
249        page: i64,
250        per_page: i64,
251        search: Option<&str>,
252    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
253        let offset = (page - 1) * per_page;
254
255        let rows = sqlx::query(&format!(
256            r#"
257            SELECT {}, COUNT(*) OVER() AS total_count
258            FROM nodes
259            JOIN locations ON locations.uuid = nodes.location_uuid
260            WHERE nodes.backup_configuration_uuid = $1 AND ($2 IS NULL OR nodes.name ILIKE '%' || $2 || '%')
261            ORDER BY nodes.created
262            LIMIT $3 OFFSET $4
263            "#,
264            Self::columns_sql(None)
265        ))
266        .bind(backup_configuration_uuid)
267        .bind(search)
268        .bind(per_page)
269        .bind(offset)
270        .fetch_all(database.read())
271        .await?;
272
273        Ok(super::Pagination {
274            total: rows
275                .first()
276                .map_or(Ok(0), |row| row.try_get("total_count"))?,
277            per_page,
278            page,
279            data: rows
280                .into_iter()
281                .map(|row| Self::map(None, &row))
282                .try_collect_vec()?,
283        })
284    }
285
286    pub async fn all_with_pagination(
287        database: &crate::database::Database,
288        page: i64,
289        per_page: i64,
290        search: Option<&str>,
291    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
292        let offset = (page - 1) * per_page;
293
294        let rows = sqlx::query(&format!(
295            r#"
296            SELECT {}, COUNT(*) OVER() AS total_count
297            FROM nodes
298            JOIN locations ON locations.uuid = nodes.location_uuid
299            WHERE $1 IS NULL OR nodes.name ILIKE '%' || $1 || '%'
300            ORDER BY nodes.created
301            LIMIT $2 OFFSET $3
302            "#,
303            Self::columns_sql(None)
304        ))
305        .bind(search)
306        .bind(per_page)
307        .bind(offset)
308        .fetch_all(database.read())
309        .await?;
310
311        Ok(super::Pagination {
312            total: rows
313                .first()
314                .map_or(Ok(0), |row| row.try_get("total_count"))?,
315            per_page,
316            page,
317            data: rows
318                .into_iter()
319                .map(|row| Self::map(None, &row))
320                .try_collect_vec()?,
321        })
322    }
323
324    pub async fn by_location_uuids_most_eligible(
325        database: &crate::database::Database,
326        location_uuids: &[uuid::Uuid],
327        limits: super::server::AdminApiServerLimits,
328        allow_overallocation: bool,
329    ) -> Result<Vec<Self>, crate::database::DatabaseError> {
330        let rows = sqlx::query(&format!(
331            r#"
332            WITH server_usage AS (
333                SELECT
334                    node_uuid,
335                    COALESCE(SUM(memory), 0)::BIGINT AS used_memory,
336                    COALESCE(SUM(disk), 0)::BIGINT AS used_disk
337                FROM servers
338                GROUP BY node_uuid
339            )
340            SELECT {}
341            FROM nodes
342            JOIN locations ON locations.uuid = nodes.location_uuid
343            LEFT JOIN server_usage u ON nodes.uuid = u.node_uuid
344            WHERE nodes.location_uuid = ANY($1)
345            AND nodes.deployment_enabled
346            AND (
347                $4 OR (
348                    COALESCE(u.used_memory, 0) + $2 <= nodes.memory
349                    AND COALESCE(u.used_disk, 0) + $3 <= nodes.disk
350                )
351            )
352            ORDER BY
353                (
354                    GREATEST(COALESCE(u.used_memory, 0) + $2 - nodes.memory, 0) + 
355                    GREATEST(COALESCE(u.used_disk, 0) + $3 - nodes.disk, 0)
356                ),
357                GREATEST(
358                    (COALESCE(u.used_memory, 0) + $2)::FLOAT / NULLIF(nodes.memory, 0), 
359                    (COALESCE(u.used_disk, 0) + $3)::FLOAT / NULLIF(nodes.disk, 0)
360                )
361            "#,
362            Self::columns_sql(None),
363        ))
364        .bind(location_uuids)
365        .bind(limits.memory)
366        .bind(limits.disk)
367        .bind(allow_overallocation)
368        .fetch_all(database.read())
369        .await?;
370
371        rows.into_iter()
372            .map(|row| Self::map(None, &row))
373            .try_collect_vec()
374    }
375
376    pub async fn by_name(
377        database: &crate::database::Database,
378        name: &str,
379    ) -> Result<Option<Self>, crate::database::DatabaseError> {
380        let row = sqlx::query(&format!(
381            r#"
382            SELECT {}
383            FROM nodes
384            JOIN locations ON locations.uuid = nodes.location_uuid
385            WHERE nodes.name = $1
386            "#,
387            Self::columns_sql(None)
388        ))
389        .bind(name)
390        .fetch_optional(database.read())
391        .await?;
392
393        row.try_map(|row| Self::map(None, &row))
394    }
395
396    pub async fn count_by_location_uuid(
397        database: &crate::database::Database,
398        location_uuid: uuid::Uuid,
399    ) -> Result<i64, sqlx::Error> {
400        sqlx::query_scalar(
401            r#"
402            SELECT COUNT(*)
403            FROM nodes
404            WHERE nodes.location_uuid = $1
405            "#,
406        )
407        .bind(location_uuid)
408        .fetch_one(database.read())
409        .await
410    }
411
412    /// Fetch the current configuration of this node
413    ///
414    /// Cached for 120 seconds.
415    pub async fn fetch_configuration(
416        &self,
417        database: &crate::database::Database,
418    ) -> Result<wings_api::Config, anyhow::Error> {
419        database
420            .cache
421            .cached(
422                &format!("node::{}::configuration", self.uuid),
423                120,
424                || async {
425                    Ok::<_, anyhow::Error>(
426                        self.api_client(database).await?.get_system_config().await?,
427                    )
428                },
429            )
430            .await
431    }
432
433    /// Fetch the current resource usages of all servers on this node.
434    ///
435    /// Cached for 15 seconds.
436    pub async fn fetch_server_resources(
437        &self,
438        database: &crate::database::Database,
439    ) -> Result<HashMap<uuid::Uuid, wings_api::ResourceUsage>, anyhow::Error> {
440        database
441            .cache
442            .cached(
443                &format!("node::{}::server_resources", self.uuid),
444                15,
445                || async {
446                    let resources = self
447                        .api_client(database)
448                        .await?
449                        .get_servers_utilization()
450                        .await?;
451
452                    Ok::<_, anyhow::Error>(resources.into_iter().collect())
453                },
454            )
455            .await
456    }
457
458    pub async fn reset_token(
459        &self,
460        state: &crate::State,
461    ) -> Result<(String, String), anyhow::Error> {
462        let token_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
463        let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64);
464
465        sqlx::query(
466            r#"
467            UPDATE nodes
468            SET token_id = $2, token = $3
469            WHERE nodes.uuid = $1
470            "#,
471        )
472        .bind(self.uuid)
473        .bind(&token_id)
474        .bind(state.database.encrypt(token.clone()).await?)
475        .execute(state.database.write())
476        .await?;
477
478        Self::get_event_emitter().emit(
479            state.clone(),
480            NodeEvent::TokenReset {
481                node: Box::new(self.clone()),
482                token_id: token_id.clone(),
483                token: token.clone(),
484            },
485        );
486
487        Ok((token_id, token))
488    }
489
490    #[inline]
491    pub fn is_all_in_one_node(&self) -> bool {
492        self.uuid == Self::AIO_NODE_UUID
493    }
494
495    #[inline]
496    pub fn url(&self, path: &str) -> reqwest::Url {
497        let mut url = self.url.clone();
498        url.path_segments_mut()
499            .unwrap()
500            .extend(path.trim_start_matches('/').split('/'));
501        url
502    }
503
504    #[inline]
505    pub async fn public_url(
506        &self,
507        state: &crate::State,
508        path: &str,
509    ) -> Result<reqwest::Url, anyhow::Error> {
510        let mut url = if self.is_all_in_one_node() {
511            let mut url = state
512                .settings
513                .get_as(|s| reqwest::Url::parse(&s.app.url))
514                .await??;
515            url.path_segments_mut()
516                .unwrap()
517                .extend(&["wings-proxy", &self.uuid.to_compact_string()]);
518            url
519        } else {
520            self.public_url.clone().unwrap_or(self.url.clone())
521        };
522
523        url.path_segments_mut()
524            .unwrap()
525            .extend(path.trim_start_matches('/').split('/'));
526
527        Ok(url)
528    }
529
530    #[inline]
531    pub async fn api_client(
532        &self,
533        database: &crate::database::Database,
534    ) -> Result<wings_api::client::WingsClient, anyhow::Error> {
535        Ok(wings_api::client::WingsClient::new(
536            self.url.to_string(),
537            database.decrypt(self.token.to_vec()).await?.into(),
538        ))
539    }
540
541    #[inline]
542    pub fn create_jwt<T: Serialize>(
543        &self,
544        database: &crate::database::Database,
545        jwt: &crate::jwt::Jwt,
546        payload: &T,
547    ) -> Result<String, jsonwebtoken::errors::Error> {
548        jwt.create_custom(
549            database.blocking_decrypt(&self.token).unwrap().as_bytes(),
550            payload,
551        )
552    }
553}
554
555#[async_trait::async_trait]
556impl IntoAdminApiObject for Node {
557    type AdminApiObject = AdminApiNode;
558    type ExtraArgs<'a> = ();
559
560    async fn into_admin_api_object<'a>(
561        self,
562        state: &crate::State,
563        _args: Self::ExtraArgs<'a>,
564    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
565        let api_object = AdminApiNode::init_hooks(&self, state).await?;
566
567        let public_url = if self.is_all_in_one_node() {
568            Some(self.public_url(state, "/").await?.to_string())
569        } else {
570            self.public_url.map(|url| url.to_string())
571        };
572
573        let (location, backup_configuration) =
574            tokio::join!(self.location.into_admin_api_object(state, ()), async {
575                if let Some(backup_configuration) = self.backup_configuration {
576                    if let Ok(backup_configuration) =
577                        backup_configuration.fetch_cached(&state.database).await
578                    {
579                        backup_configuration
580                            .into_admin_api_object(state, ())
581                            .await
582                            .ok()
583                    } else {
584                        None
585                    }
586                } else {
587                    None
588                }
589            });
590
591        let api_object = finish_extendible!(
592            AdminApiNode {
593                uuid: self.uuid,
594                location: location?,
595                backup_configuration,
596                name: self.name,
597                description: self.description,
598                deployment_enabled: self.deployment_enabled,
599                maintenance_enabled: self.maintenance_enabled,
600                public_url,
601                url: self.url.to_string(),
602                sftp_host: self.sftp_host,
603                sftp_port: self.sftp_port,
604                memory: self.memory,
605                disk: self.disk,
606                token_id: self.token_id,
607                token: state.database.decrypt(self.token).await?,
608                created: self.created.and_utc(),
609            },
610            api_object,
611            state
612        )?;
613
614        Ok(api_object)
615    }
616}
617
618#[async_trait::async_trait]
619impl ByUuid for Node {
620    async fn by_uuid(
621        database: &crate::database::Database,
622        uuid: uuid::Uuid,
623    ) -> Result<Self, crate::database::DatabaseError> {
624        let row = sqlx::query(&format!(
625            r#"
626            SELECT {}, {}
627            FROM nodes
628            JOIN locations ON locations.uuid = nodes.location_uuid
629            WHERE nodes.uuid = $1
630            "#,
631            Self::columns_sql(None),
632            super::location::Location::columns_sql(Some("location_")),
633        ))
634        .bind(uuid)
635        .fetch_one(database.read())
636        .await?;
637
638        Self::map(None, &row)
639    }
640
641    async fn by_uuid_with_transaction(
642        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
643        uuid: uuid::Uuid,
644    ) -> Result<Self, crate::database::DatabaseError> {
645        let row = sqlx::query(&format!(
646            r#"
647            SELECT {}, {}
648            FROM nodes
649            JOIN locations ON locations.uuid = nodes.location_uuid
650            WHERE nodes.uuid = $1
651            "#,
652            Self::columns_sql(None),
653            super::location::Location::columns_sql(Some("location_")),
654        ))
655        .bind(uuid)
656        .fetch_one(&mut **transaction)
657        .await?;
658
659        Self::map(None, &row)
660    }
661}
662
663#[derive(ToSchema, Deserialize, Validate)]
664pub struct CreateNodeOptions {
665    #[garde(skip)]
666    pub location_uuid: uuid::Uuid,
667    #[garde(skip)]
668    pub backup_configuration_uuid: Option<uuid::Uuid>,
669    #[garde(length(chars, min = 1, max = 255))]
670    #[schema(min_length = 1, max_length = 255)]
671    pub name: compact_str::CompactString,
672    #[garde(length(chars, min = 1, max = 1024))]
673    #[schema(min_length = 1, max_length = 1024)]
674    pub description: Option<compact_str::CompactString>,
675    #[garde(skip)]
676    pub deployment_enabled: bool,
677    #[garde(skip)]
678    pub maintenance_enabled: bool,
679    #[garde(length(chars, min = 3, max = 255), url)]
680    #[schema(min_length = 3, max_length = 255, format = "uri")]
681    pub public_url: Option<compact_str::CompactString>,
682    #[garde(length(chars, min = 3, max = 255), url)]
683    #[schema(min_length = 3, max_length = 255, format = "uri")]
684    pub url: compact_str::CompactString,
685    #[garde(length(chars, min = 3, max = 255))]
686    #[schema(min_length = 3, max_length = 255)]
687    pub sftp_host: Option<compact_str::CompactString>,
688    #[garde(range(min = 1))]
689    #[schema(minimum = 1)]
690    pub sftp_port: u16,
691    #[garde(range(min = 1))]
692    #[schema(minimum = 1)]
693    pub memory: i64,
694    #[garde(range(min = 1))]
695    #[schema(minimum = 1)]
696    pub disk: i64,
697}
698
699#[async_trait::async_trait]
700impl CreatableModel for Node {
701    type CreateOptions<'a> = CreateNodeOptions;
702    type CreateResult = Self;
703
704    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
705        static CREATE_LISTENERS: LazyLock<CreateListenerList<Node>> =
706            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
707
708        &CREATE_LISTENERS
709    }
710
711    async fn create_with_transaction(
712        state: &crate::State,
713        mut options: Self::CreateOptions<'_>,
714        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
715    ) -> Result<Self, crate::database::DatabaseError> {
716        options.validate()?;
717
718        if let Some(backup_configuration_uuid) = &options.backup_configuration_uuid {
719            super::backup_configuration::BackupConfiguration::by_uuid_optional(
720                &state.database,
721                *backup_configuration_uuid,
722            )
723            .await?
724            .ok_or(crate::database::InvalidRelationError(
725                "backup_configuration",
726            ))?;
727        }
728
729        let mut query_builder = InsertQueryBuilder::new("nodes");
730
731        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
732
733        let token_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
734        let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64);
735
736        query_builder
737            .set("location_uuid", options.location_uuid)
738            .set(
739                "backup_configuration_uuid",
740                options.backup_configuration_uuid,
741            )
742            .set("name", &options.name)
743            .set("description", &options.description)
744            .set("deployment_enabled", options.deployment_enabled)
745            .set("maintenance_enabled", options.maintenance_enabled)
746            .set("public_url", &options.public_url)
747            .set("url", &options.url)
748            .set("sftp_host", &options.sftp_host)
749            .set("sftp_port", options.sftp_port as i32)
750            .set("memory", options.memory)
751            .set("disk", options.disk)
752            .set("token_id", token_id.clone())
753            .set("token", state.database.encrypt(token.clone()).await?);
754
755        let row = query_builder
756            .returning("uuid")
757            .fetch_one(&mut **transaction)
758            .await?;
759        let uuid: uuid::Uuid = row.try_get("uuid")?;
760
761        let mut result = Self::by_uuid_with_transaction(transaction, uuid).await?;
762
763        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
764
765        Ok(result)
766    }
767}
768
769#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
770pub struct UpdateNodeOptions {
771    #[garde(skip)]
772    pub location_uuid: Option<uuid::Uuid>,
773    #[serde(
774        default,
775        skip_serializing_if = "Option::is_none",
776        with = "::serde_with::rust::double_option"
777    )]
778    #[garde(skip)]
779    pub backup_configuration_uuid: Option<Option<uuid::Uuid>>,
780    #[garde(length(chars, min = 1, max = 255))]
781    #[schema(min_length = 1, max_length = 255)]
782    pub name: Option<compact_str::CompactString>,
783    #[garde(length(chars, min = 1, max = 1024))]
784    #[schema(min_length = 1, max_length = 1024)]
785    #[serde(
786        default,
787        skip_serializing_if = "Option::is_none",
788        with = "::serde_with::rust::double_option"
789    )]
790    pub description: Option<Option<compact_str::CompactString>>,
791    #[garde(skip)]
792    pub deployment_enabled: Option<bool>,
793    #[garde(skip)]
794    pub maintenance_enabled: Option<bool>,
795    #[garde(length(chars, min = 3, max = 255), url)]
796    #[schema(min_length = 3, max_length = 255, format = "uri")]
797    #[serde(
798        default,
799        skip_serializing_if = "Option::is_none",
800        with = "::serde_with::rust::double_option"
801    )]
802    pub public_url: Option<Option<compact_str::CompactString>>,
803    #[garde(length(chars, min = 3, max = 255), url)]
804    #[schema(min_length = 3, max_length = 255, format = "uri")]
805    pub url: Option<compact_str::CompactString>,
806    #[garde(length(chars, min = 3, max = 255))]
807    #[schema(min_length = 3, max_length = 255)]
808    #[serde(
809        default,
810        skip_serializing_if = "Option::is_none",
811        with = "::serde_with::rust::double_option"
812    )]
813    pub sftp_host: Option<Option<compact_str::CompactString>>,
814    #[garde(range(min = 1))]
815    #[schema(minimum = 1)]
816    pub sftp_port: Option<u16>,
817    #[garde(range(min = 1))]
818    #[schema(minimum = 1)]
819    pub memory: Option<i64>,
820    #[garde(range(min = 1))]
821    #[schema(minimum = 1)]
822    pub disk: Option<i64>,
823}
824
825#[async_trait::async_trait]
826impl UpdatableModel for Node {
827    type UpdateOptions = UpdateNodeOptions;
828
829    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
830        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<Node>> =
831            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
832
833        &UPDATE_LISTENERS
834    }
835
836    async fn update_with_transaction(
837        &mut self,
838        state: &crate::State,
839        mut options: Self::UpdateOptions,
840        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
841    ) -> Result<(), crate::database::DatabaseError> {
842        options.validate()?;
843
844        let location = if let Some(location_uuid) = options.location_uuid {
845            Some(
846                super::location::Location::by_uuid_optional(&state.database, location_uuid)
847                    .await?
848                    .ok_or(crate::database::InvalidRelationError("location"))?,
849            )
850        } else {
851            None
852        };
853
854        let backup_configuration =
855            if let Some(backup_configuration_uuid) = &options.backup_configuration_uuid {
856                match backup_configuration_uuid {
857                    Some(uuid) => {
858                        super::backup_configuration::BackupConfiguration::by_uuid_optional(
859                            &state.database,
860                            *uuid,
861                        )
862                        .await?
863                        .ok_or(crate::database::InvalidRelationError(
864                            "backup_configuration",
865                        ))?;
866
867                        Some(Some(
868                            super::backup_configuration::BackupConfiguration::get_fetchable(*uuid),
869                        ))
870                    }
871                    None => Some(None),
872                }
873            } else {
874                None
875            };
876
877        let mut query_builder = UpdateQueryBuilder::new("nodes");
878
879        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
880            .await?;
881
882        query_builder
883            .set("location_uuid", options.location_uuid.as_ref())
884            .set(
885                "backup_configuration_uuid",
886                options
887                    .backup_configuration_uuid
888                    .as_ref()
889                    .map(|u| u.as_ref()),
890            )
891            .set("name", options.name.as_ref())
892            .set(
893                "description",
894                options.description.as_ref().map(|d| d.as_ref()),
895            )
896            .set("deployment_enabled", options.deployment_enabled)
897            .set("maintenance_enabled", options.maintenance_enabled)
898            .set(
899                "public_url",
900                options.public_url.as_ref().map(|u| u.as_ref()),
901            )
902            .set("url", options.url.as_ref())
903            .set("sftp_host", options.sftp_host.as_ref().map(|h| h.as_ref()))
904            .set("sftp_port", options.sftp_port.as_ref().map(|p| *p as i32))
905            .set("memory", options.memory.as_ref())
906            .set("disk", options.disk.as_ref())
907            .where_eq("uuid", self.uuid);
908
909        query_builder.execute(&mut **transaction).await?;
910
911        if let Some(location) = location {
912            self.location = location;
913        }
914        if let Some(backup_configuration) = backup_configuration {
915            self.backup_configuration = backup_configuration;
916        }
917        if let Some(name) = options.name {
918            self.name = name;
919        }
920        if let Some(description) = options.description {
921            self.description = description;
922        }
923        if let Some(deployment_enabled) = options.deployment_enabled {
924            self.deployment_enabled = deployment_enabled;
925        }
926        if let Some(maintenance_enabled) = options.maintenance_enabled {
927            self.maintenance_enabled = maintenance_enabled;
928        }
929        if let Some(public_url) = options.public_url {
930            self.public_url = public_url
931                .try_map(|url| url.parse())
932                .map_err(anyhow::Error::new)?;
933        }
934        if let Some(url) = options.url {
935            self.url = url.parse().map_err(anyhow::Error::new)?;
936        }
937        if let Some(sftp_host) = options.sftp_host {
938            self.sftp_host = sftp_host;
939        }
940        if let Some(sftp_port) = options.sftp_port {
941            self.sftp_port = sftp_port as i32;
942        }
943        if let Some(memory) = options.memory {
944            self.memory = memory;
945        }
946        if let Some(disk) = options.disk {
947            self.disk = disk;
948        }
949
950        self.run_after_update_handlers(state, transaction).await?;
951
952        Ok(())
953    }
954}
955
956#[async_trait::async_trait]
957impl DeletableModel for Node {
958    type DeleteOptions = ();
959
960    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
961        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<Node>> =
962            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
963
964        &DELETE_LISTENERS
965    }
966
967    async fn delete_with_transaction(
968        &self,
969        state: &crate::State,
970        options: Self::DeleteOptions,
971        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
972    ) -> Result<(), anyhow::Error> {
973        if self.is_all_in_one_node() && state.container_type.is_all_in_one() {
974            return Err(anyhow::anyhow!("The AIO node cannot be deleted"));
975        }
976
977        self.run_delete_handlers(&options, state, transaction)
978            .await?;
979
980        sqlx::query(
981            r#"
982            DELETE FROM nodes
983            WHERE nodes.uuid = $1
984            "#,
985        )
986        .bind(self.uuid)
987        .execute(&mut **transaction)
988        .await?;
989
990        self.run_after_delete_handlers(&options, state, transaction)
991            .await?;
992
993        Ok(())
994    }
995}
996
997#[schema_extension_derive::extendible]
998#[init_args(Node, crate::State)]
999#[hook_args(crate::State)]
1000#[derive(ToSchema, Serialize)]
1001#[schema(title = "Node")]
1002pub struct AdminApiNode {
1003    pub uuid: uuid::Uuid,
1004    pub location: super::location::AdminApiLocation,
1005    pub backup_configuration: Option<super::backup_configuration::AdminApiBackupConfiguration>,
1006
1007    pub name: compact_str::CompactString,
1008    pub description: Option<compact_str::CompactString>,
1009
1010    pub deployment_enabled: bool,
1011    pub maintenance_enabled: bool,
1012
1013    #[schema(format = "uri")]
1014    pub public_url: Option<String>,
1015    #[schema(format = "uri")]
1016    pub url: String,
1017    pub sftp_host: Option<compact_str::CompactString>,
1018    pub sftp_port: i32,
1019
1020    pub memory: i64,
1021    pub disk: i64,
1022
1023    pub token_id: compact_str::CompactString,
1024    pub token: compact_str::CompactString,
1025
1026    pub created: chrono::DateTime<chrono::Utc>,
1027}