1mod events;
2pub use events::ServerBackupEvent;
3
4use crate::{
5 jwt::BasePayload,
6 models::{InsertQueryBuilder, UpdateQueryBuilder, server_variable::ServerVariable},
7 prelude::*,
8 storage::StorageUrlRetriever,
9};
10use compact_str::ToCompactString;
11use garde::Validate;
12use reqwest::StatusCode;
13use serde::{Deserialize, Serialize};
14use sqlx::{Row, postgres::PgRow, prelude::Type};
15use std::{
16 collections::{BTreeMap, HashMap},
17 sync::{Arc, LazyLock},
18};
19use utoipa::ToSchema;
20
21#[derive(Debug, ToSchema, Serialize, Deserialize, Type, PartialEq, Eq, Hash, Clone, Copy)]
22#[serde(rename_all = "kebab-case")]
23#[sqlx(type_name = "backup_disk", rename_all = "SCREAMING_SNAKE_CASE")]
24pub enum BackupDisk {
25 Local,
26 S3,
27 DdupBak,
28 Btrfs,
29 Zfs,
30 Restic,
31 ProxmoxBackupServer,
32 Kopia,
33}
34
35impl BackupDisk {
36 #[inline]
37 pub fn to_wings_adapter(self) -> wings_api::BackupAdapter {
38 match self {
39 BackupDisk::Local => wings_api::BackupAdapter::Wings,
40 BackupDisk::S3 => wings_api::BackupAdapter::S3,
41 BackupDisk::DdupBak => wings_api::BackupAdapter::DdupBak,
42 BackupDisk::Btrfs => wings_api::BackupAdapter::Btrfs,
43 BackupDisk::Zfs => wings_api::BackupAdapter::Zfs,
44 BackupDisk::Restic => wings_api::BackupAdapter::Restic,
45 BackupDisk::ProxmoxBackupServer => wings_api::BackupAdapter::ProxmoxBackupServer,
46 BackupDisk::Kopia => wings_api::BackupAdapter::Kopia,
47 }
48 }
49}
50
51pub struct ServerBackupRestoreOptions {
52 pub truncate_directory: bool,
53 pub restore_startup: bool,
54}
55
56#[derive(Serialize, Deserialize, Clone)]
57pub struct ServerBackup {
58 pub uuid: uuid::Uuid,
59 pub server: Option<Fetchable<super::server::Server>>,
60 pub node: Fetchable<super::node::Node>,
61 pub backup_configuration: Option<Fetchable<super::backup_configuration::BackupConfiguration>>,
62
63 pub name: compact_str::CompactString,
64 pub successful: bool,
65 pub browsable: bool,
66 pub streaming: bool,
67 pub locked: bool,
68 pub shared: bool,
69
70 pub ignored_files: Vec<compact_str::CompactString>,
71 pub checksum: Option<compact_str::CompactString>,
72 pub bytes: i64,
73 pub files: i64,
74
75 pub disk: BackupDisk,
76 pub upload_id: Option<compact_str::CompactString>,
77 pub upload_path: Option<compact_str::CompactString>,
78 pub metadata: serde_json::Value,
79
80 pub completed: Option<chrono::NaiveDateTime>,
81 pub deleted: Option<chrono::NaiveDateTime>,
82 pub created: chrono::NaiveDateTime,
83
84 extension_data: super::ModelExtensionData,
85}
86
87impl BaseModel for ServerBackup {
88 const NAME: &'static str = "server_backup";
89
90 fn get_extension_list() -> &'static super::ModelExtensionList {
91 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
92 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
93
94 &EXTENSIONS
95 }
96
97 fn get_extension_data(&self) -> &super::ModelExtensionData {
98 &self.extension_data
99 }
100
101 #[inline]
102 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
103 let prefix = prefix.unwrap_or_default();
104
105 BTreeMap::from([
106 (
107 "server_backups.uuid",
108 compact_str::format_compact!("{prefix}uuid"),
109 ),
110 (
111 "server_backups.server_uuid",
112 compact_str::format_compact!("{prefix}server_uuid"),
113 ),
114 (
115 "server_backups.node_uuid",
116 compact_str::format_compact!("{prefix}node_uuid"),
117 ),
118 (
119 "server_backups.backup_configuration_uuid",
120 compact_str::format_compact!("{prefix}backup_configuration_uuid"),
121 ),
122 (
123 "server_backups.name",
124 compact_str::format_compact!("{prefix}name"),
125 ),
126 (
127 "server_backups.successful",
128 compact_str::format_compact!("{prefix}successful"),
129 ),
130 (
131 "server_backups.browsable",
132 compact_str::format_compact!("{prefix}browsable"),
133 ),
134 (
135 "server_backups.streaming",
136 compact_str::format_compact!("{prefix}streaming"),
137 ),
138 (
139 "server_backups.locked",
140 compact_str::format_compact!("{prefix}locked"),
141 ),
142 (
143 "server_backups.shared",
144 compact_str::format_compact!("{prefix}shared"),
145 ),
146 (
147 "server_backups.ignored_files",
148 compact_str::format_compact!("{prefix}ignored_files"),
149 ),
150 (
151 "server_backups.checksum",
152 compact_str::format_compact!("{prefix}checksum"),
153 ),
154 (
155 "server_backups.bytes",
156 compact_str::format_compact!("{prefix}bytes"),
157 ),
158 (
159 "server_backups.files",
160 compact_str::format_compact!("{prefix}files"),
161 ),
162 (
163 "server_backups.disk",
164 compact_str::format_compact!("{prefix}disk"),
165 ),
166 (
167 "server_backups.upload_id",
168 compact_str::format_compact!("{prefix}upload_id"),
169 ),
170 (
171 "server_backups.upload_path",
172 compact_str::format_compact!("{prefix}upload_path"),
173 ),
174 (
175 "server_backups.metadata",
176 compact_str::format_compact!("{prefix}metadata"),
177 ),
178 (
179 "server_backups.completed",
180 compact_str::format_compact!("{prefix}completed"),
181 ),
182 (
183 "server_backups.deleted",
184 compact_str::format_compact!("{prefix}deleted"),
185 ),
186 (
187 "server_backups.created",
188 compact_str::format_compact!("{prefix}created"),
189 ),
190 ])
191 }
192
193 #[inline]
194 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
195 let prefix = prefix.unwrap_or_default();
196
197 Ok(Self {
198 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
199 server: super::server::Server::get_fetchable_from_row(
200 row,
201 compact_str::format_compact!("{prefix}server_uuid"),
202 ),
203 backup_configuration:
204 super::backup_configuration::BackupConfiguration::get_fetchable_from_row(
205 row,
206 compact_str::format_compact!("{prefix}backup_configuration_uuid"),
207 ),
208 node: super::node::Node::get_fetchable(
209 row.try_get(compact_str::format_compact!("{prefix}node_uuid").as_str())?,
210 ),
211 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
212 successful: row.try_get(compact_str::format_compact!("{prefix}successful").as_str())?,
213 browsable: row.try_get(compact_str::format_compact!("{prefix}browsable").as_str())?,
214 streaming: row.try_get(compact_str::format_compact!("{prefix}streaming").as_str())?,
215 locked: row.try_get(compact_str::format_compact!("{prefix}locked").as_str())?,
216 shared: row.try_get(compact_str::format_compact!("{prefix}shared").as_str())?,
217 ignored_files: row
218 .try_get(compact_str::format_compact!("{prefix}ignored_files").as_str())?,
219 checksum: row.try_get(compact_str::format_compact!("{prefix}checksum").as_str())?,
220 bytes: row.try_get(compact_str::format_compact!("{prefix}bytes").as_str())?,
221 files: row.try_get(compact_str::format_compact!("{prefix}files").as_str())?,
222 disk: row.try_get(compact_str::format_compact!("{prefix}disk").as_str())?,
223 upload_id: row.try_get(compact_str::format_compact!("{prefix}upload_id").as_str())?,
224 upload_path: row
225 .try_get(compact_str::format_compact!("{prefix}upload_path").as_str())?,
226 metadata: row.try_get(compact_str::format_compact!("{prefix}metadata").as_str())?,
227 completed: row.try_get(compact_str::format_compact!("{prefix}completed").as_str())?,
228 deleted: row.try_get(compact_str::format_compact!("{prefix}deleted").as_str())?,
229 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
230 extension_data: Self::map_extensions(prefix, row)?,
231 })
232 }
233}
234
235impl ServerBackup {
236 pub async fn create_raw(
237 state: &crate::State,
238 mut options: CreateServerBackupOptions<'_>,
239 ) -> Result<Self, anyhow::Error> {
240 let backup_configuration = options
241 .server
242 .backup_configuration(&state.database)
243 .await
244 .ok_or_else(|| {
245 crate::response::DisplayError::new(
246 "no backup configuration available, unable to create backup",
247 )
248 .with_status(StatusCode::EXPECTATION_FAILED)
249 })?;
250
251 if backup_configuration.maintenance_enabled {
252 return Err(crate::response::DisplayError::new(
253 "cannot create backup while backup configuration is in maintenance mode",
254 )
255 .with_status(StatusCode::EXPECTATION_FAILED)
256 .into());
257 }
258
259 let mut transaction = state.database.write().begin().await?;
260
261 let mut query_builder = InsertQueryBuilder::new("server_backups");
262
263 Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
264 .await?;
265
266 query_builder
267 .set("server_uuid", options.server.uuid)
268 .set("node_uuid", options.server.node.uuid)
269 .set("backup_configuration_uuid", backup_configuration.uuid)
270 .set("name", &options.name)
271 .set("ignored_files", &options.ignored_files)
272 .set("bytes", 0i64)
273 .set("disk", backup_configuration.backup_disk)
274 .set("shared", backup_configuration.shared)
275 .set("metadata", &options.metadata);
276
277 let row = query_builder
278 .returning(&Self::columns_sql(None))
279 .fetch_one(&mut *transaction)
280 .await?;
281 let mut backup = Self::map(None, &row)?;
282
283 Self::run_after_create_handlers(&mut backup, &options, state, &mut transaction).await?;
284
285 transaction.commit().await?;
286
287 Ok(backup)
288 }
289
290 pub async fn by_server_uuid_uuid(
291 database: &crate::database::Database,
292 server_uuid: uuid::Uuid,
293 uuid: uuid::Uuid,
294 ) -> Result<Option<Self>, crate::database::DatabaseError> {
295 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
296 r#"
297 SELECT {}
298 FROM server_backups
299 WHERE server_backups.server_uuid = $1 AND server_backups.uuid = $2
300 "#,
301 Self::columns_sql(None)
302 )))
303 .bind(server_uuid)
304 .bind(uuid)
305 .fetch_optional(database.read())
306 .await?;
307
308 row.try_map(|row| Self::map(None, &row))
309 }
310
311 pub async fn by_node_uuid_uuid(
312 database: &crate::database::Database,
313 node_uuid: uuid::Uuid,
314 uuid: uuid::Uuid,
315 ) -> Result<Option<Self>, crate::database::DatabaseError> {
316 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
317 r#"
318 SELECT {}
319 FROM server_backups
320 WHERE server_backups.node_uuid = $1 AND server_backups.uuid = $2
321 "#,
322 Self::columns_sql(None)
323 )))
324 .bind(node_uuid)
325 .bind(uuid)
326 .fetch_optional(database.read())
327 .await?;
328
329 row.try_map(|row| Self::map(None, &row))
330 }
331
332 pub async fn by_server_uuid_with_pagination(
333 database: &crate::database::Database,
334 server_uuid: uuid::Uuid,
335 page: i64,
336 per_page: i64,
337 search: Option<&str>,
338 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
339 let offset = (page - 1) * per_page;
340
341 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
342 r#"
343 SELECT {}, COUNT(*) OVER() AS total_count
344 FROM server_backups
345 WHERE
346 server_backups.server_uuid = $1
347 AND server_backups.deleted IS NULL
348 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
349 ORDER BY server_backups.created
350 LIMIT $3 OFFSET $4
351 "#,
352 Self::columns_sql(None)
353 )))
354 .bind(server_uuid)
355 .bind(search)
356 .bind(per_page)
357 .bind(offset)
358 .fetch_all(database.read())
359 .await?;
360
361 Ok(super::Pagination {
362 total: rows
363 .first()
364 .map_or(Ok(0), |row| row.try_get("total_count"))?,
365 per_page,
366 page,
367 data: rows
368 .into_iter()
369 .map(|row| Self::map(None, &row))
370 .try_collect_vec()?,
371 })
372 }
373
374 pub async fn by_server_uuid_node_uuid_with_pagination(
375 database: &crate::database::Database,
376 server_uuid: uuid::Uuid,
377 node_uuid: uuid::Uuid,
378 page: i64,
379 per_page: i64,
380 search: Option<&str>,
381 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
382 let offset = (page - 1) * per_page;
383
384 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
385 r#"
386 SELECT {}, COUNT(*) OVER() AS total_count
387 FROM server_backups
388 WHERE
389 server_backups.server_uuid = $1
390 AND server_backups.node_uuid = $2
391 AND server_backups.deleted IS NULL
392 AND ($3 IS NULL OR server_backups.name ILIKE '%' || $3 || '%')
393 ORDER BY server_backups.created
394 LIMIT $4 OFFSET $5
395 "#,
396 Self::columns_sql(None)
397 )))
398 .bind(server_uuid)
399 .bind(node_uuid)
400 .bind(search)
401 .bind(per_page)
402 .bind(offset)
403 .fetch_all(database.read())
404 .await?;
405
406 Ok(super::Pagination {
407 total: rows
408 .first()
409 .map_or(Ok(0), |row| row.try_get("total_count"))?,
410 per_page,
411 page,
412 data: rows
413 .into_iter()
414 .map(|row| Self::map(None, &row))
415 .try_collect_vec()?,
416 })
417 }
418
419 pub async fn by_partially_detached_server_uuid_node_uuid_with_pagination(
420 database: &crate::database::Database,
421 server_uuid: uuid::Uuid,
422 node_uuid: uuid::Uuid,
423 page: i64,
424 per_page: i64,
425 search: Option<&str>,
426 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
427 let offset = (page - 1) * per_page;
428
429 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
430 r#"
431 SELECT {}, COUNT(*) OVER() AS total_count
432 FROM server_backups
433 WHERE
434 server_backups.server_uuid = $1
435 AND server_backups.node_uuid != $2
436 AND server_backups.deleted IS NULL
437 AND ($3 IS NULL OR server_backups.name ILIKE '%' || $3 || '%')
438 ORDER BY server_backups.created
439 LIMIT $4 OFFSET $5
440 "#,
441 Self::columns_sql(None)
442 )))
443 .bind(server_uuid)
444 .bind(node_uuid)
445 .bind(search)
446 .bind(per_page)
447 .bind(offset)
448 .fetch_all(database.read())
449 .await?;
450
451 Ok(super::Pagination {
452 total: rows
453 .first()
454 .map_or(Ok(0), |row| row.try_get("total_count"))?,
455 per_page,
456 page,
457 data: rows
458 .into_iter()
459 .map(|row| Self::map(None, &row))
460 .try_collect_vec()?,
461 })
462 }
463
464 pub async fn by_node_uuid_with_pagination(
465 database: &crate::database::Database,
466 node_uuid: uuid::Uuid,
467 page: i64,
468 per_page: i64,
469 search: Option<&str>,
470 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
471 let offset = (page - 1) * per_page;
472
473 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
474 r#"
475 SELECT {}, COUNT(*) OVER() AS total_count
476 FROM server_backups
477 WHERE
478 server_backups.node_uuid = $1
479 AND server_backups.deleted IS NULL
480 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
481 ORDER BY server_backups.created
482 LIMIT $3 OFFSET $4
483 "#,
484 Self::columns_sql(None)
485 )))
486 .bind(node_uuid)
487 .bind(search)
488 .bind(per_page)
489 .bind(offset)
490 .fetch_all(database.read())
491 .await?;
492
493 Ok(super::Pagination {
494 total: rows
495 .first()
496 .map_or(Ok(0), |row| row.try_get("total_count"))?,
497 per_page,
498 page,
499 data: rows
500 .into_iter()
501 .map(|row| Self::map(None, &row))
502 .try_collect_vec()?,
503 })
504 }
505
506 pub async fn by_backup_configuration_uuid_with_pagination(
507 database: &crate::database::Database,
508 backup_configuration_uuid: uuid::Uuid,
509 page: i64,
510 per_page: i64,
511 search: Option<&str>,
512 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
513 let offset = (page - 1) * per_page;
514
515 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
516 r#"
517 SELECT {}, COUNT(*) OVER() AS total_count
518 FROM server_backups
519 WHERE
520 server_backups.backup_configuration_uuid = $1
521 AND server_backups.deleted IS NULL
522 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
523 ORDER BY server_backups.created
524 LIMIT $3 OFFSET $4
525 "#,
526 Self::columns_sql(None)
527 )))
528 .bind(backup_configuration_uuid)
529 .bind(search)
530 .bind(per_page)
531 .bind(offset)
532 .fetch_all(database.read())
533 .await?;
534
535 Ok(super::Pagination {
536 total: rows
537 .first()
538 .map_or(Ok(0), |row| row.try_get("total_count"))?,
539 per_page,
540 page,
541 data: rows
542 .into_iter()
543 .map(|row| Self::map(None, &row))
544 .try_collect_vec()?,
545 })
546 }
547
548 pub async fn by_detached_node_uuid_with_pagination(
549 database: &crate::database::Database,
550 node_uuid: uuid::Uuid,
551 page: i64,
552 per_page: i64,
553 search: Option<&str>,
554 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
555 let offset = (page - 1) * per_page;
556
557 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
558 r#"
559 SELECT {}, COUNT(*) OVER() AS total_count
560 FROM server_backups
561 WHERE
562 server_backups.node_uuid = $1
563 AND server_backups.server_uuid IS NULL
564 AND server_backups.deleted IS NULL
565 AND ($2 IS NULL OR server_backups.name ILIKE '%' || $2 || '%')
566 ORDER BY server_backups.created
567 LIMIT $3 OFFSET $4
568 "#,
569 Self::columns_sql(None)
570 )))
571 .bind(node_uuid)
572 .bind(search)
573 .bind(per_page)
574 .bind(offset)
575 .fetch_all(database.read())
576 .await?;
577
578 Ok(super::Pagination {
579 total: rows
580 .first()
581 .map_or(Ok(0), |row| row.try_get("total_count"))?,
582 per_page,
583 page,
584 data: rows
585 .into_iter()
586 .map(|row| Self::map(None, &row))
587 .try_collect_vec()?,
588 })
589 }
590
591 pub async fn all_uuids_by_server_uuid(
592 database: &crate::database::Database,
593 server_uuid: uuid::Uuid,
594 ) -> Result<Vec<uuid::Uuid>, crate::database::DatabaseError> {
595 let rows = sqlx::query(
596 r#"
597 SELECT server_backups.uuid
598 FROM server_backups
599 WHERE server_backups.server_uuid = $1 AND server_backups.deleted IS NULL
600 "#,
601 )
602 .bind(server_uuid)
603 .fetch_all(database.read())
604 .await?;
605
606 Ok(rows
607 .into_iter()
608 .map(|row| row.get::<uuid::Uuid, _>("uuid"))
609 .collect())
610 }
611
612 pub async fn all_uuids_by_server_uuid_not_shared(
613 database: &crate::database::Database,
614 server_uuid: uuid::Uuid,
615 ) -> Result<Vec<uuid::Uuid>, crate::database::DatabaseError> {
616 let rows = sqlx::query(
617 r#"
618 SELECT server_backups.uuid
619 FROM server_backups
620 WHERE server_backups.server_uuid = $1 AND server_backups.deleted IS NULL AND server_backups.shared = false
621 "#,
622 )
623 .bind(server_uuid)
624 .fetch_all(database.read())
625 .await?;
626
627 Ok(rows
628 .into_iter()
629 .map(|row| row.get::<uuid::Uuid, _>("uuid"))
630 .collect())
631 }
632
633 pub async fn all_by_server_uuid(
634 database: &crate::database::Database,
635 server_uuid: uuid::Uuid,
636 ) -> Result<Vec<Self>, crate::database::DatabaseError> {
637 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
638 r#"
639 SELECT {}
640 FROM server_backups
641 WHERE server_backups.server_uuid = $1 AND server_backups.deleted IS NULL
642 "#,
643 Self::columns_sql(None)
644 )))
645 .bind(server_uuid)
646 .fetch_all(database.read())
647 .await?;
648
649 rows.into_iter()
650 .map(|row| Self::map(None, &row))
651 .try_collect_vec()
652 }
653
654 pub async fn count_by_server_uuid(
655 database: &crate::database::Database,
656 server_uuid: uuid::Uuid,
657 ) -> Result<i64, sqlx::Error> {
658 sqlx::query_scalar(
659 r#"
660 SELECT COUNT(*)
661 FROM server_backups
662 WHERE server_backups.server_uuid = $1 AND server_backups.deleted IS NULL
663 "#,
664 )
665 .bind(server_uuid)
666 .fetch_one(database.read())
667 .await
668 }
669
670 pub async fn generate_metadata(
671 state: &crate::State,
672 server: &super::server::Server,
673 ) -> Result<serde_json::Value, anyhow::Error> {
674 let mut variables = serde_json::Map::new();
675
676 for variable in ServerVariable::all_by_server_uuid_egg_uuid(
677 &state.database,
678 server.uuid,
679 server.egg.uuid,
680 )
681 .await?
682 {
683 variables.insert(variable.variable.env_variable.into(), variable.value.into());
684 }
685
686 Ok(serde_json::json!({
687 "startup": server.startup,
688 "image": server.image,
689 "variables": variables,
690 }))
691 }
692
693 pub async fn download_url(
694 &self,
695 state: &crate::State,
696 user: &super::user::User,
697 node: &super::node::Node,
698 archive_format: wings_api::StreamableArchiveFormat,
699 ) -> Result<String, anyhow::Error> {
700 let backup_configuration = self
701 .backup_configuration
702 .as_ref()
703 .ok_or_else(|| {
704 crate::response::DisplayError::new(
705 "no backup configuration available, unable to restore backup",
706 )
707 .with_status(StatusCode::EXPECTATION_FAILED)
708 })?
709 .fetch_cached(&state.database)
710 .await?;
711
712 if backup_configuration.maintenance_enabled {
713 return Err(crate::response::DisplayError::new(
714 "cannot restore backup while backup configuration is in maintenance mode",
715 )
716 .with_status(StatusCode::EXPECTATION_FAILED)
717 .into());
718 }
719
720 if matches!(self.disk, BackupDisk::S3)
721 && let Some(mut s3_configuration) = backup_configuration.backup_configs.s3
722 {
723 s3_configuration.decrypt(&state.database).await?;
724
725 let (client, bucket) = s3_configuration.into_client();
726
727 let file_path = match &self.upload_path {
728 Some(path) => path,
729 None => {
730 return Err(crate::response::DisplayError::new(
731 "backup does not have an upload path",
732 )
733 .with_status(StatusCode::EXPECTATION_FAILED)
734 .into());
735 }
736 };
737
738 let presigning_config = aws_sdk_s3::presigning::PresigningConfig::expires_in(
739 std::time::Duration::from_mins(15),
740 )?;
741 let presigned = client
742 .get_object()
743 .bucket(bucket)
744 .key(&**file_path)
745 .presigned(presigning_config)
746 .await?;
747
748 return Ok(presigned.uri().to_string());
749 }
750
751 #[derive(Serialize)]
752 struct BackupDownloadJwt {
753 #[serde(flatten)]
754 base: BasePayload,
755
756 backup_uuid: uuid::Uuid,
757 unique_id: uuid::Uuid,
758 }
759
760 let token = node.create_jwt(
761 &state.database,
762 &state.jwt,
763 &BackupDownloadJwt {
764 base: BasePayload {
765 scope: "backup-download".into(),
766 issuer: "panel".into(),
767 subject: None,
768 audience: Vec::new(),
769 expiration_time: Some(chrono::Utc::now().timestamp() + 900),
770 not_before: None,
771 issued_at: Some(chrono::Utc::now().timestamp()),
772 jwt_id: user.uuid.to_compact_string(),
773 },
774 backup_uuid: self.uuid,
775 unique_id: uuid::Uuid::new_v4(),
776 },
777 )?;
778
779 let mut url = node.public_url(state, "/download/backup").await?;
780 url.set_query(Some(&format!(
781 "token={}&archive_format={}",
782 urlencoding::encode(&token),
783 archive_format
784 )));
785
786 Ok(url.to_string())
787 }
788
789 pub async fn restore(
790 self,
791 state: &crate::State,
792 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
793 mut server: super::server::Server,
794 options: ServerBackupRestoreOptions,
795 ) -> Result<(), anyhow::Error> {
796 let backup_configuration = self
797 .backup_configuration
798 .as_ref()
799 .ok_or_else(|| {
800 crate::response::DisplayError::new(
801 "no backup configuration available, unable to restore backup",
802 )
803 .with_status(StatusCode::EXPECTATION_FAILED)
804 })?
805 .fetch_cached(&state.database)
806 .await?;
807
808 if backup_configuration.maintenance_enabled {
809 return Err(crate::response::DisplayError::new(
810 "cannot restore backup while backup configuration is in maintenance mode",
811 )
812 .with_status(StatusCode::EXPECTATION_FAILED)
813 .into());
814 }
815
816 if options.restore_startup {
817 let startup_cmd = self
818 .metadata
819 .get("startup")
820 .and_then(|v| v.as_str())
821 .map(|s| s.to_compact_string());
822 let image_str = self
823 .metadata
824 .get("image")
825 .and_then(|v| v.as_str())
826 .map(|s| s.to_compact_string());
827 let variables = self
828 .metadata
829 .get("variables")
830 .and_then(|v| v.as_object())
831 .cloned()
832 .unwrap_or_default();
833
834 if let Some(startup) = startup_cmd
835 && let Ok(egg_config) = server.egg.configuration(&state.database).await
836 {
837 let is_predefined = server
838 .egg
839 .startup_commands
840 .values()
841 .any(|cmd| cmd == startup.as_str());
842 let custom_allowed = egg_config
843 .config_startup
844 .as_ref()
845 .is_some_and(|c| c.allow_custom_startup_command);
846 if is_predefined || custom_allowed {
847 server
848 .update_with_transaction(
849 state,
850 super::server::UpdateServerOptions {
851 startup: Some(startup),
852 ..Default::default()
853 },
854 transaction,
855 )
856 .await?;
857 }
858 }
859
860 if let Some(image) = image_str {
861 let is_valid_image = server
862 .egg
863 .docker_images
864 .values()
865 .any(|img| img == image.as_str());
866 if is_valid_image {
867 let current_is_custom = !server
868 .egg
869 .docker_images
870 .values()
871 .any(|img| img == server.image.as_str());
872 let allow_overwrite = state
873 .settings
874 .get()
875 .await
876 .map(|s| s.server.allow_overwriting_custom_docker_image)
877 .unwrap_or(false);
878 if !current_is_custom || allow_overwrite {
879 server
880 .update_with_transaction(
881 state,
882 super::server::UpdateServerOptions {
883 image: Some(image),
884 ..Default::default()
885 },
886 transaction,
887 )
888 .await?;
889 }
890 }
891 }
892
893 if !variables.is_empty() {
894 let existing_variables = ServerVariable::all_by_server_uuid_egg_uuid(
895 &state.database,
896 server.uuid,
897 server.egg.uuid,
898 )
899 .await?;
900
901 let mut validator_variables = HashMap::new();
902 for variable in existing_variables.iter() {
903 validator_variables.insert(
904 variable.variable.env_variable.as_str(),
905 (
906 variable.variable.rules.as_slice(),
907 if let Some(value) = variables
908 .iter()
909 .find(|v| v.0 == variable.variable.env_variable)
910 && variable.variable.user_editable
911 && let Some(value) = value.1.as_str()
912 {
913 value
914 } else {
915 variable.value.as_str()
916 },
917 ),
918 );
919 }
920
921 let validator = match rule_validator::Validator::new(validator_variables) {
922 Ok(validator) => validator,
923 Err(error) => {
924 return Err(crate::response::DisplayError::new(error)
925 .with_status(StatusCode::EXPECTATION_FAILED)
926 .into());
927 }
928 };
929 if let Err(error) = validator.validate() {
930 return Err(crate::response::DisplayError::new(error)
931 .with_status(StatusCode::EXPECTATION_FAILED)
932 .into());
933 }
934
935 for (env_var, value) in &variables {
936 let Some(value) = value.as_str() else {
937 continue;
938 };
939 let variable_uuid = match existing_variables
940 .iter()
941 .find(|v| v.variable.env_variable == env_var)
942 {
943 Some(variable) if variable.variable.user_editable => variable.variable.uuid,
944 _ => continue,
945 };
946
947 ServerVariable::create_with_transaction(
948 transaction,
949 server.uuid,
950 variable_uuid,
951 value,
952 )
953 .await?;
954 }
955 }
956 }
957
958 server
959 .node
960 .fetch_cached(&state.database)
961 .await?
962 .api_client(&state.database)
963 .await?
964 .post_servers_server_backup_backup_restore(
965 server.uuid,
966 self.uuid,
967 &wings_api::servers_server_backup_backup_restore::post::RequestBody {
968 adapter: self.disk.to_wings_adapter(),
969 download_url: match self.disk {
970 BackupDisk::S3 => {
971 if let Some(mut s3_configuration) =
972 backup_configuration.backup_configs.s3
973 {
974 s3_configuration.decrypt(&state.database).await?;
975
976 let compression_type = s3_configuration.compression_type;
977 let (client, bucket) = s3_configuration.into_client();
978
979 let file_path = match &self.upload_path {
980 Some(path) => path.as_str(),
981 None => {
982 &Self::s3_path(server.uuid, self.uuid, compression_type)
983 }
984 };
985
986 let presigning_config =
987 aws_sdk_s3::presigning::PresigningConfig::expires_in(
988 std::time::Duration::from_mins(60),
989 )?;
990 let presigned = client
991 .get_object()
992 .bucket(bucket)
993 .key(file_path)
994 .presigned(presigning_config)
995 .await?;
996
997 Some(presigned.uri().to_compact_string())
998 } else {
999 None
1000 }
1001 }
1002 _ => None,
1003 },
1004 truncate_directory: options.truncate_directory,
1005 },
1006 )
1007 .await?;
1008
1009 Self::get_event_emitter().emit(
1010 state.clone(),
1011 ServerBackupEvent::RestoreStarted {
1012 backup: Box::new(self),
1013 server: Box::new(server),
1014 },
1015 );
1016
1017 Ok(())
1018 }
1019
1020 pub async fn delete_oldest_by_server_uuid(
1021 state: &crate::State,
1022 server: &super::server::Server,
1023 ) -> Result<(), anyhow::Error> {
1024 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
1025 r#"
1026 SELECT {}
1027 FROM server_backups
1028 WHERE server_backups.server_uuid = $1
1029 AND server_backups.locked = false
1030 AND server_backups.completed IS NOT NULL
1031 AND server_backups.deleted IS NULL
1032 ORDER BY server_backups.created ASC
1033 LIMIT 1
1034 "#,
1035 Self::columns_sql(None)
1036 )))
1037 .bind(server.uuid)
1038 .fetch_optional(state.database.read())
1039 .await?;
1040
1041 if let Some(row) = row {
1042 let backup = Self::map(None, &row)?;
1043
1044 backup.delete(state, Default::default()).await
1045 } else {
1046 Err(sqlx::Error::RowNotFound.into())
1047 }
1048 }
1049
1050 #[inline]
1051 pub fn default_name() -> compact_str::CompactString {
1052 let now = chrono::Local::now();
1053
1054 now.format("%Y-%m-%d %H:%M:%S %z").to_compact_string()
1055 }
1056
1057 #[inline]
1058 pub fn s3_path(
1059 server_uuid: uuid::Uuid,
1060 backup_uuid: uuid::Uuid,
1061 compression_type: wings_api::CompressionType,
1062 ) -> compact_str::CompactString {
1063 compact_str::format_compact!(
1064 "{server_uuid}/{backup_uuid}.tar{}",
1065 match compression_type {
1066 wings_api::CompressionType::None => "",
1067 wings_api::CompressionType::Gz => ".gz",
1068 wings_api::CompressionType::Xz => ".xz",
1069 wings_api::CompressionType::Lzip => ".lz",
1070 wings_api::CompressionType::Bz2 => ".bz2",
1071 wings_api::CompressionType::Lz4 => ".lz4",
1072 wings_api::CompressionType::Zstd => ".zst",
1073 }
1074 )
1075 }
1076
1077 #[inline]
1078 pub fn s3_content_type(name: &str) -> &'static str {
1079 if name.ends_with("tar") {
1080 "application/x-tar"
1081 } else if name.ends_with(".tar.gz") {
1082 "application/x-gzip"
1083 } else if name.ends_with(".tar.xz") {
1084 "application/x-xz"
1085 } else if name.ends_with(".tar.lz") {
1086 "application/x-lzip"
1087 } else if name.ends_with(".tar.bz2") {
1088 "application/x-bzip2"
1089 } else if name.ends_with(".tar.lz4") {
1090 "application/x-lz4"
1091 } else if name.ends_with(".tar.zst") {
1092 "application/zstd"
1093 } else {
1094 "application/octet-stream"
1095 }
1096 }
1097
1098 pub async fn into_admin_node_api_object(
1099 self,
1100 state: &crate::State,
1101 storage_url_retriever: &StorageUrlRetriever<'_>,
1102 ) -> Result<AdminApiNodeServerBackup, crate::database::DatabaseError> {
1103 Ok(AdminApiNodeServerBackup {
1104 uuid: self.uuid,
1105 server: match self.server {
1106 Some(server) => Some(
1107 server
1108 .fetch_cached(&state.database)
1109 .await?
1110 .into_admin_api_object(state, storage_url_retriever)
1111 .await?,
1112 ),
1113 None => None,
1114 },
1115 node: self
1116 .node
1117 .fetch_cached(&state.database)
1118 .await?
1119 .into_admin_api_object(state, ())
1120 .await?,
1121 name: self.name,
1122 ignored_files: self.ignored_files,
1123 is_successful: self.successful,
1124 is_locked: self.locked,
1125 is_browsable: self.browsable,
1126 is_streaming: self.streaming,
1127 is_shared: self.shared,
1128 checksum: self.checksum,
1129 bytes: self.bytes,
1130 files: self.files,
1131 metadata: self.metadata,
1132 completed: self.completed.map(|dt| dt.and_utc()),
1133 created: self.created.and_utc(),
1134 })
1135 }
1136}
1137
1138#[async_trait::async_trait]
1139impl IntoAdminApiObject for ServerBackup {
1140 type AdminApiObject = AdminApiServerBackup;
1141 type ExtraArgs<'a> = &'a crate::storage::StorageUrlRetriever<'a>;
1142
1143 async fn into_admin_api_object<'a>(
1144 self,
1145 state: &crate::State,
1146 storage_url_retriever: Self::ExtraArgs<'a>,
1147 ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
1148 let api_object = AdminApiServerBackup::init_hooks(&self, state).await?;
1149
1150 let api_object = finish_extendible!(
1151 AdminApiServerBackup {
1152 uuid: self.uuid,
1153 server: match self.server {
1154 Some(server) => Some(
1155 server
1156 .fetch_cached(&state.database)
1157 .await?
1158 .into_admin_api_object(state, storage_url_retriever)
1159 .await?,
1160 ),
1161 None => None,
1162 },
1163 name: self.name,
1164 ignored_files: self.ignored_files,
1165 is_successful: self.successful,
1166 is_locked: self.locked,
1167 is_browsable: self.browsable,
1168 is_streaming: self.streaming,
1169 is_shared: self.shared,
1170 checksum: self.checksum,
1171 bytes: self.bytes,
1172 files: self.files,
1173 metadata: self.metadata,
1174 completed: self.completed.map(|dt| dt.and_utc()),
1175 created: self.created.and_utc(),
1176 },
1177 api_object,
1178 state
1179 )?;
1180
1181 Ok(api_object)
1182 }
1183}
1184
1185#[async_trait::async_trait]
1186impl IntoApiObject for ServerBackup {
1187 type ApiObject = ApiServerBackup;
1188 type ExtraArgs<'a> = ();
1189
1190 async fn into_api_object<'a>(
1191 self,
1192 state: &crate::State,
1193 _args: Self::ExtraArgs<'a>,
1194 ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
1195 let api_object = ApiServerBackup::init_hooks(&self, state).await?;
1196
1197 let api_object = finish_extendible!(
1198 ApiServerBackup {
1199 uuid: self.uuid,
1200 name: self.name,
1201 ignored_files: self.ignored_files,
1202 is_successful: self.successful,
1203 is_locked: self.locked,
1204 is_browsable: self.browsable,
1205 is_streaming: self.streaming,
1206 checksum: self.checksum,
1207 bytes: self.bytes,
1208 files: self.files,
1209 metadata: self.metadata,
1210 completed: self.completed.map(|dt| dt.and_utc()),
1211 created: self.created.and_utc(),
1212 },
1213 api_object,
1214 state
1215 )?;
1216
1217 Ok(api_object)
1218 }
1219}
1220
1221#[derive(Validate)]
1222pub struct CreateServerBackupOptions<'a> {
1223 #[garde(skip)]
1224 pub server: &'a super::server::Server,
1225 #[garde(length(chars, min = 1, max = 255))]
1226 pub name: compact_str::CompactString,
1227 #[garde(skip)]
1228 pub ignored_files: Vec<compact_str::CompactString>,
1229 #[garde(skip)]
1230 pub metadata: serde_json::Value,
1231}
1232
1233#[async_trait::async_trait]
1234impl CreatableModel for ServerBackup {
1235 type CreateOptions<'a> = CreateServerBackupOptions<'a>;
1236 type CreateResult = Self;
1237
1238 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
1239 static CREATE_LISTENERS: LazyLock<CreateListenerList<ServerBackup>> =
1240 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1241
1242 &CREATE_LISTENERS
1243 }
1244
1245 async fn create_with_transaction(
1246 _state: &crate::State,
1247 _options: Self::CreateOptions<'_>,
1248 _transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1249 ) -> Result<Self, crate::database::DatabaseError> {
1250 Err(anyhow::anyhow!("create_with_transaction is not supported for ServerBackup").into())
1251 }
1252
1253 async fn create(
1254 state: &crate::State,
1255 mut options: Self::CreateOptions<'_>,
1256 ) -> Result<Self, crate::database::DatabaseError> {
1257 options.validate()?;
1258
1259 let backup_configuration = options
1260 .server
1261 .backup_configuration(&state.database)
1262 .await
1263 .ok_or_else(|| {
1264 anyhow::Error::new(
1265 crate::response::DisplayError::new(
1266 "no backup configuration available, unable to create backup",
1267 )
1268 .with_status(StatusCode::EXPECTATION_FAILED),
1269 )
1270 })?;
1271
1272 if backup_configuration.maintenance_enabled {
1273 return Err(anyhow::Error::new(
1274 crate::response::DisplayError::new(
1275 "cannot create backup while backup configuration is in maintenance mode",
1276 )
1277 .with_status(StatusCode::EXPECTATION_FAILED),
1278 )
1279 .into());
1280 }
1281
1282 let mut transaction = state.database.write().begin().await?;
1283
1284 let mut query_builder = InsertQueryBuilder::new("server_backups");
1285
1286 Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
1287 .await?;
1288
1289 query_builder
1290 .set("server_uuid", options.server.uuid)
1291 .set("node_uuid", options.server.node.uuid)
1292 .set("backup_configuration_uuid", backup_configuration.uuid)
1293 .set("name", &options.name)
1294 .set("ignored_files", &options.ignored_files)
1295 .set("bytes", 0i64)
1296 .set("disk", backup_configuration.backup_disk)
1297 .set("shared", backup_configuration.shared)
1298 .set("metadata", &options.metadata);
1299
1300 let row = query_builder
1301 .returning(&Self::columns_sql(None))
1302 .fetch_one(&mut *transaction)
1303 .await?;
1304 let mut backup = Self::map(None, &row)?;
1305
1306 Self::run_after_create_handlers(&mut backup, &options, state, &mut transaction).await?;
1307
1308 transaction.commit().await?;
1309
1310 let server = options.server.clone();
1311 let database = Arc::clone(&state.database);
1312 let backup_uuid = backup.uuid;
1313 let backup_disk = backup_configuration.backup_disk;
1314 let ignored_files_str = options
1315 .ignored_files
1316 .iter()
1317 .map(|s| s.as_str())
1318 .collect::<Vec<_>>()
1319 .join("\n");
1320
1321 tokio::spawn(async move {
1322 tracing::debug!(backup = %backup_uuid, "creating server backup");
1323
1324 let node = match server.node.fetch_cached(&database).await {
1325 Ok(node) => node,
1326 Err(err) => {
1327 tracing::error!(backup = %backup_uuid, "failed to create server backup: {:?}", err);
1328
1329 if let Err(err) = sqlx::query!(
1330 "UPDATE server_backups
1331 SET successful = false, completed = NOW()
1332 WHERE server_backups.uuid = $1",
1333 backup_uuid
1334 )
1335 .execute(database.write())
1336 .await
1337 {
1338 tracing::error!(backup = %backup_uuid, "failed to update server backup status: {:?}", err);
1339 }
1340
1341 return;
1342 }
1343 };
1344
1345 let api_client = match node.api_client(&database).await {
1346 Ok(api_client) => api_client,
1347 Err(err) => {
1348 tracing::error!(backup = %backup_uuid, "failed to create server backup: {:?}", err);
1349
1350 if let Err(err) = sqlx::query!(
1351 "UPDATE server_backups
1352 SET successful = false, completed = NOW()
1353 WHERE server_backups.uuid = $1",
1354 backup_uuid
1355 )
1356 .execute(database.write())
1357 .await
1358 {
1359 tracing::error!(backup = %backup_uuid, "failed to update server backup status: {:?}", err);
1360 }
1361
1362 return;
1363 }
1364 };
1365
1366 if let Err(err) = api_client
1367 .post_servers_server_backup(
1368 server.uuid,
1369 &wings_api::servers_server_backup::post::RequestBody {
1370 adapter: backup_disk.to_wings_adapter(),
1371 uuid: backup_uuid,
1372 ignore: ignored_files_str.into(),
1373 },
1374 )
1375 .await
1376 {
1377 tracing::error!(backup = %backup_uuid, "failed to create server backup: {:?}", err);
1378
1379 if let Err(err) = sqlx::query!(
1380 "UPDATE server_backups
1381 SET successful = false, completed = NOW()
1382 WHERE server_backups.uuid = $1",
1383 backup_uuid
1384 )
1385 .execute(database.write())
1386 .await
1387 {
1388 tracing::error!(backup = %backup_uuid, "failed to update server backup status: {:?}", err);
1389 }
1390 }
1391 });
1392
1393 Ok(backup)
1394 }
1395}
1396
1397#[derive(ToSchema, Serialize, Deserialize, Validate, Default)]
1398pub struct UpdateServerBackupOptions {
1399 #[garde(length(chars, min = 1, max = 255))]
1400 #[schema(min_length = 1, max_length = 255)]
1401 pub name: Option<compact_str::CompactString>,
1402 #[garde(skip)]
1403 pub locked: Option<bool>,
1404}
1405
1406#[async_trait::async_trait]
1407impl UpdatableModel for ServerBackup {
1408 type UpdateOptions = UpdateServerBackupOptions;
1409
1410 fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
1411 static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<ServerBackup>> =
1412 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1413
1414 &UPDATE_LISTENERS
1415 }
1416
1417 async fn update_with_transaction(
1418 &mut self,
1419 state: &crate::State,
1420 mut options: Self::UpdateOptions,
1421 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1422 ) -> Result<(), crate::database::DatabaseError> {
1423 options.validate()?;
1424
1425 let mut query_builder = UpdateQueryBuilder::new("server_backups");
1426
1427 self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
1428 .await?;
1429
1430 query_builder
1431 .set("name", options.name.as_ref())
1432 .set("locked", options.locked)
1433 .where_eq("uuid", self.uuid);
1434
1435 query_builder.execute(&mut **transaction).await?;
1436
1437 if let Some(name) = options.name {
1438 self.name = name;
1439 }
1440 if let Some(locked) = options.locked {
1441 self.locked = locked;
1442 }
1443
1444 self.run_after_update_handlers(state, transaction).await?;
1445
1446 Ok(())
1447 }
1448}
1449
1450#[async_trait::async_trait]
1451impl ByUuid for ServerBackup {
1452 async fn by_uuid(
1453 database: &crate::database::Database,
1454 uuid: uuid::Uuid,
1455 ) -> Result<Self, crate::database::DatabaseError> {
1456 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
1457 r#"
1458 SELECT {}
1459 FROM server_backups
1460 WHERE server_backups.uuid = $1
1461 "#,
1462 Self::columns_sql(None)
1463 )))
1464 .bind(uuid)
1465 .fetch_one(database.read())
1466 .await?;
1467
1468 Self::map(None, &row)
1469 }
1470
1471 async fn by_uuid_with_transaction(
1472 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1473 uuid: uuid::Uuid,
1474 ) -> Result<Self, crate::database::DatabaseError> {
1475 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
1476 r#"
1477 SELECT {}
1478 FROM server_backups
1479 WHERE server_backups.uuid = $1
1480 "#,
1481 Self::columns_sql(None)
1482 )))
1483 .bind(uuid)
1484 .fetch_one(&mut **transaction)
1485 .await?;
1486
1487 Self::map(None, &row)
1488 }
1489}
1490
1491#[derive(Clone, Default)]
1492pub struct DeleteServerBackupOptions {
1493 pub force: bool,
1494}
1495
1496#[async_trait::async_trait]
1497impl DeletableModel for ServerBackup {
1498 type DeleteOptions = DeleteServerBackupOptions;
1499
1500 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
1501 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<ServerBackup>> =
1502 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1503
1504 &DELETE_LISTENERS
1505 }
1506
1507 async fn delete_with_transaction(
1508 &self,
1509 _state: &crate::State,
1510 _options: Self::DeleteOptions,
1511 _transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1512 ) -> Result<(), anyhow::Error> {
1513 Err(anyhow::anyhow!(
1514 "delete_with_transaction is not supported for ServerBackup"
1515 ))
1516 }
1517
1518 async fn delete(
1519 &self,
1520 state: &crate::State,
1521 options: Self::DeleteOptions,
1522 ) -> Result<(), anyhow::Error> {
1523 let mut transaction = state.database.write().begin().await?;
1524
1525 self.run_delete_handlers(&options, state, &mut transaction)
1526 .await?;
1527
1528 let node = self.node.fetch_cached(&state.database).await?;
1529
1530 let backup_configuration = match &self.backup_configuration {
1531 Some(backup_configuration) => {
1532 backup_configuration.fetch_cached(&state.database).await?
1533 }
1534 None if options.force => {
1535 let database = Arc::clone(&state.database);
1536 let backup_uuid = self.uuid;
1537 let backup_disk = self.disk;
1538
1539 return tokio::spawn(async move {
1540 if backup_disk != BackupDisk::S3
1541 && let Err(err) = node
1542 .api_client(&database)
1543 .await?
1544 .delete_backups_backup(
1545 backup_uuid,
1546 &wings_api::backups_backup::delete::RequestBody {
1547 adapter: backup_disk.to_wings_adapter(),
1548 },
1549 )
1550 .await
1551 && !matches!(
1552 err,
1553 wings_api::client::ApiHttpError::Http(StatusCode::NOT_FOUND, _)
1554 )
1555 {
1556 tracing::error!(node = %node.uuid, backup = %backup_uuid, "unable to delete backup on node: {:?}", err)
1557 }
1558
1559 sqlx::query(
1560 r#"
1561 UPDATE server_backups
1562 SET deleted = NOW()
1563 WHERE server_backups.uuid = $1
1564 "#,
1565 )
1566 .bind(backup_uuid)
1567 .execute(&mut *transaction)
1568 .await?;
1569
1570 transaction.commit().await?;
1571
1572 Ok(())
1573 })
1574 .await?;
1575 }
1576 None => {
1577 return Err(crate::response::DisplayError::new(
1578 "no backup configuration available, unable to delete backup",
1579 )
1580 .with_status(StatusCode::EXPECTATION_FAILED)
1581 .into());
1582 }
1583 };
1584
1585 if backup_configuration.maintenance_enabled {
1586 return Err(crate::response::DisplayError::new(
1587 "cannot delete backup while backup configuration is in maintenance mode",
1588 )
1589 .with_status(StatusCode::EXPECTATION_FAILED)
1590 .into());
1591 }
1592
1593 let backup = self.clone();
1594 let state = state.clone();
1595
1596 tokio::spawn(async move {
1597 match backup.disk {
1598 BackupDisk::S3 => {
1599 if let Some(mut s3_configuration) = backup_configuration.backup_configs.s3 {
1600 s3_configuration.decrypt(&state.database).await?;
1601
1602 let compression_type = s3_configuration.compression_type;
1603 let (client, bucket) = s3_configuration.into_client();
1604
1605 let file_path = match &backup.upload_path {
1606 Some(path) => path,
1607 None => if let Some(server) = &backup.server {
1608 &Self::s3_path(server.uuid, backup.uuid, compression_type)
1609 } else {
1610 return Err(anyhow::anyhow!("backup upload path not found"))
1611 }
1612 };
1613
1614 if let Err(err) = client.delete_object().bucket(bucket).key(&**file_path).send().await {
1615 if options.force {
1616 tracing::error!(server = ?backup.server.as_ref().map(|s| s.uuid), backup = %backup.uuid, "failed to delete S3 backup, ignoring: {:?}", err);
1617 } else {
1618 return Err(err.into());
1619 }
1620 }
1621 } else if options.force {
1622 tracing::warn!(server = ?backup.server.as_ref().map(|s| s.uuid), backup = %backup.uuid, "S3 backup deletion attempted but no S3 configuration found, ignoring");
1623 } else {
1624 return Err(anyhow::anyhow!("s3 backup deletion attempted but no S3 configuration found"));
1625 }
1626 }
1627 _ => {
1628 if let Err(err) = node
1629 .api_client(&state.database)
1630 .await?
1631 .delete_backups_backup(
1632 backup.uuid,
1633 &wings_api::backups_backup::delete::RequestBody {
1634 adapter: backup.disk.to_wings_adapter(),
1635 },
1636 )
1637 .await
1638 && !matches!(err, wings_api::client::ApiHttpError::Http(StatusCode::NOT_FOUND, _))
1639 {
1640 return Err(err.into());
1641 }
1642 }
1643 }
1644
1645 sqlx::query(
1646 r#"
1647 UPDATE server_backups
1648 SET deleted = NOW()
1649 WHERE server_backups.uuid = $1
1650 "#,
1651 )
1652 .bind(backup.uuid)
1653 .execute(&mut *transaction)
1654 .await?;
1655
1656 backup.run_after_delete_handlers(&options, &state, &mut transaction).await?;
1657
1658 transaction.commit().await?;
1659
1660 Ok(())
1661 }).await?
1662 }
1663}
1664
1665#[derive(ToSchema, Serialize)]
1666#[schema(title = "AdminNodeServerBackup")]
1667pub struct AdminApiNodeServerBackup {
1668 pub uuid: uuid::Uuid,
1669 pub server: Option<super::server::AdminApiServer>,
1670 pub node: super::node::AdminApiNode,
1671
1672 pub name: compact_str::CompactString,
1673 pub ignored_files: Vec<compact_str::CompactString>,
1674
1675 pub is_successful: bool,
1676 pub is_locked: bool,
1677 pub is_browsable: bool,
1678 pub is_streaming: bool,
1679 pub is_shared: bool,
1680
1681 pub checksum: Option<compact_str::CompactString>,
1682 pub bytes: i64,
1683 pub files: i64,
1684
1685 pub metadata: serde_json::Value,
1686
1687 pub completed: Option<chrono::DateTime<chrono::Utc>>,
1688 pub created: chrono::DateTime<chrono::Utc>,
1689}
1690
1691#[schema_extension_derive::extendible]
1692#[init_args(ServerBackup, crate::State)]
1693#[hook_args(crate::State)]
1694#[derive(ToSchema, Serialize)]
1695#[schema(title = "AdminServerBackup")]
1696pub struct AdminApiServerBackup {
1697 pub uuid: uuid::Uuid,
1698 pub server: Option<super::server::AdminApiServer>,
1699
1700 pub name: compact_str::CompactString,
1701 pub ignored_files: Vec<compact_str::CompactString>,
1702
1703 pub is_successful: bool,
1704 pub is_locked: bool,
1705 pub is_browsable: bool,
1706 pub is_streaming: bool,
1707 pub is_shared: bool,
1708
1709 pub checksum: Option<compact_str::CompactString>,
1710 pub bytes: i64,
1711 pub files: i64,
1712
1713 pub metadata: serde_json::Value,
1714
1715 pub completed: Option<chrono::DateTime<chrono::Utc>>,
1716 pub created: chrono::DateTime<chrono::Utc>,
1717}
1718
1719#[schema_extension_derive::extendible]
1720#[init_args(ServerBackup, crate::State)]
1721#[hook_args(crate::State)]
1722#[derive(ToSchema, Serialize)]
1723#[schema(title = "ServerBackup")]
1724pub struct ApiServerBackup {
1725 pub uuid: uuid::Uuid,
1726
1727 pub name: compact_str::CompactString,
1728 pub ignored_files: Vec<compact_str::CompactString>,
1729
1730 pub is_successful: bool,
1731 pub is_locked: bool,
1732 pub is_browsable: bool,
1733 pub is_streaming: bool,
1734
1735 pub checksum: Option<compact_str::CompactString>,
1736 pub bytes: i64,
1737 pub files: i64,
1738
1739 pub metadata: serde_json::Value,
1740
1741 pub completed: Option<chrono::DateTime<chrono::Utc>>,
1742 pub created: chrono::DateTime<chrono::Utc>,
1743}