1use crate::{
2 jwt::BasePayload,
3 models::{InsertQueryBuilder, UpdateQueryBuilder},
4 prelude::*,
5 storage::StorageUrlRetriever,
6};
7use compact_str::ToCompactString;
8use garde::Validate;
9use reqwest::StatusCode;
10use serde::{Deserialize, Serialize};
11use sqlx::{Row, postgres::PgRow, prelude::Type};
12use std::{
13 collections::BTreeMap,
14 sync::{Arc, LazyLock},
15};
16use utoipa::ToSchema;
17
18#[derive(Debug, ToSchema, Serialize, Deserialize, Type, PartialEq, Eq, Hash, Clone, Copy)]
19#[serde(rename_all = "kebab-case")]
20#[schema(rename_all = "kebab-case")]
21#[sqlx(type_name = "backup_disk", rename_all = "SCREAMING_SNAKE_CASE")]
22pub enum BackupDisk {
23 Local,
24 S3,
25 DdupBak,
26 Btrfs,
27 Zfs,
28 Restic,
29}
30
31impl BackupDisk {
32 #[inline]
33 pub fn to_wings_adapter(self) -> wings_api::BackupAdapter {
34 match self {
35 BackupDisk::Local => wings_api::BackupAdapter::Wings,
36 BackupDisk::S3 => wings_api::BackupAdapter::S3,
37 BackupDisk::DdupBak => wings_api::BackupAdapter::DdupBak,
38 BackupDisk::Btrfs => wings_api::BackupAdapter::Btrfs,
39 BackupDisk::Zfs => wings_api::BackupAdapter::Zfs,
40 BackupDisk::Restic => wings_api::BackupAdapter::Restic,
41 }
42 }
43}
44
45#[derive(Serialize, Deserialize, Clone)]
46pub struct ServerBackup {
47 pub uuid: uuid::Uuid,
48 pub server: Option<Fetchable<super::server::Server>>,
49 pub node: Fetchable<super::node::Node>,
50 pub backup_configuration: Option<Fetchable<super::backup_configuration::BackupConfiguration>>,
51
52 pub name: compact_str::CompactString,
53 pub successful: bool,
54 pub browsable: bool,
55 pub streaming: bool,
56 pub locked: bool,
57
58 pub ignored_files: Vec<compact_str::CompactString>,
59 pub checksum: Option<compact_str::CompactString>,
60 pub bytes: i64,
61 pub files: i64,
62
63 pub disk: BackupDisk,
64 pub upload_id: Option<compact_str::CompactString>,
65 pub upload_path: Option<compact_str::CompactString>,
66
67 pub completed: Option<chrono::NaiveDateTime>,
68 pub deleted: Option<chrono::NaiveDateTime>,
69 pub created: chrono::NaiveDateTime,
70}
71
72impl BaseModel for ServerBackup {
73 const NAME: &'static str = "server_backup";
74
75 #[inline]
76 fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
77 let prefix = prefix.unwrap_or_default();
78
79 BTreeMap::from([
80 (
81 "server_backups.uuid",
82 compact_str::format_compact!("{prefix}uuid"),
83 ),
84 (
85 "server_backups.server_uuid",
86 compact_str::format_compact!("{prefix}server_uuid"),
87 ),
88 (
89 "server_backups.node_uuid",
90 compact_str::format_compact!("{prefix}node_uuid"),
91 ),
92 (
93 "server_backups.backup_configuration_uuid",
94 compact_str::format_compact!("{prefix}backup_configuration_uuid"),
95 ),
96 (
97 "server_backups.name",
98 compact_str::format_compact!("{prefix}name"),
99 ),
100 (
101 "server_backups.successful",
102 compact_str::format_compact!("{prefix}successful"),
103 ),
104 (
105 "server_backups.browsable",
106 compact_str::format_compact!("{prefix}browsable"),
107 ),
108 (
109 "server_backups.streaming",
110 compact_str::format_compact!("{prefix}streaming"),
111 ),
112 (
113 "server_backups.locked",
114 compact_str::format_compact!("{prefix}locked"),
115 ),
116 (
117 "server_backups.ignored_files",
118 compact_str::format_compact!("{prefix}ignored_files"),
119 ),
120 (
121 "server_backups.checksum",
122 compact_str::format_compact!("{prefix}checksum"),
123 ),
124 (
125 "server_backups.bytes",
126 compact_str::format_compact!("{prefix}bytes"),
127 ),
128 (
129 "server_backups.files",
130 compact_str::format_compact!("{prefix}files"),
131 ),
132 (
133 "server_backups.disk",
134 compact_str::format_compact!("{prefix}disk"),
135 ),
136 (
137 "server_backups.upload_id",
138 compact_str::format_compact!("{prefix}upload_id"),
139 ),
140 (
141 "server_backups.upload_path",
142 compact_str::format_compact!("{prefix}upload_path"),
143 ),
144 (
145 "server_backups.completed",
146 compact_str::format_compact!("{prefix}completed"),
147 ),
148 (
149 "server_backups.deleted",
150 compact_str::format_compact!("{prefix}deleted"),
151 ),
152 (
153 "server_backups.created",
154 compact_str::format_compact!("{prefix}created"),
155 ),
156 ])
157 }
158
159 #[inline]
160 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
161 let prefix = prefix.unwrap_or_default();
162
163 Ok(Self {
164 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
165 server: super::server::Server::get_fetchable_from_row(
166 row,
167 compact_str::format_compact!("{prefix}server_uuid"),
168 ),
169 backup_configuration:
170 super::backup_configuration::BackupConfiguration::get_fetchable_from_row(
171 row,
172 compact_str::format_compact!("{prefix}backup_configuration_uuid"),
173 ),
174 node: super::node::Node::get_fetchable(
175 row.try_get(compact_str::format_compact!("{prefix}node_uuid").as_str())?,
176 ),
177 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
178 successful: row.try_get(compact_str::format_compact!("{prefix}successful").as_str())?,
179 browsable: row.try_get(compact_str::format_compact!("{prefix}browsable").as_str())?,
180 streaming: row.try_get(compact_str::format_compact!("{prefix}streaming").as_str())?,
181 locked: row.try_get(compact_str::format_compact!("{prefix}locked").as_str())?,
182 ignored_files: row
183 .try_get(compact_str::format_compact!("{prefix}ignored_files").as_str())?,
184 checksum: row.try_get(compact_str::format_compact!("{prefix}checksum").as_str())?,
185 bytes: row.try_get(compact_str::format_compact!("{prefix}bytes").as_str())?,
186 files: row.try_get(compact_str::format_compact!("{prefix}files").as_str())?,
187 disk: row.try_get(compact_str::format_compact!("{prefix}disk").as_str())?,
188 upload_id: row.try_get(compact_str::format_compact!("{prefix}upload_id").as_str())?,
189 upload_path: row
190 .try_get(compact_str::format_compact!("{prefix}upload_path").as_str())?,
191 completed: row.try_get(compact_str::format_compact!("{prefix}completed").as_str())?,
192 deleted: row.try_get(compact_str::format_compact!("{prefix}deleted").as_str())?,
193 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
194 })
195 }
196}
197
198impl ServerBackup {
199 pub async fn create_raw(
200 state: &crate::State,
201 options: CreateServerBackupOptions<'_>,
202 ) -> Result<Self, anyhow::Error> {
203 let backup_configuration = options
204 .server
205 .backup_configuration(&state.database)
206 .await
207 .ok_or_else(|| {
208 crate::response::DisplayError::new(
209 "no backup configuration available, unable to create backup",
210 )
211 .with_status(StatusCode::EXPECTATION_FAILED)
212 })?;
213
214 if backup_configuration.maintenance_enabled {
215 return Err(crate::response::DisplayError::new(
216 "cannot create backup while backup configuration is in maintenance mode",
217 )
218 .with_status(StatusCode::EXPECTATION_FAILED)
219 .into());
220 }
221
222 let row = sqlx::query(&format!(
223 r#"
224 INSERT INTO server_backups (server_uuid, node_uuid, backup_configuration_uuid, name, ignored_files, bytes, disk)
225 VALUES ($1, $2, $3, $4, $5, $6, $7)
226 RETURNING {}
227 "#,
228 Self::columns_sql(None)
229 ))
230 .bind(options.server.uuid)
231 .bind(options.server.node.uuid)
232 .bind(backup_configuration.uuid)
233 .bind(options.name)
234 .bind(&options.ignored_files)
235 .bind(0i64)
236 .bind(backup_configuration.backup_disk)
237 .fetch_one(state.database.write())
238 .await?;
239
240 Ok(Self::map(None, &row)?)
241 }
242
243 pub async fn by_server_uuid_uuid(
244 database: &crate::database::Database,
245 server_uuid: uuid::Uuid,
246 uuid: uuid::Uuid,
247 ) -> Result<Option<Self>, crate::database::DatabaseError> {
248 let row = sqlx::query(&format!(
249 r#"
250 SELECT {}
251 FROM server_backups
252 WHERE server_backups.server_uuid = $1 AND server_backups.uuid = $2
253 "#,
254 Self::columns_sql(None)
255 ))
256 .bind(server_uuid)
257 .bind(uuid)
258 .fetch_optional(database.read())
259 .await?;
260
261 row.try_map(|row| Self::map(None, &row))
262 }
263
264 pub async fn by_node_uuid_uuid(
265 database: &crate::database::Database,
266 node_uuid: uuid::Uuid,
267 uuid: uuid::Uuid,
268 ) -> Result<Option<Self>, crate::database::DatabaseError> {
269 let row = sqlx::query(&format!(
270 r#"
271 SELECT {}
272 FROM server_backups
273 WHERE server_backups.node_uuid = $1 AND server_backups.uuid = $2
274 "#,
275 Self::columns_sql(None)
276 ))
277 .bind(node_uuid)
278 .bind(uuid)
279 .fetch_optional(database.read())
280 .await?;
281
282 row.try_map(|row| Self::map(None, &row))
283 }
284
285 pub async fn by_server_uuid_with_pagination(
286 database: &crate::database::Database,
287 server_uuid: uuid::Uuid,
288 page: i64,
289 per_page: i64,
290 search: Option<&str>,
291 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
292 let offset = (page - 1) * per_page;
293
294 let rows = sqlx::query(&format!(
295 r#"
296 SELECT {}, COUNT(*) OVER() AS total_count
297 FROM server_backups
298 WHERE
299 server_backups.server_uuid = $1
300 AND server_backups.deleted IS NULL
301 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
302 ORDER BY server_backups.created
303 LIMIT $3 OFFSET $4
304 "#,
305 Self::columns_sql(None)
306 ))
307 .bind(server_uuid)
308 .bind(search)
309 .bind(per_page)
310 .bind(offset)
311 .fetch_all(database.read())
312 .await?;
313
314 Ok(super::Pagination {
315 total: rows
316 .first()
317 .map_or(Ok(0), |row| row.try_get("total_count"))?,
318 per_page,
319 page,
320 data: rows
321 .into_iter()
322 .map(|row| Self::map(None, &row))
323 .try_collect_vec()?,
324 })
325 }
326
327 pub async fn by_node_uuid_with_pagination(
328 database: &crate::database::Database,
329 node_uuid: uuid::Uuid,
330 page: i64,
331 per_page: i64,
332 search: Option<&str>,
333 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
334 let offset = (page - 1) * per_page;
335
336 let rows = sqlx::query(&format!(
337 r#"
338 SELECT {}, COUNT(*) OVER() AS total_count
339 FROM server_backups
340 WHERE
341 server_backups.node_uuid = $1
342 AND server_backups.deleted IS NULL
343 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
344 ORDER BY server_backups.created
345 LIMIT $3 OFFSET $4
346 "#,
347 Self::columns_sql(None)
348 ))
349 .bind(node_uuid)
350 .bind(search)
351 .bind(per_page)
352 .bind(offset)
353 .fetch_all(database.read())
354 .await?;
355
356 Ok(super::Pagination {
357 total: rows
358 .first()
359 .map_or(Ok(0), |row| row.try_get("total_count"))?,
360 per_page,
361 page,
362 data: rows
363 .into_iter()
364 .map(|row| Self::map(None, &row))
365 .try_collect_vec()?,
366 })
367 }
368
369 pub async fn by_backup_configuration_uuid_with_pagination(
370 database: &crate::database::Database,
371 backup_configuration_uuid: uuid::Uuid,
372 page: i64,
373 per_page: i64,
374 search: Option<&str>,
375 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
376 let offset = (page - 1) * per_page;
377
378 let rows = sqlx::query(&format!(
379 r#"
380 SELECT {}, COUNT(*) OVER() AS total_count
381 FROM server_backups
382 WHERE
383 server_backups.backup_configuration_uuid = $1
384 AND server_backups.deleted IS NULL
385 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
386 ORDER BY server_backups.created
387 LIMIT $3 OFFSET $4
388 "#,
389 Self::columns_sql(None)
390 ))
391 .bind(backup_configuration_uuid)
392 .bind(search)
393 .bind(per_page)
394 .bind(offset)
395 .fetch_all(database.read())
396 .await?;
397
398 Ok(super::Pagination {
399 total: rows
400 .first()
401 .map_or(Ok(0), |row| row.try_get("total_count"))?,
402 per_page,
403 page,
404 data: rows
405 .into_iter()
406 .map(|row| Self::map(None, &row))
407 .try_collect_vec()?,
408 })
409 }
410
411 pub async fn by_detached_node_uuid_with_pagination(
412 database: &crate::database::Database,
413 node_uuid: uuid::Uuid,
414 page: i64,
415 per_page: i64,
416 search: Option<&str>,
417 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
418 let offset = (page - 1) * per_page;
419
420 let rows = sqlx::query(&format!(
421 r#"
422 SELECT {}, COUNT(*) OVER() AS total_count
423 FROM server_backups
424 WHERE
425 server_backups.node_uuid = $1
426 AND server_backups.server_uuid IS NULL
427 AND server_backups.deleted IS NULL
428 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
429 ORDER BY server_backups.created
430 LIMIT $3 OFFSET $4
431 "#,
432 Self::columns_sql(None)
433 ))
434 .bind(node_uuid)
435 .bind(search)
436 .bind(per_page)
437 .bind(offset)
438 .fetch_all(database.read())
439 .await?;
440
441 Ok(super::Pagination {
442 total: rows
443 .first()
444 .map_or(Ok(0), |row| row.try_get("total_count"))?,
445 per_page,
446 page,
447 data: rows
448 .into_iter()
449 .map(|row| Self::map(None, &row))
450 .try_collect_vec()?,
451 })
452 }
453
454 pub async fn all_uuids_by_server_uuid(
455 database: &crate::database::Database,
456 server_uuid: uuid::Uuid,
457 ) -> Result<Vec<uuid::Uuid>, crate::database::DatabaseError> {
458 let rows = sqlx::query(
459 r#"
460 SELECT server_backups.uuid
461 FROM server_backups
462 WHERE server_backups.server_uuid = $1 AND server_backups.deleted IS NULL
463 "#,
464 )
465 .bind(server_uuid)
466 .fetch_all(database.read())
467 .await?;
468
469 Ok(rows
470 .into_iter()
471 .map(|row| row.get::<uuid::Uuid, _>("uuid"))
472 .collect())
473 }
474
475 pub async fn all_by_server_uuid(
476 database: &crate::database::Database,
477 server_uuid: uuid::Uuid,
478 ) -> Result<Vec<Self>, crate::database::DatabaseError> {
479 let rows = sqlx::query(&format!(
480 r#"
481 SELECT {}
482 FROM server_backups
483 WHERE server_backups.server_uuid = $1 AND server_backups.deleted IS NULL
484 "#,
485 Self::columns_sql(None)
486 ))
487 .bind(server_uuid)
488 .fetch_all(database.read())
489 .await?;
490
491 rows.into_iter()
492 .map(|row| Self::map(None, &row))
493 .try_collect_vec()
494 }
495
496 pub async fn count_by_server_uuid(
497 database: &crate::database::Database,
498 server_uuid: uuid::Uuid,
499 ) -> i64 {
500 sqlx::query_scalar(
501 r#"
502 SELECT COUNT(*)
503 FROM server_backups
504 WHERE server_backups.server_uuid = $1 AND server_backups.deleted IS NULL
505 "#,
506 )
507 .bind(server_uuid)
508 .fetch_one(database.read())
509 .await
510 .unwrap_or(0)
511 }
512
513 pub async fn download_url(
514 &self,
515 state: &crate::State,
516 user: &super::user::User,
517 node: &super::node::Node,
518 archive_format: wings_api::StreamableArchiveFormat,
519 ) -> Result<String, anyhow::Error> {
520 let backup_configuration = self
521 .backup_configuration
522 .as_ref()
523 .ok_or_else(|| {
524 crate::response::DisplayError::new(
525 "no backup configuration available, unable to restore backup",
526 )
527 .with_status(StatusCode::EXPECTATION_FAILED)
528 })?
529 .fetch_cached(&state.database)
530 .await?;
531
532 if backup_configuration.maintenance_enabled {
533 return Err(crate::response::DisplayError::new(
534 "cannot restore backup while backup configuration is in maintenance mode",
535 )
536 .with_status(StatusCode::EXPECTATION_FAILED)
537 .into());
538 }
539
540 if matches!(self.disk, BackupDisk::S3)
541 && let Some(mut s3_configuration) = backup_configuration.backup_configs.s3
542 {
543 s3_configuration.decrypt(&state.database).await?;
544
545 let client = match s3_configuration.into_client() {
546 Ok(client) => client,
547 Err(err) => {
548 return Err(anyhow::Error::from(err).context("failed to create s3 client"));
549 }
550 };
551 let file_path = match &self.upload_path {
552 Some(path) => path,
553 None => {
554 return Err(crate::response::DisplayError::new(
555 "backup does not have an upload path",
556 )
557 .with_status(StatusCode::EXPECTATION_FAILED)
558 .into());
559 }
560 };
561
562 let url = client.presign_get(file_path, 15 * 60, None).await?;
563
564 return Ok(url);
565 }
566
567 #[derive(Serialize)]
568 struct BackupDownloadJwt {
569 #[serde(flatten)]
570 base: BasePayload,
571
572 backup_uuid: uuid::Uuid,
573 unique_id: uuid::Uuid,
574 }
575
576 let token = node.create_jwt(
577 &state.database,
578 &state.jwt,
579 &BackupDownloadJwt {
580 base: BasePayload {
581 issuer: "panel".into(),
582 subject: None,
583 audience: Vec::new(),
584 expiration_time: Some(chrono::Utc::now().timestamp() + 900),
585 not_before: None,
586 issued_at: Some(chrono::Utc::now().timestamp()),
587 jwt_id: user.uuid.to_string(),
588 },
589 backup_uuid: self.uuid,
590 unique_id: uuid::Uuid::new_v4(),
591 },
592 )?;
593
594 let mut url = node.public_url();
595 url.set_path("/download/backup");
596 url.set_query(Some(&format!(
597 "token={}&archive_format={}",
598 urlencoding::encode(&token),
599 archive_format
600 )));
601
602 Ok(url.to_string())
603 }
604
605 pub async fn restore(
606 self,
607 database: &crate::database::Database,
608 server: super::server::Server,
609 truncate_directory: bool,
610 ) -> Result<(), anyhow::Error> {
611 let backup_configuration = self
612 .backup_configuration
613 .ok_or_else(|| {
614 crate::response::DisplayError::new(
615 "no backup configuration available, unable to restore backup",
616 )
617 .with_status(StatusCode::EXPECTATION_FAILED)
618 })?
619 .fetch_cached(database)
620 .await?;
621
622 if backup_configuration.maintenance_enabled {
623 return Err(crate::response::DisplayError::new(
624 "cannot restore backup while backup configuration is in maintenance mode",
625 )
626 .with_status(StatusCode::EXPECTATION_FAILED)
627 .into());
628 }
629
630 server
631 .node
632 .fetch_cached(database)
633 .await?
634 .api_client(database)
635 .await?
636 .post_servers_server_backup_backup_restore(
637 server.uuid,
638 self.uuid,
639 &wings_api::servers_server_backup_backup_restore::post::RequestBody {
640 adapter: self.disk.to_wings_adapter(),
641 download_url: match self.disk {
642 BackupDisk::S3 => {
643 if let Some(mut s3_configuration) =
644 backup_configuration.backup_configs.s3
645 {
646 s3_configuration.decrypt(database).await?;
647
648 let client = s3_configuration.into_client()?;
649 let file_path = match &self.upload_path {
650 Some(path) => path.as_str(),
651 None => &Self::s3_path(server.uuid, self.uuid),
652 };
653
654 Some(client.presign_get(file_path, 60 * 60, None).await?.into())
655 } else {
656 None
657 }
658 }
659 _ => None,
660 },
661 truncate_directory,
662 },
663 )
664 .await?;
665
666 Ok(())
667 }
668
669 pub async fn delete_oldest_by_server_uuid(
670 state: &crate::State,
671 server: &super::server::Server,
672 ) -> Result<(), anyhow::Error> {
673 let row = sqlx::query(&format!(
674 r#"
675 SELECT {}
676 FROM server_backups
677 WHERE server_backups.server_uuid = $1
678 AND server_backups.locked = false
679 AND server_backups.completed IS NOT NULL
680 AND server_backups.deleted IS NULL
681 ORDER BY server_backups.created ASC
682 LIMIT 1
683 "#,
684 Self::columns_sql(None)
685 ))
686 .bind(server.uuid)
687 .fetch_optional(state.database.read())
688 .await?;
689
690 if let Some(row) = row {
691 let backup = Self::map(None, &row)?;
692
693 backup.delete(state, Default::default()).await
694 } else {
695 Err(sqlx::Error::RowNotFound.into())
696 }
697 }
698
699 #[inline]
700 pub fn default_name() -> compact_str::CompactString {
701 let now = chrono::Local::now();
702
703 now.format("%Y-%m-%d %H:%M:%S %z").to_compact_string()
704 }
705
706 #[inline]
707 pub fn s3_path(server_uuid: uuid::Uuid, backup_uuid: uuid::Uuid) -> compact_str::CompactString {
708 compact_str::format_compact!("{server_uuid}/{backup_uuid}.tar.gz")
709 }
710
711 #[inline]
712 pub fn s3_content_type(name: &str) -> &'static str {
713 if name.ends_with(".tar.gz") {
714 "application/x-gzip"
715 } else {
716 "application/octet-stream"
717 }
718 }
719
720 pub async fn into_admin_node_api_object(
721 self,
722 database: &crate::database::Database,
723 storage_url_retriever: &StorageUrlRetriever<'_>,
724 ) -> Result<AdminApiNodeServerBackup, anyhow::Error> {
725 Ok(AdminApiNodeServerBackup {
726 uuid: self.uuid,
727 server: match self.server {
728 Some(server) => Some(
729 server
730 .fetch_cached(database)
731 .await?
732 .into_admin_api_object(database, storage_url_retriever)
733 .await?,
734 ),
735 None => None,
736 },
737 node: self
738 .node
739 .fetch_cached(database)
740 .await?
741 .into_admin_api_object(database)
742 .await?,
743 name: self.name,
744 ignored_files: self.ignored_files,
745 is_successful: self.successful,
746 is_locked: self.locked,
747 is_browsable: self.browsable,
748 is_streaming: self.streaming,
749 checksum: self.checksum,
750 bytes: self.bytes,
751 files: self.files,
752 completed: self.completed.map(|dt| dt.and_utc()),
753 created: self.created.and_utc(),
754 })
755 }
756
757 pub async fn into_admin_api_object(
758 self,
759 database: &crate::database::Database,
760 storage_url_retriever: &StorageUrlRetriever<'_>,
761 ) -> Result<AdminApiServerBackup, anyhow::Error> {
762 Ok(AdminApiServerBackup {
763 uuid: self.uuid,
764 server: match self.server {
765 Some(server) => Some(
766 server
767 .fetch_cached(database)
768 .await?
769 .into_admin_api_object(database, storage_url_retriever)
770 .await?,
771 ),
772 None => None,
773 },
774 name: self.name,
775 ignored_files: self.ignored_files,
776 is_successful: self.successful,
777 is_locked: self.locked,
778 is_browsable: self.browsable,
779 is_streaming: self.streaming,
780 checksum: self.checksum,
781 bytes: self.bytes,
782 files: self.files,
783 completed: self.completed.map(|dt| dt.and_utc()),
784 created: self.created.and_utc(),
785 })
786 }
787
788 #[inline]
789 pub fn into_api_object(self) -> ApiServerBackup {
790 ApiServerBackup {
791 uuid: self.uuid,
792 name: self.name,
793 ignored_files: self.ignored_files,
794 is_successful: self.successful,
795 is_locked: self.locked,
796 is_browsable: self.browsable,
797 is_streaming: self.streaming,
798 checksum: self.checksum,
799 bytes: self.bytes,
800 files: self.files,
801 completed: self.completed.map(|dt| dt.and_utc()),
802 created: self.created.and_utc(),
803 }
804 }
805}
806
807#[derive(Validate)]
808pub struct CreateServerBackupOptions<'a> {
809 #[garde(skip)]
810 pub server: &'a super::server::Server,
811 #[garde(length(chars, min = 1, max = 255))]
812 pub name: compact_str::CompactString,
813 #[garde(skip)]
814 pub ignored_files: Vec<compact_str::CompactString>,
815}
816
817#[async_trait::async_trait]
818impl CreatableModel for ServerBackup {
819 type CreateOptions<'a> = CreateServerBackupOptions<'a>;
820 type CreateResult = Self;
821
822 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
823 static CREATE_LISTENERS: LazyLock<CreateListenerList<ServerBackup>> =
824 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
825
826 &CREATE_LISTENERS
827 }
828
829 async fn create(
830 state: &crate::State,
831 mut options: Self::CreateOptions<'_>,
832 ) -> Result<Self, crate::database::DatabaseError> {
833 options.validate()?;
834
835 let backup_configuration = options
836 .server
837 .backup_configuration(&state.database)
838 .await
839 .ok_or_else(|| {
840 anyhow::Error::new(
841 crate::response::DisplayError::new(
842 "no backup configuration available, unable to create backup",
843 )
844 .with_status(StatusCode::EXPECTATION_FAILED),
845 )
846 })?;
847
848 if backup_configuration.maintenance_enabled {
849 return Err(anyhow::Error::new(
850 crate::response::DisplayError::new(
851 "cannot create backup while backup configuration is in maintenance mode",
852 )
853 .with_status(StatusCode::EXPECTATION_FAILED),
854 )
855 .into());
856 }
857
858 let mut transaction = state.database.write().begin().await?;
859
860 let mut query_builder = InsertQueryBuilder::new("server_backups");
861
862 Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
863 .await?;
864
865 query_builder
866 .set("server_uuid", options.server.uuid)
867 .set("node_uuid", options.server.node.uuid)
868 .set("backup_configuration_uuid", backup_configuration.uuid)
869 .set("name", &options.name)
870 .set("ignored_files", &options.ignored_files)
871 .set("bytes", 0i64)
872 .set("disk", backup_configuration.backup_disk);
873
874 let row = query_builder
875 .returning(&Self::columns_sql(None))
876 .fetch_one(&mut *transaction)
877 .await?;
878 let backup = Self::map(None, &row)?;
879
880 transaction.commit().await?;
881
882 let server = options.server.clone();
883 let database = Arc::clone(&state.database);
884 let backup_uuid = backup.uuid;
885 let backup_disk = backup_configuration.backup_disk;
886 let ignored_files_str = options
887 .ignored_files
888 .iter()
889 .map(|s| s.as_str())
890 .collect::<Vec<_>>()
891 .join("\n");
892
893 tokio::spawn(async move {
894 tracing::debug!(backup = %backup_uuid, "creating server backup");
895
896 let node = match server.node.fetch_cached(&database).await {
897 Ok(node) => node,
898 Err(err) => {
899 tracing::error!(backup = %backup_uuid, "failed to create server backup: {:?}", err);
900
901 if let Err(err) = sqlx::query!(
902 "UPDATE server_backups
903 SET successful = false, completed = NOW()
904 WHERE server_backups.uuid = $1",
905 backup_uuid
906 )
907 .execute(database.write())
908 .await
909 {
910 tracing::error!(backup = %backup_uuid, "failed to update server backup status: {:?}", err);
911 }
912
913 return;
914 }
915 };
916
917 let api_client = match node.api_client(&database).await {
918 Ok(api_client) => api_client,
919 Err(err) => {
920 tracing::error!(backup = %backup_uuid, "failed to create server backup: {:?}", err);
921
922 if let Err(err) = sqlx::query!(
923 "UPDATE server_backups
924 SET successful = false, completed = NOW()
925 WHERE server_backups.uuid = $1",
926 backup_uuid
927 )
928 .execute(database.write())
929 .await
930 {
931 tracing::error!(backup = %backup_uuid, "failed to update server backup status: {:?}", err);
932 }
933
934 return;
935 }
936 };
937
938 if let Err(err) = api_client
939 .post_servers_server_backup(
940 server.uuid,
941 &wings_api::servers_server_backup::post::RequestBody {
942 adapter: backup_disk.to_wings_adapter(),
943 uuid: backup_uuid,
944 ignore: ignored_files_str.into(),
945 },
946 )
947 .await
948 {
949 tracing::error!(backup = %backup_uuid, "failed to create server backup: {:?}", err);
950
951 if let Err(err) = sqlx::query!(
952 "UPDATE server_backups
953 SET successful = false, completed = NOW()
954 WHERE server_backups.uuid = $1",
955 backup_uuid
956 )
957 .execute(database.write())
958 .await
959 {
960 tracing::error!(backup = %backup_uuid, "failed to update server backup status: {:?}", err);
961 }
962 }
963 });
964
965 Ok(backup)
966 }
967}
968
969#[derive(ToSchema, Serialize, Deserialize, Validate, Default)]
970pub struct UpdateServerBackupOptions {
971 #[garde(length(chars, min = 1, max = 255))]
972 #[schema(min_length = 1, max_length = 255)]
973 pub name: Option<compact_str::CompactString>,
974 #[garde(skip)]
975 pub locked: Option<bool>,
976}
977
978#[async_trait::async_trait]
979impl UpdatableModel for ServerBackup {
980 type UpdateOptions = UpdateServerBackupOptions;
981
982 fn get_update_handlers() -> &'static LazyLock<UpdateListenerList<Self>> {
983 static UPDATE_LISTENERS: LazyLock<UpdateListenerList<ServerBackup>> =
984 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
985
986 &UPDATE_LISTENERS
987 }
988
989 async fn update(
990 &mut self,
991 state: &crate::State,
992 mut options: Self::UpdateOptions,
993 ) -> Result<(), crate::database::DatabaseError> {
994 options.validate()?;
995
996 let mut transaction = state.database.write().begin().await?;
997
998 let mut query_builder = UpdateQueryBuilder::new("server_backups");
999
1000 Self::run_update_handlers(
1001 self,
1002 &mut options,
1003 &mut query_builder,
1004 state,
1005 &mut transaction,
1006 )
1007 .await?;
1008
1009 query_builder
1010 .set("name", options.name.as_ref())
1011 .set("locked", options.locked)
1012 .where_eq("uuid", self.uuid);
1013
1014 query_builder.execute(&mut *transaction).await?;
1015
1016 if let Some(name) = options.name {
1017 self.name = name;
1018 }
1019 if let Some(locked) = options.locked {
1020 self.locked = locked;
1021 }
1022
1023 transaction.commit().await?;
1024
1025 Ok(())
1026 }
1027}
1028
1029#[async_trait::async_trait]
1030impl ByUuid for ServerBackup {
1031 async fn by_uuid(
1032 database: &crate::database::Database,
1033 uuid: uuid::Uuid,
1034 ) -> Result<Self, crate::database::DatabaseError> {
1035 let row = sqlx::query(&format!(
1036 r#"
1037 SELECT {}
1038 FROM server_backups
1039 WHERE server_backups.uuid = $1
1040 "#,
1041 Self::columns_sql(None)
1042 ))
1043 .bind(uuid)
1044 .fetch_one(database.read())
1045 .await?;
1046
1047 Self::map(None, &row)
1048 }
1049}
1050
1051#[derive(Default)]
1052pub struct DeleteServerBackupOptions {
1053 pub force: bool,
1054}
1055
1056#[async_trait::async_trait]
1057impl DeletableModel for ServerBackup {
1058 type DeleteOptions = DeleteServerBackupOptions;
1059
1060 fn get_delete_handlers() -> &'static LazyLock<DeleteListenerList<Self>> {
1061 static DELETE_LISTENERS: LazyLock<DeleteListenerList<ServerBackup>> =
1062 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1063
1064 &DELETE_LISTENERS
1065 }
1066
1067 async fn delete(
1068 &self,
1069 state: &crate::State,
1070 options: Self::DeleteOptions,
1071 ) -> Result<(), anyhow::Error> {
1072 let mut transaction = state.database.write().begin().await?;
1073
1074 self.run_delete_handlers(&options, state, &mut transaction)
1075 .await?;
1076
1077 let node = self.node.fetch_cached(&state.database).await?;
1078
1079 let backup_configuration = match &self.backup_configuration {
1080 Some(backup_configuration) => {
1081 backup_configuration.fetch_cached(&state.database).await?
1082 }
1083 None if options.force => {
1084 let database = Arc::clone(&state.database);
1085 let backup_uuid = self.uuid;
1086 let backup_disk = self.disk;
1087
1088 return tokio::spawn(async move {
1089 if backup_disk != BackupDisk::S3
1090 && let Err(err) = node
1091 .api_client(&database)
1092 .await?
1093 .delete_backups_backup(
1094 backup_uuid,
1095 &wings_api::backups_backup::delete::RequestBody {
1096 adapter: backup_disk.to_wings_adapter(),
1097 },
1098 )
1099 .await
1100 && !matches!(
1101 err,
1102 wings_api::client::ApiHttpError::Http(StatusCode::NOT_FOUND, _)
1103 )
1104 {
1105 tracing::error!(node = %node.uuid, backup = %backup_uuid, "unable to delete backup on node: {:?}", err)
1106 }
1107
1108 sqlx::query(
1109 r#"
1110 UPDATE server_backups
1111 SET deleted = NOW()
1112 WHERE server_backups.uuid = $1
1113 "#,
1114 )
1115 .bind(backup_uuid)
1116 .execute(&mut *transaction)
1117 .await?;
1118
1119 transaction.commit().await?;
1120
1121 Ok(())
1122 })
1123 .await?;
1124 }
1125 None => {
1126 return Err(crate::response::DisplayError::new(
1127 "no backup configuration available, unable to delete backup",
1128 )
1129 .with_status(StatusCode::EXPECTATION_FAILED)
1130 .into());
1131 }
1132 };
1133
1134 if backup_configuration.maintenance_enabled {
1135 return Err(crate::response::DisplayError::new(
1136 "cannot delete backup while backup configuration is in maintenance mode",
1137 )
1138 .with_status(StatusCode::EXPECTATION_FAILED)
1139 .into());
1140 }
1141
1142 let database = Arc::clone(&state.database);
1143 let server_uuid = self.server.as_ref().map(|s| s.uuid);
1144 let backup_uuid = self.uuid;
1145 let backup_disk = self.disk;
1146 let backup_upload_path = self.upload_path.clone();
1147
1148 tokio::spawn(async move {
1149 match backup_disk {
1150 BackupDisk::S3 => {
1151 if let Some(mut s3_configuration) = backup_configuration.backup_configs.s3 {
1152 s3_configuration.decrypt(&database).await?;
1153
1154 let client = s3_configuration
1155 .into_client()
1156 .map_err(|err| sqlx::Error::Io(std::io::Error::other(err)))?;
1157 let file_path = match backup_upload_path {
1158 Some(path) => path,
1159 None => if let Some(server_uuid) = server_uuid {
1160 Self::s3_path(server_uuid, backup_uuid)
1161 } else {
1162 return Err(anyhow::anyhow!("backup upload path not found"))
1163 }
1164 };
1165
1166 if let Err(err) = client.delete_object(file_path).await {
1167 if options.force {
1168 tracing::error!(server = ?server_uuid, backup = %backup_uuid, "failed to delete S3 backup, ignoring: {:?}", err);
1169 } else {
1170 return Err(err.into());
1171 }
1172 }
1173 } else if options.force {
1174 tracing::warn!(server = ?server_uuid, backup = %backup_uuid, "S3 backup deletion attempted but no S3 configuration found, ignoring");
1175 } else {
1176 return Err(anyhow::anyhow!("s3 backup deletion attempted but no S3 configuration found"));
1177 }
1178 }
1179 _ => {
1180 if let Err(err) = node
1181 .api_client(&database)
1182 .await?
1183 .delete_backups_backup(
1184 backup_uuid,
1185 &wings_api::backups_backup::delete::RequestBody {
1186 adapter: backup_disk.to_wings_adapter(),
1187 },
1188 )
1189 .await
1190 && !matches!(err, wings_api::client::ApiHttpError::Http(StatusCode::NOT_FOUND, _))
1191 {
1192 return Err(err.into());
1193 }
1194 }
1195 }
1196
1197 sqlx::query(
1198 r#"
1199 UPDATE server_backups
1200 SET deleted = NOW()
1201 WHERE server_backups.uuid = $1
1202 "#,
1203 )
1204 .bind(backup_uuid)
1205 .execute(&mut *transaction)
1206 .await?;
1207
1208 transaction.commit().await?;
1209
1210 Ok(())
1211 }).await?
1212 }
1213}
1214
1215#[derive(ToSchema, Serialize)]
1216#[schema(title = "AdminNodeServerBackup")]
1217pub struct AdminApiNodeServerBackup {
1218 pub uuid: uuid::Uuid,
1219 pub server: Option<super::server::AdminApiServer>,
1220 pub node: super::node::AdminApiNode,
1221
1222 pub name: compact_str::CompactString,
1223 pub ignored_files: Vec<compact_str::CompactString>,
1224
1225 pub is_successful: bool,
1226 pub is_locked: bool,
1227 pub is_browsable: bool,
1228 pub is_streaming: bool,
1229
1230 pub checksum: Option<compact_str::CompactString>,
1231 pub bytes: i64,
1232 pub files: i64,
1233
1234 pub completed: Option<chrono::DateTime<chrono::Utc>>,
1235 pub created: chrono::DateTime<chrono::Utc>,
1236}
1237
1238#[derive(ToSchema, Serialize)]
1239#[schema(title = "AdminServerBackup")]
1240pub struct AdminApiServerBackup {
1241 pub uuid: uuid::Uuid,
1242 pub server: Option<super::server::AdminApiServer>,
1243
1244 pub name: compact_str::CompactString,
1245 pub ignored_files: Vec<compact_str::CompactString>,
1246
1247 pub is_successful: bool,
1248 pub is_locked: bool,
1249 pub is_browsable: bool,
1250 pub is_streaming: bool,
1251
1252 pub checksum: Option<compact_str::CompactString>,
1253 pub bytes: i64,
1254 pub files: i64,
1255
1256 pub completed: Option<chrono::DateTime<chrono::Utc>>,
1257 pub created: chrono::DateTime<chrono::Utc>,
1258}
1259
1260#[derive(ToSchema, Serialize)]
1261#[schema(title = "ServerBackup")]
1262pub struct ApiServerBackup {
1263 pub uuid: uuid::Uuid,
1264
1265 pub name: compact_str::CompactString,
1266 pub ignored_files: Vec<compact_str::CompactString>,
1267
1268 pub is_successful: bool,
1269 pub is_locked: bool,
1270 pub is_browsable: bool,
1271 pub is_streaming: bool,
1272
1273 pub checksum: Option<compact_str::CompactString>,
1274 pub bytes: i64,
1275 pub files: i64,
1276
1277 pub completed: Option<chrono::DateTime<chrono::Utc>>,
1278 pub created: chrono::DateTime<chrono::Utc>,
1279}