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::{RngExt, distr::SampleString};
11use serde::{Deserialize, Serialize};
12use sha2::Digest;
13use sqlx::{Row, postgres::PgRow};
14use std::{
15    collections::{BTreeMap, HashMap},
16    sync::{Arc, LazyLock},
17};
18use utoipa::ToSchema;
19
20mod events;
21pub use events::NodeEvent;
22
23pub type GetNode = crate::extract::ConsumingExtension<Node>;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum NodeDeploymentBlocker {
27    /// No nodes exist in the selected location(s).
28    NoNodes,
29    /// Nodes exist but deployment is disabled on all of them.
30    DeploymentDisabled,
31    /// No node has enough unallocated memory.
32    InsufficientMemory,
33    /// No node has enough unallocated disk.
34    InsufficientDisk,
35    /// No node has enough unallocated memory or disk.
36    InsufficientResources,
37    /// Memory and disk are each available somewhere, but no single node has
38    /// enough of both at once.
39    ResourcesSplitAcrossNodes,
40}
41
42impl NodeDeploymentBlocker {
43    pub fn message(self) -> &'static str {
44        match self {
45            Self::NoNodes => "no nodes exist in the selected location(s)",
46            Self::DeploymentDisabled => {
47                "deployment is disabled on every node in the selected location(s)"
48            }
49            Self::InsufficientMemory => {
50                "no node in the selected location(s) has enough unallocated memory"
51            }
52            Self::InsufficientDisk => {
53                "no node in the selected location(s) has enough unallocated disk"
54            }
55            Self::InsufficientResources => {
56                "no node in the selected location(s) has enough unallocated memory or disk"
57            }
58            Self::ResourcesSplitAcrossNodes => {
59                "no single node in the selected location(s) has enough unallocated memory and disk at the same time"
60            }
61        }
62    }
63}
64
65#[derive(Serialize, Deserialize, Clone)]
66pub struct Node {
67    pub uuid: uuid::Uuid,
68    pub location: super::location::Location,
69    pub backup_configuration: Option<Fetchable<super::backup_configuration::BackupConfiguration>>,
70
71    pub name: compact_str::CompactString,
72    pub description: Option<compact_str::CompactString>,
73
74    pub deployment_enabled: bool,
75    pub maintenance_enabled: bool,
76
77    pub public_url: Option<reqwest::Url>,
78    pub url: reqwest::Url,
79    pub sftp_host: Option<compact_str::CompactString>,
80    pub sftp_port: i32,
81
82    pub memory: i64,
83    pub disk: i64,
84
85    pub token_id: compact_str::CompactString,
86    pub token: Vec<u8>,
87
88    pub created: chrono::NaiveDateTime,
89
90    extension_data: super::ModelExtensionData,
91}
92
93impl BaseModel for Node {
94    const NAME: &'static str = "node";
95
96    fn get_extension_list() -> &'static super::ModelExtensionList {
97        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
98            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
99
100        &EXTENSIONS
101    }
102
103    fn get_extension_data(&self) -> &super::ModelExtensionData {
104        &self.extension_data
105    }
106
107    #[inline]
108    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
109        let prefix = prefix.unwrap_or_default();
110
111        let mut columns = BTreeMap::from([
112            ("nodes.uuid", compact_str::format_compact!("{prefix}uuid")),
113            (
114                "nodes.backup_configuration_uuid",
115                compact_str::format_compact!("{prefix}node_backup_configuration_uuid"),
116            ),
117            ("nodes.name", compact_str::format_compact!("{prefix}name")),
118            (
119                "nodes.description",
120                compact_str::format_compact!("{prefix}description"),
121            ),
122            (
123                "nodes.deployment_enabled",
124                compact_str::format_compact!("{prefix}deployment_enabled"),
125            ),
126            (
127                "nodes.maintenance_enabled",
128                compact_str::format_compact!("{prefix}maintenance_enabled"),
129            ),
130            (
131                "nodes.public_url",
132                compact_str::format_compact!("{prefix}public_url"),
133            ),
134            ("nodes.url", compact_str::format_compact!("{prefix}url")),
135            (
136                "nodes.sftp_host",
137                compact_str::format_compact!("{prefix}sftp_host"),
138            ),
139            (
140                "nodes.sftp_port",
141                compact_str::format_compact!("{prefix}sftp_port"),
142            ),
143            (
144                "nodes.memory",
145                compact_str::format_compact!("{prefix}memory"),
146            ),
147            ("nodes.disk", compact_str::format_compact!("{prefix}disk")),
148            (
149                "nodes.token_id",
150                compact_str::format_compact!("{prefix}token_id"),
151            ),
152            ("nodes.token", compact_str::format_compact!("{prefix}token")),
153            (
154                "nodes.created",
155                compact_str::format_compact!("{prefix}created"),
156            ),
157        ]);
158
159        columns.extend(super::location::Location::base_columns(Some("location_")));
160
161        columns
162    }
163
164    #[inline]
165    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
166        let prefix = prefix.unwrap_or_default();
167
168        Ok(Self {
169            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
170            location: super::location::Location::map(Some("location_"), row)?,
171            backup_configuration:
172                super::backup_configuration::BackupConfiguration::get_fetchable_from_row(
173                    row,
174                    compact_str::format_compact!("{prefix}node_backup_configuration_uuid"),
175                ),
176            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
177            description: row
178                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
179            deployment_enabled: row
180                .try_get(compact_str::format_compact!("{prefix}deployment_enabled").as_str())?,
181            maintenance_enabled: row
182                .try_get(compact_str::format_compact!("{prefix}maintenance_enabled").as_str())?,
183            public_url: row
184                .try_get::<Option<String>, _>(
185                    compact_str::format_compact!("{prefix}public_url").as_str(),
186                )?
187                .try_map(|url| url.parse())
188                .map_err(anyhow::Error::new)?,
189            url: row
190                .try_get::<String, _>(compact_str::format_compact!("{prefix}url").as_str())?
191                .parse()
192                .map_err(anyhow::Error::new)?,
193            sftp_host: row.try_get(compact_str::format_compact!("{prefix}sftp_host").as_str())?,
194            sftp_port: row.try_get(compact_str::format_compact!("{prefix}sftp_port").as_str())?,
195            memory: row.try_get(compact_str::format_compact!("{prefix}memory").as_str())?,
196            disk: row.try_get(compact_str::format_compact!("{prefix}disk").as_str())?,
197            token_id: row.try_get(compact_str::format_compact!("{prefix}token_id").as_str())?,
198            token: row.try_get(compact_str::format_compact!("{prefix}token").as_str())?,
199            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
200            extension_data: Self::map_extensions(prefix, row)?,
201        })
202    }
203}
204
205impl Node {
206    pub const AIO_NODE_UUID: uuid::Uuid = uuid::uuid!("7dbbbb63-1734-48c4-e1de-d1a65f62cada");
207
208    pub async fn by_token_id_token_cached(
209        database: &crate::database::Database,
210        token_id: &str,
211        token: &str,
212    ) -> Result<Option<Self>, anyhow::Error> {
213        database
214            .cache
215            .cached(
216                &format!(
217                    "node::token::{token_id}.{}",
218                    hex::encode(sha2::Sha256::digest(token.as_bytes()))
219                ),
220                10,
221                || async {
222                    let row = sqlx::query(sqlx::AssertSqlSafe(format!(
223                        r#"
224                        SELECT {}
225                        FROM nodes
226                        JOIN locations ON locations.uuid = nodes.location_uuid
227                        WHERE nodes.token_id = $1
228                        "#,
229                        Self::columns_sql(None)
230                    )))
231                    .bind(token_id)
232                    .fetch_optional(database.read())
233                    .await?;
234
235                    Ok::<_, anyhow::Error>(
236                        if let Some(node) = row.try_map(|row| Self::map(None, &row))? {
237                            if constant_time_eq::constant_time_eq(
238                                database.decrypt(node.token.clone()).await?.as_bytes(),
239                                token.as_bytes(),
240                            ) {
241                                Some(node)
242                            } else {
243                                None
244                            }
245                        } else {
246                            None
247                        },
248                    )
249                },
250            )
251            .await
252    }
253
254    pub async fn by_location_uuid_with_pagination(
255        database: &crate::database::Database,
256        location_uuid: uuid::Uuid,
257        page: i64,
258        per_page: i64,
259        search: Option<&str>,
260    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
261        let offset = (page - 1) * per_page;
262
263        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
264            r#"
265            SELECT {}, COUNT(*) OVER() AS total_count
266            FROM nodes
267            JOIN locations ON locations.uuid = nodes.location_uuid
268            WHERE nodes.location_uuid = $1 AND ($2 IS NULL OR nodes.name ILIKE '%' || $2 || '%')
269            ORDER BY nodes.created
270            LIMIT $3 OFFSET $4
271            "#,
272            Self::columns_sql(None)
273        )))
274        .bind(location_uuid)
275        .bind(search)
276        .bind(per_page)
277        .bind(offset)
278        .fetch_all(database.read())
279        .await?;
280
281        Ok(super::Pagination {
282            total: rows
283                .first()
284                .map_or(Ok(0), |row| row.try_get("total_count"))?,
285            per_page,
286            page,
287            data: rows
288                .into_iter()
289                .map(|row| Self::map(None, &row))
290                .try_collect_vec()?,
291        })
292    }
293
294    pub async fn by_backup_configuration_uuid_with_pagination(
295        database: &crate::database::Database,
296        backup_configuration_uuid: uuid::Uuid,
297        page: i64,
298        per_page: i64,
299        search: Option<&str>,
300    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
301        let offset = (page - 1) * per_page;
302
303        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
304            r#"
305            SELECT {}, COUNT(*) OVER() AS total_count
306            FROM nodes
307            JOIN locations ON locations.uuid = nodes.location_uuid
308            WHERE nodes.backup_configuration_uuid = $1 AND ($2 IS NULL OR nodes.name ILIKE '%' || $2 || '%')
309            ORDER BY nodes.created
310            LIMIT $3 OFFSET $4
311            "#,
312            Self::columns_sql(None)
313        )))
314        .bind(backup_configuration_uuid)
315        .bind(search)
316        .bind(per_page)
317        .bind(offset)
318        .fetch_all(database.read())
319        .await?;
320
321        Ok(super::Pagination {
322            total: rows
323                .first()
324                .map_or(Ok(0), |row| row.try_get("total_count"))?,
325            per_page,
326            page,
327            data: rows
328                .into_iter()
329                .map(|row| Self::map(None, &row))
330                .try_collect_vec()?,
331        })
332    }
333
334    pub async fn all_with_pagination(
335        database: &crate::database::Database,
336        page: i64,
337        per_page: i64,
338        search: Option<&str>,
339    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
340        let offset = (page - 1) * per_page;
341
342        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
343            r#"
344            SELECT {}, COUNT(*) OVER() AS total_count
345            FROM nodes
346            JOIN locations ON locations.uuid = nodes.location_uuid
347            WHERE $1 IS NULL OR nodes.name ILIKE '%' || $1 || '%'
348            ORDER BY nodes.created
349            LIMIT $2 OFFSET $3
350            "#,
351            Self::columns_sql(None)
352        )))
353        .bind(search)
354        .bind(per_page)
355        .bind(offset)
356        .fetch_all(database.read())
357        .await?;
358
359        Ok(super::Pagination {
360            total: rows
361                .first()
362                .map_or(Ok(0), |row| row.try_get("total_count"))?,
363            per_page,
364            page,
365            data: rows
366                .into_iter()
367                .map(|row| Self::map(None, &row))
368                .try_collect_vec()?,
369        })
370    }
371
372    pub async fn by_location_uuids_most_eligible(
373        database: &crate::database::Database,
374        location_uuids: &[uuid::Uuid],
375        limits: super::server::AdminApiServerLimits,
376        allow_overallocation: bool,
377        suspension_penalty: f64,
378        randomness: f64,
379    ) -> Result<Vec<Self>, crate::database::DatabaseError> {
380        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
381            r#"
382            WITH server_usage AS (
383                SELECT
384                    node_uuid,
385                    COALESCE(SUM((memory + memory_overhead) * CASE WHEN suspended THEN $5 ELSE 1.0 END), 0)::BIGINT AS used_memory,
386                    COALESCE(SUM(disk * CASE WHEN suspended THEN $5 ELSE 1.0 END), 0)::BIGINT AS used_disk
387                FROM servers
388                GROUP BY node_uuid
389            )
390            SELECT {}, COALESCE(u.used_memory, 0) AS used_memory, COALESCE(u.used_disk, 0) AS used_disk
391            FROM nodes
392            JOIN locations ON locations.uuid = nodes.location_uuid
393            LEFT JOIN server_usage u ON nodes.uuid = u.node_uuid
394            WHERE nodes.location_uuid = ANY($1)
395            AND nodes.deployment_enabled
396            AND (
397                $4 OR (
398                    (nodes.memory = 0 OR COALESCE(u.used_memory, 0) + $2 <= nodes.memory)
399                    AND (nodes.disk = 0 OR COALESCE(u.used_disk, 0) + $3 <= nodes.disk)
400                )
401            )
402            ORDER BY
403                (
404                    CASE WHEN nodes.memory = 0 THEN 0 ELSE GREATEST(COALESCE(u.used_memory, 0) + $2 - nodes.memory, 0) END +
405                    CASE WHEN nodes.disk = 0 THEN 0 ELSE GREATEST(COALESCE(u.used_disk, 0) + $3 - nodes.disk, 0) END
406                ),
407                GREATEST(
408                    CASE WHEN nodes.memory = 0 THEN 0 ELSE (COALESCE(u.used_memory, 0) + $2)::FLOAT / nodes.memory END,
409                    CASE WHEN nodes.disk = 0 THEN 0 ELSE (COALESCE(u.used_disk, 0) + $3)::FLOAT / nodes.disk END
410                )
411            "#,
412            Self::columns_sql(None),
413        )))
414        .bind(location_uuids)
415        .bind(limits.memory)
416        .bind(limits.disk)
417        .bind(allow_overallocation)
418        .bind(suspension_penalty)
419        .fetch_all(database.read())
420        .await?;
421
422        let nodes = rows
423            .into_iter()
424            .map(|row| {
425                Ok((
426                    Self::map(None, &row)?,
427                    row.try_get::<i64, _>("used_memory")?,
428                    row.try_get::<i64, _>("used_disk")?,
429                ))
430            })
431            .collect::<Result<Vec<_>, crate::database::DatabaseError>>()?;
432
433        if randomness > 0.0 {
434            let mut rng = rand::rng();
435
436            let mut keyed = nodes
437                .into_iter()
438                .map(|(node, used_memory, used_disk)| {
439                    let memory_free = if node.memory == 0 {
440                        1.0
441                    } else {
442                        1.0 - (used_memory + limits.memory) as f64 / node.memory as f64
443                    };
444                    let disk_free = if node.disk == 0 {
445                        1.0
446                    } else {
447                        1.0 - (used_disk + limits.disk) as f64 / node.disk as f64
448                    };
449
450                    let free_ratio = f64::min(memory_free, disk_free).clamp(0.0001, 1.0);
451
452                    let weight = free_ratio.powf(1.0 / randomness);
453                    let key = rng.random::<f64>().powf(1.0 / weight);
454
455                    (key, node)
456                })
457                .collect::<Vec<_>>();
458
459            keyed.sort_by(|(a, _), (b, _)| b.total_cmp(a));
460
461            return Ok(keyed.into_iter().map(|(_, node)| node).collect());
462        }
463
464        Ok(nodes.into_iter().map(|(node, _, _)| node).collect())
465    }
466
467    pub async fn by_name(
468        database: &crate::database::Database,
469        name: &str,
470    ) -> Result<Option<Self>, crate::database::DatabaseError> {
471        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
472            r#"
473            SELECT {}
474            FROM nodes
475            JOIN locations ON locations.uuid = nodes.location_uuid
476            WHERE nodes.name = $1
477            "#,
478            Self::columns_sql(None)
479        )))
480        .bind(name)
481        .fetch_optional(database.read())
482        .await?;
483
484        row.try_map(|row| Self::map(None, &row))
485    }
486
487    pub async fn count_by_location_uuid(
488        database: &crate::database::Database,
489        location_uuid: uuid::Uuid,
490    ) -> Result<i64, sqlx::Error> {
491        sqlx::query_scalar(
492            r#"
493            SELECT COUNT(*)
494            FROM nodes
495            WHERE nodes.location_uuid = $1
496            "#,
497        )
498        .bind(location_uuid)
499        .fetch_one(database.read())
500        .await
501    }
502
503    pub async fn find_deployment_blocker(
504        database: &crate::database::Database,
505        location_uuids: &[uuid::Uuid],
506        limits: super::server::AdminApiServerLimits,
507        allow_overallocation: bool,
508        suspension_penalty: f64,
509    ) -> Result<Option<NodeDeploymentBlocker>, crate::database::DatabaseError> {
510        let row = sqlx::query(
511            r#"
512            WITH server_usage AS (
513                SELECT
514                    node_uuid,
515                    COALESCE(SUM((memory + memory_overhead) * CASE WHEN suspended THEN $5 ELSE 1.0 END), 0)::BIGINT AS used_memory,
516                    COALESCE(SUM(disk * CASE WHEN suspended THEN $5 ELSE 1.0 END), 0)::BIGINT AS used_disk
517                FROM servers
518                GROUP BY node_uuid
519            )
520            SELECT
521                COUNT(*) AS total,
522                COUNT(*) FILTER (WHERE nodes.deployment_enabled) AS deployable,
523                COUNT(*) FILTER (
524                    WHERE nodes.deployment_enabled
525                    AND ($4 OR nodes.memory = 0 OR COALESCE(u.used_memory, 0) + $2 <= nodes.memory)
526                ) AS memory_ok,
527                COUNT(*) FILTER (
528                    WHERE nodes.deployment_enabled
529                    AND ($4 OR nodes.disk = 0 OR COALESCE(u.used_disk, 0) + $3 <= nodes.disk)
530                ) AS disk_ok,
531                COUNT(*) FILTER (
532                    WHERE nodes.deployment_enabled
533                    AND ($4 OR nodes.memory = 0 OR COALESCE(u.used_memory, 0) + $2 <= nodes.memory)
534                    AND ($4 OR nodes.disk = 0 OR COALESCE(u.used_disk, 0) + $3 <= nodes.disk)
535                ) AS resource_ok
536            FROM nodes
537            LEFT JOIN server_usage u ON nodes.uuid = u.node_uuid
538            WHERE nodes.location_uuid = ANY($1)
539            "#,
540        )
541        .bind(location_uuids)
542        .bind(limits.memory)
543        .bind(limits.disk)
544        .bind(allow_overallocation)
545        .bind(suspension_penalty)
546        .fetch_one(database.read())
547        .await?;
548
549        let total: i64 = row.try_get("total")?;
550        let deployable: i64 = row.try_get("deployable")?;
551        let memory_ok: i64 = row.try_get("memory_ok")?;
552        let disk_ok: i64 = row.try_get("disk_ok")?;
553        let resource_ok: i64 = row.try_get("resource_ok")?;
554
555        Ok(Some(if total == 0 {
556            NodeDeploymentBlocker::NoNodes
557        } else if deployable == 0 {
558            NodeDeploymentBlocker::DeploymentDisabled
559        } else if resource_ok > 0 {
560            return Ok(None);
561        } else if memory_ok == 0 && disk_ok == 0 {
562            NodeDeploymentBlocker::InsufficientResources
563        } else if memory_ok == 0 {
564            NodeDeploymentBlocker::InsufficientMemory
565        } else if disk_ok == 0 {
566            NodeDeploymentBlocker::InsufficientDisk
567        } else {
568            NodeDeploymentBlocker::ResourcesSplitAcrossNodes
569        }))
570    }
571
572    /// Fetch the current configuration of this node
573    ///
574    /// Cached for 120 seconds.
575    pub async fn fetch_configuration(
576        &self,
577        database: &crate::database::Database,
578    ) -> Result<wings_api::Config, anyhow::Error> {
579        database
580            .cache
581            .cached(
582                &format!("node::{}::configuration", self.uuid),
583                120,
584                || async {
585                    Ok::<_, anyhow::Error>(
586                        self.api_client(database).await?.get_system_config().await?,
587                    )
588                },
589            )
590            .await
591    }
592
593    /// Update the configuration of this node
594    ///
595    /// Invalidates the cached configuration.
596    pub async fn update_configuration(
597        &self,
598        database: &crate::database::Database,
599        config_patch: &serde_json::Value,
600    ) -> Result<bool, anyhow::Error> {
601        let response = self
602            .api_client(database)
603            .await?
604            .post_update(config_patch)
605            .await?;
606        if !response.applied {
607            return Ok(false);
608        }
609
610        database
611            .cache
612            .invalidate(&format!("node::{}::configuration", self.uuid))
613            .await?;
614
615        Ok(true)
616    }
617
618    /// Fetch the current resource usages of all servers on this node.
619    ///
620    /// Cached for 15 seconds.
621    pub async fn fetch_server_resources(
622        &self,
623        database: &crate::database::Database,
624    ) -> Result<HashMap<uuid::Uuid, wings_api::ResourceUsage>, anyhow::Error> {
625        database
626            .cache
627            .cached(
628                &format!("node::{}::server_resources", self.uuid),
629                15,
630                || async {
631                    let resources = self
632                        .api_client(database)
633                        .await?
634                        .get_servers_utilization()
635                        .await?;
636
637                    Ok::<_, anyhow::Error>(resources.into_iter().collect())
638                },
639            )
640            .await
641    }
642
643    #[inline]
644    pub fn generate_token() -> (String, String) {
645        let token_id = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 16);
646        let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64);
647
648        (token_id, token)
649    }
650
651    pub async fn reset_token(
652        &self,
653        state: &crate::State,
654    ) -> Result<(String, String), anyhow::Error> {
655        let (token_id, token) = Self::generate_token();
656
657        sqlx::query(
658            r#"
659            UPDATE nodes
660            SET token_id = $2, token = $3
661            WHERE nodes.uuid = $1
662            "#,
663        )
664        .bind(self.uuid)
665        .bind(&token_id)
666        .bind(state.database.encrypt(token.clone()).await?)
667        .execute(state.database.write())
668        .await?;
669
670        Self::get_event_emitter().emit(
671            state.clone(),
672            NodeEvent::TokenReset {
673                node: Box::new(self.clone()),
674                token_id: token_id.clone(),
675                token: token.clone(),
676            },
677        );
678
679        Ok((token_id, token))
680    }
681
682    #[inline]
683    pub fn is_all_in_one_node(&self) -> bool {
684        self.uuid == Self::AIO_NODE_UUID
685    }
686
687    #[inline]
688    pub fn url(&self, path: &str) -> reqwest::Url {
689        let mut url = self.url.clone();
690        url.path_segments_mut()
691            .unwrap()
692            .extend(path.trim_start_matches('/').split('/'));
693        url
694    }
695
696    #[inline]
697    pub async fn public_url(
698        &self,
699        state: &crate::State,
700        path: &str,
701    ) -> Result<reqwest::Url, anyhow::Error> {
702        let mut url = if self.is_all_in_one_node() {
703            let mut url = state
704                .settings
705                .get_as(|s| reqwest::Url::parse(&s.app.url))
706                .await??;
707            url.path_segments_mut()
708                .unwrap()
709                .extend(&["wings-proxy", &self.uuid.to_compact_string()]);
710            url
711        } else {
712            self.public_url.clone().unwrap_or(self.url.clone())
713        };
714
715        url.path_segments_mut()
716            .unwrap()
717            .extend(path.trim_start_matches('/').split('/'));
718
719        Ok(url)
720    }
721
722    #[inline]
723    pub async fn api_client(
724        &self,
725        database: &crate::database::Database,
726    ) -> Result<wings_api::client::WingsClient, anyhow::Error> {
727        Ok(wings_api::client::WingsClient::new(
728            self.url.to_string(),
729            database.decrypt(self.token.to_vec()).await?.into(),
730        ))
731    }
732
733    pub async fn used_ports(
734        &self,
735        state: &crate::State,
736        ips: &[std::net::IpAddr],
737    ) -> Result<HashMap<std::net::IpAddr, Vec<u16>>, anyhow::Error> {
738        let mut used = HashMap::new();
739        let mut missing = Vec::new();
740
741        const USED_PORTS_TTL: u64 = 10;
742        const USED_PORTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
743
744        #[inline]
745        fn used_ports_cache_key(
746            node_uuid: uuid::Uuid,
747            ip: std::net::IpAddr,
748        ) -> compact_str::CompactString {
749            compact_str::format_compact!("nodes::{node_uuid}::used_ports::{ip}")
750        }
751
752        for ip in ips {
753            match state
754                .cache
755                .get(&used_ports_cache_key(self.uuid, *ip))
756                .await?
757            {
758                Some(ports) => {
759                    used.insert(*ip, ports);
760                }
761                None => missing.push(*ip),
762            }
763        }
764
765        if missing.is_empty() {
766            return Ok(used);
767        }
768
769        let client = self.api_client(&state.database).await?;
770        let response = tokio::time::timeout(
771            USED_PORTS_TIMEOUT,
772            client.get_ports_used(&wings_api::ports_used::get::Query {
773                ip: Some(
774                    missing
775                        .iter()
776                        .map(|ip| compact_str::format_compact!("{ip}"))
777                        .collect(),
778                ),
779                ..Default::default()
780            }),
781        )
782        .await
783        .map_err(|_| anyhow::anyhow!("timed out asking the node which ports are in use"))??;
784
785        for ip in missing {
786            let ports: Vec<_> = response
787                .used
788                .get(compact_str::format_compact!("{ip}").as_str())
789                .map(|ports| ports.iter().map(|port| port.port as u16).collect())
790                .unwrap_or_default();
791
792            state
793                .cache
794                .set(&used_ports_cache_key(self.uuid, ip), USED_PORTS_TTL, &ports)
795                .await?;
796            used.insert(ip, ports);
797        }
798
799        Ok(used)
800    }
801
802    #[inline]
803    pub fn create_jwt<T: Serialize>(
804        &self,
805        database: &crate::database::Database,
806        jwt: &crate::jwt::Jwt,
807        payload: &T,
808    ) -> Result<String, anyhow::Error> {
809        Ok(jwt.create_custom(database.blocking_decrypt(&self.token)?.as_bytes(), payload)?)
810    }
811}
812
813#[async_trait::async_trait]
814impl IntoAdminApiObject for Node {
815    type AdminApiObject = AdminApiNode;
816    type ExtraArgs<'a> = ();
817
818    async fn into_admin_api_object<'a>(
819        self,
820        state: &crate::State,
821        _args: Self::ExtraArgs<'a>,
822    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
823        let api_object = AdminApiNode::init_hooks(&self, state).await?;
824
825        let public_url = if self.is_all_in_one_node() {
826            Some(self.public_url(state, "/").await?.to_string())
827        } else {
828            self.public_url.map(|url| url.to_string())
829        };
830
831        let (location, backup_configuration) =
832            tokio::join!(self.location.into_admin_api_object(state, ()), async {
833                if let Some(backup_configuration) = self.backup_configuration {
834                    if let Ok(backup_configuration) =
835                        backup_configuration.fetch_cached(&state.database).await
836                    {
837                        backup_configuration
838                            .into_admin_api_object(state, ())
839                            .await
840                            .ok()
841                    } else {
842                        None
843                    }
844                } else {
845                    None
846                }
847            });
848
849        let api_object = finish_extendible!(
850            AdminApiNode {
851                uuid: self.uuid,
852                location: location?,
853                backup_configuration,
854                name: self.name,
855                description: self.description,
856                deployment_enabled: self.deployment_enabled,
857                maintenance_enabled: self.maintenance_enabled,
858                public_url,
859                url: self.url.to_string(),
860                sftp_host: self.sftp_host,
861                sftp_port: self.sftp_port,
862                memory: self.memory,
863                disk: self.disk,
864                created: self.created.and_utc(),
865            },
866            api_object,
867            state
868        )?;
869
870        Ok(api_object)
871    }
872}
873
874#[async_trait::async_trait]
875impl ByUuid for Node {
876    async fn by_uuid(
877        database: &crate::database::Database,
878        uuid: uuid::Uuid,
879    ) -> Result<Self, crate::database::DatabaseError> {
880        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
881            r#"
882            SELECT {}, {}
883            FROM nodes
884            JOIN locations ON locations.uuid = nodes.location_uuid
885            WHERE nodes.uuid = $1
886            "#,
887            Self::columns_sql(None),
888            super::location::Location::columns_sql(Some("location_")),
889        )))
890        .bind(uuid)
891        .fetch_one(database.read())
892        .await?;
893
894        Self::map(None, &row)
895    }
896
897    async fn by_uuid_with_transaction(
898        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
899        uuid: uuid::Uuid,
900    ) -> Result<Self, crate::database::DatabaseError> {
901        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
902            r#"
903            SELECT {}, {}
904            FROM nodes
905            JOIN locations ON locations.uuid = nodes.location_uuid
906            WHERE nodes.uuid = $1
907            "#,
908            Self::columns_sql(None),
909            super::location::Location::columns_sql(Some("location_")),
910        )))
911        .bind(uuid)
912        .fetch_one(&mut **transaction)
913        .await?;
914
915        Self::map(None, &row)
916    }
917}
918
919#[derive(ToSchema, Deserialize, Validate)]
920pub struct CreateNodeOptions {
921    #[garde(skip)]
922    pub location_uuid: uuid::Uuid,
923    #[garde(skip)]
924    pub backup_configuration_uuid: Option<uuid::Uuid>,
925    #[garde(length(chars, min = 1, max = 255))]
926    #[schema(min_length = 1, max_length = 255)]
927    pub name: compact_str::CompactString,
928    #[garde(length(chars, min = 1, max = 1024))]
929    #[schema(min_length = 1, max_length = 1024)]
930    pub description: Option<compact_str::CompactString>,
931    #[garde(skip)]
932    pub deployment_enabled: bool,
933    #[garde(skip)]
934    pub maintenance_enabled: bool,
935    #[garde(length(chars, min = 3, max = 255), url)]
936    #[schema(min_length = 3, max_length = 255, format = "uri")]
937    pub public_url: Option<compact_str::CompactString>,
938    #[garde(length(chars, min = 3, max = 255), url)]
939    #[schema(min_length = 3, max_length = 255, format = "uri")]
940    pub url: compact_str::CompactString,
941    #[garde(length(chars, min = 3, max = 255))]
942    #[schema(min_length = 3, max_length = 255)]
943    pub sftp_host: Option<compact_str::CompactString>,
944    #[garde(range(min = 1))]
945    #[schema(minimum = 1)]
946    pub sftp_port: u16,
947    #[garde(range(min = 0))]
948    #[schema(minimum = 0)]
949    pub memory: i64,
950    #[garde(range(min = 0))]
951    #[schema(minimum = 0)]
952    pub disk: i64,
953}
954
955#[async_trait::async_trait]
956impl CreatableModel for Node {
957    type CreateOptions<'a> = CreateNodeOptions;
958    type CreateResult = Self;
959
960    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
961        static CREATE_LISTENERS: LazyLock<CreateListenerList<Node>> =
962            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
963
964        &CREATE_LISTENERS
965    }
966
967    async fn create_with_transaction(
968        state: &crate::State,
969        mut options: Self::CreateOptions<'_>,
970        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
971    ) -> Result<Self, crate::database::DatabaseError> {
972        options.validate()?;
973
974        if let Some(backup_configuration_uuid) = &options.backup_configuration_uuid {
975            super::backup_configuration::BackupConfiguration::by_uuid_optional(
976                &state.database,
977                *backup_configuration_uuid,
978            )
979            .await?
980            .ok_or(crate::database::InvalidRelationError(
981                "backup_configuration",
982            ))?;
983        }
984
985        let mut query_builder = InsertQueryBuilder::new("nodes");
986
987        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
988
989        let (token_id, token) = Self::generate_token();
990
991        query_builder
992            .set("location_uuid", options.location_uuid)
993            .set(
994                "backup_configuration_uuid",
995                options.backup_configuration_uuid,
996            )
997            .set("name", &options.name)
998            .set("description", &options.description)
999            .set("deployment_enabled", options.deployment_enabled)
1000            .set("maintenance_enabled", options.maintenance_enabled)
1001            .set("public_url", &options.public_url)
1002            .set("url", &options.url)
1003            .set("sftp_host", &options.sftp_host)
1004            .set("sftp_port", options.sftp_port as i32)
1005            .set("memory", options.memory)
1006            .set("disk", options.disk)
1007            .set("token_id", token_id.clone())
1008            .set("token", state.database.encrypt(token.clone()).await?);
1009
1010        let row = query_builder
1011            .returning("uuid")
1012            .fetch_one(&mut **transaction)
1013            .await?;
1014        let uuid: uuid::Uuid = row.try_get("uuid")?;
1015
1016        let mut result = Self::by_uuid_with_transaction(transaction, uuid).await?;
1017
1018        Self::run_after_create_handlers(&mut result, &options, state, transaction).await?;
1019
1020        Ok(result)
1021    }
1022}
1023
1024#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
1025pub struct UpdateNodeOptions {
1026    #[garde(skip)]
1027    pub location_uuid: Option<uuid::Uuid>,
1028    #[serde(
1029        default,
1030        skip_serializing_if = "Option::is_none",
1031        with = "::serde_with::rust::double_option"
1032    )]
1033    #[garde(skip)]
1034    pub backup_configuration_uuid: Option<Option<uuid::Uuid>>,
1035    #[garde(length(chars, min = 1, max = 255))]
1036    #[schema(min_length = 1, max_length = 255)]
1037    pub name: Option<compact_str::CompactString>,
1038    #[garde(length(chars, min = 1, max = 1024))]
1039    #[schema(min_length = 1, max_length = 1024)]
1040    #[serde(
1041        default,
1042        skip_serializing_if = "Option::is_none",
1043        with = "::serde_with::rust::double_option"
1044    )]
1045    pub description: Option<Option<compact_str::CompactString>>,
1046    #[garde(skip)]
1047    pub deployment_enabled: Option<bool>,
1048    #[garde(skip)]
1049    pub maintenance_enabled: Option<bool>,
1050    #[garde(length(chars, min = 3, max = 255), url)]
1051    #[schema(min_length = 3, max_length = 255, format = "uri")]
1052    #[serde(
1053        default,
1054        skip_serializing_if = "Option::is_none",
1055        with = "::serde_with::rust::double_option"
1056    )]
1057    pub public_url: Option<Option<compact_str::CompactString>>,
1058    #[garde(length(chars, min = 3, max = 255), url)]
1059    #[schema(min_length = 3, max_length = 255, format = "uri")]
1060    pub url: Option<compact_str::CompactString>,
1061    #[garde(length(chars, min = 3, max = 255))]
1062    #[schema(min_length = 3, max_length = 255)]
1063    #[serde(
1064        default,
1065        skip_serializing_if = "Option::is_none",
1066        with = "::serde_with::rust::double_option"
1067    )]
1068    pub sftp_host: Option<Option<compact_str::CompactString>>,
1069    #[garde(range(min = 1))]
1070    #[schema(minimum = 1)]
1071    pub sftp_port: Option<u16>,
1072    #[garde(range(min = 0))]
1073    #[schema(minimum = 0)]
1074    pub memory: Option<i64>,
1075    #[garde(range(min = 0))]
1076    #[schema(minimum = 0)]
1077    pub disk: Option<i64>,
1078}
1079
1080#[async_trait::async_trait]
1081impl UpdatableModel for Node {
1082    type UpdateOptions = UpdateNodeOptions;
1083
1084    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
1085        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<Node>> =
1086            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1087
1088        &UPDATE_LISTENERS
1089    }
1090
1091    async fn update_with_transaction(
1092        &mut self,
1093        state: &crate::State,
1094        mut options: Self::UpdateOptions,
1095        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1096    ) -> Result<(), crate::database::DatabaseError> {
1097        options.validate()?;
1098
1099        let location = if let Some(location_uuid) = options.location_uuid {
1100            Some(
1101                super::location::Location::by_uuid_optional(&state.database, location_uuid)
1102                    .await?
1103                    .ok_or(crate::database::InvalidRelationError("location"))?,
1104            )
1105        } else {
1106            None
1107        };
1108
1109        let backup_configuration =
1110            if let Some(backup_configuration_uuid) = &options.backup_configuration_uuid {
1111                match backup_configuration_uuid {
1112                    Some(uuid) => {
1113                        super::backup_configuration::BackupConfiguration::by_uuid_optional(
1114                            &state.database,
1115                            *uuid,
1116                        )
1117                        .await?
1118                        .ok_or(crate::database::InvalidRelationError(
1119                            "backup_configuration",
1120                        ))?;
1121
1122                        Some(Some(
1123                            super::backup_configuration::BackupConfiguration::get_fetchable(*uuid),
1124                        ))
1125                    }
1126                    None => Some(None),
1127                }
1128            } else {
1129                None
1130            };
1131
1132        let mut query_builder = UpdateQueryBuilder::new("nodes");
1133
1134        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
1135            .await?;
1136
1137        query_builder
1138            .set("location_uuid", options.location_uuid.as_ref())
1139            .set(
1140                "backup_configuration_uuid",
1141                options
1142                    .backup_configuration_uuid
1143                    .as_ref()
1144                    .map(|u| u.as_ref()),
1145            )
1146            .set("name", options.name.as_ref())
1147            .set(
1148                "description",
1149                options.description.as_ref().map(|d| d.as_ref()),
1150            )
1151            .set("deployment_enabled", options.deployment_enabled)
1152            .set("maintenance_enabled", options.maintenance_enabled)
1153            .set(
1154                "public_url",
1155                options.public_url.as_ref().map(|u| u.as_ref()),
1156            )
1157            .set("url", options.url.as_ref())
1158            .set("sftp_host", options.sftp_host.as_ref().map(|h| h.as_ref()))
1159            .set("sftp_port", options.sftp_port.as_ref().map(|p| *p as i32))
1160            .set("memory", options.memory.as_ref())
1161            .set("disk", options.disk.as_ref())
1162            .where_eq("uuid", self.uuid);
1163
1164        query_builder.execute(&mut **transaction).await?;
1165
1166        if let Some(location) = location {
1167            self.location = location;
1168        }
1169        if let Some(backup_configuration) = backup_configuration {
1170            self.backup_configuration = backup_configuration;
1171        }
1172        if let Some(name) = options.name {
1173            self.name = name;
1174        }
1175        if let Some(description) = options.description {
1176            self.description = description;
1177        }
1178        if let Some(deployment_enabled) = options.deployment_enabled {
1179            self.deployment_enabled = deployment_enabled;
1180        }
1181        if let Some(maintenance_enabled) = options.maintenance_enabled {
1182            self.maintenance_enabled = maintenance_enabled;
1183        }
1184        if let Some(public_url) = options.public_url {
1185            self.public_url = public_url
1186                .try_map(|url| url.parse())
1187                .map_err(anyhow::Error::new)?;
1188        }
1189        if let Some(url) = options.url {
1190            self.url = url.parse().map_err(anyhow::Error::new)?;
1191        }
1192        if let Some(sftp_host) = options.sftp_host {
1193            self.sftp_host = sftp_host;
1194        }
1195        if let Some(sftp_port) = options.sftp_port {
1196            self.sftp_port = sftp_port as i32;
1197        }
1198        if let Some(memory) = options.memory {
1199            self.memory = memory;
1200        }
1201        if let Some(disk) = options.disk {
1202            self.disk = disk;
1203        }
1204
1205        self.run_after_update_handlers(state, transaction).await?;
1206
1207        Ok(())
1208    }
1209}
1210
1211#[async_trait::async_trait]
1212impl DeletableModel for Node {
1213    type DeleteOptions = ();
1214
1215    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
1216        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<Node>> =
1217            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1218
1219        &DELETE_LISTENERS
1220    }
1221
1222    async fn delete_with_transaction(
1223        &self,
1224        state: &crate::State,
1225        options: Self::DeleteOptions,
1226        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1227    ) -> Result<(), anyhow::Error> {
1228        if self.is_all_in_one_node() && state.container_type.is_all_in_one() {
1229            return Err(anyhow::anyhow!("The AIO node cannot be deleted"));
1230        }
1231
1232        self.run_delete_handlers(&options, state, transaction)
1233            .await?;
1234
1235        sqlx::query(
1236            r#"
1237            DELETE FROM nodes
1238            WHERE nodes.uuid = $1
1239            "#,
1240        )
1241        .bind(self.uuid)
1242        .execute(&mut **transaction)
1243        .await?;
1244
1245        self.run_after_delete_handlers(&options, state, transaction)
1246            .await?;
1247
1248        Ok(())
1249    }
1250}
1251
1252#[derive(Validate)]
1253pub struct DuplicateNodeOptions {
1254    #[garde(length(chars, min = 1, max = 255))]
1255    pub name: compact_str::CompactString,
1256}
1257
1258#[async_trait::async_trait]
1259impl DuplicableModel for Node {
1260    type DuplicateOptions<'a> = DuplicateNodeOptions;
1261
1262    fn get_duplicate_handlers() -> &'static LazyLock<DuplicateHandlerList<Self>> {
1263        static DUPLICATE_LISTENERS: LazyLock<DuplicateHandlerList<Node>> =
1264            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1265
1266        &DUPLICATE_LISTENERS
1267    }
1268
1269    async fn duplicate_with_transaction(
1270        &self,
1271        state: &crate::State,
1272        options: Self::DuplicateOptions<'_>,
1273        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1274    ) -> Result<Self, crate::database::DatabaseError> {
1275        options.validate()?;
1276
1277        self.run_duplicate_handlers(&options, state, transaction)
1278            .await?;
1279
1280        let mut query_builder = InsertQueryBuilder::new("nodes");
1281
1282        let (token_id, token) = Self::generate_token();
1283
1284        query_builder
1285            .set("location_uuid", self.location.uuid)
1286            .set(
1287                "backup_configuration_uuid",
1288                self.backup_configuration.as_ref().map(|c| c.uuid),
1289            )
1290            .set("name", &options.name)
1291            .set("description", &self.description)
1292            .set("deployment_enabled", self.deployment_enabled)
1293            .set("maintenance_enabled", self.maintenance_enabled)
1294            .set("public_url", self.public_url.as_ref().map(|u| u.as_str()))
1295            .set("url", self.url.as_str())
1296            .set("sftp_host", &self.sftp_host)
1297            .set("sftp_port", self.sftp_port)
1298            .set("memory", self.memory)
1299            .set("disk", self.disk)
1300            .set("token_id", token_id)
1301            .set("token", state.database.encrypt(token).await?);
1302
1303        let row = query_builder
1304            .returning("uuid")
1305            .fetch_one(&mut **transaction)
1306            .await?;
1307        let uuid: uuid::Uuid = row.try_get("uuid")?;
1308
1309        let mut node = Self::by_uuid_with_transaction(transaction, uuid).await?;
1310
1311        sqlx::query!(
1312            "INSERT INTO node_mounts (node_uuid, mount_uuid)
1313            SELECT $1, node_mounts.mount_uuid
1314            FROM node_mounts
1315            WHERE node_mounts.node_uuid = $2",
1316            node.uuid,
1317            self.uuid,
1318        )
1319        .execute(&mut **transaction)
1320        .await?;
1321
1322        sqlx::query!(
1323            "INSERT INTO node_database_hosts (node_uuid, database_host_uuid)
1324            SELECT $1, node_database_hosts.database_host_uuid
1325            FROM node_database_hosts
1326            WHERE node_database_hosts.node_uuid = $2",
1327            node.uuid,
1328            self.uuid,
1329        )
1330        .execute(&mut **transaction)
1331        .await?;
1332
1333        sqlx::query!(
1334            "INSERT INTO node_database_agent_hosts (node_uuid, database_agent_host_uuid)
1335            SELECT $1, node_database_agent_hosts.database_agent_host_uuid
1336            FROM node_database_agent_hosts
1337            WHERE node_database_agent_hosts.node_uuid = $2",
1338            node.uuid,
1339            self.uuid,
1340        )
1341        .execute(&mut **transaction)
1342        .await?;
1343
1344        self.run_after_duplicate_handlers(&mut node, &options, state, transaction)
1345            .await?;
1346
1347        Ok(node)
1348    }
1349}
1350
1351#[schema_extension_derive::extendible]
1352#[init_args(Node, crate::State)]
1353#[hook_args(crate::State)]
1354#[derive(ToSchema, Serialize)]
1355#[schema(title = "Node")]
1356pub struct AdminApiNode {
1357    pub uuid: uuid::Uuid,
1358    pub location: super::location::AdminApiLocation,
1359    pub backup_configuration: Option<super::backup_configuration::AdminApiBackupConfiguration>,
1360
1361    pub name: compact_str::CompactString,
1362    pub description: Option<compact_str::CompactString>,
1363
1364    pub deployment_enabled: bool,
1365    pub maintenance_enabled: bool,
1366
1367    #[schema(format = "uri")]
1368    pub public_url: Option<String>,
1369    #[schema(format = "uri")]
1370    pub url: String,
1371    pub sftp_host: Option<compact_str::CompactString>,
1372    pub sftp_port: i32,
1373
1374    pub memory: i64,
1375    pub disk: i64,
1376
1377    pub created: chrono::DateTime<chrono::Utc>,
1378}