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