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 NoNodes,
29 DeploymentDisabled,
31 InsufficientMemory,
33 InsufficientDisk,
35 InsufficientResources,
37 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 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 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 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 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 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}