Skip to main content

shared/models/node/
mod.rs

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