shared/models/
server_backup.rs

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