Skip to main content

shared/models/server_backup/
mod.rs

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}