shared/models/node/
mod.rs

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