shared/models/
backup_configuration.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use garde::Validate;
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use sqlx::{Row, postgres::PgRow};
9use std::{
10    collections::BTreeMap,
11    sync::{Arc, LazyLock},
12};
13use utoipa::ToSchema;
14
15#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
16pub struct BackupConfigsS3 {
17    #[garde(length(chars, min = 1, max = 255))]
18    #[schema(min_length = 1, max_length = 255)]
19    pub access_key: compact_str::CompactString,
20    #[garde(length(chars, min = 1, max = 255))]
21    #[schema(min_length = 1, max_length = 255)]
22    pub secret_key: compact_str::CompactString,
23    #[garde(length(chars, min = 1, max = 255))]
24    #[schema(min_length = 1, max_length = 255)]
25    pub bucket: compact_str::CompactString,
26    #[garde(length(chars, min = 1, max = 255))]
27    #[schema(min_length = 1, max_length = 255)]
28    pub region: compact_str::CompactString,
29    #[garde(length(chars, min = 1, max = 255), url)]
30    #[schema(min_length = 1, max_length = 255, format = "uri")]
31    pub endpoint: compact_str::CompactString,
32    #[garde(skip)]
33    pub path_style: bool,
34    #[garde(skip)]
35    pub part_size: u64,
36}
37
38impl BackupConfigsS3 {
39    pub async fn encrypt(
40        &mut self,
41        database: &crate::database::Database,
42    ) -> Result<(), anyhow::Error> {
43        self.secret_key = base32::encode(
44            base32::Alphabet::Z,
45            &database.encrypt(self.secret_key.clone()).await?,
46        )
47        .into();
48
49        Ok(())
50    }
51
52    pub async fn decrypt(
53        &mut self,
54        database: &crate::database::Database,
55    ) -> Result<(), anyhow::Error> {
56        self.secret_key = database
57            .decrypt(base32::decode(base32::Alphabet::Z, &self.secret_key).unwrap())
58            .await?;
59
60        Ok(())
61    }
62
63    pub fn censor(&mut self) {
64        self.secret_key = "".into();
65    }
66
67    pub fn into_client(self) -> Result<Box<s3::Bucket>, s3::error::S3Error> {
68        let mut bucket = s3::Bucket::new(
69            &self.bucket,
70            s3::Region::Custom {
71                region: self.region.into(),
72                endpoint: self.endpoint.into(),
73            },
74            s3::creds::Credentials::new(
75                Some(&self.access_key),
76                Some(&self.secret_key),
77                None,
78                None,
79                None,
80            )
81            .unwrap(),
82        )?;
83
84        if self.path_style {
85            bucket.set_path_style();
86        }
87
88        Ok(bucket)
89    }
90}
91
92#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
93pub struct BackupConfigsRestic {
94    #[garde(length(chars, min = 3, max = 255))]
95    #[schema(min_length = 3, max_length = 255)]
96    pub repository: compact_str::CompactString,
97    #[garde(skip)]
98    pub retry_lock_seconds: u64,
99
100    #[garde(skip)]
101    pub environment: IndexMap<compact_str::CompactString, compact_str::CompactString>,
102}
103
104impl BackupConfigsRestic {
105    pub async fn encrypt(
106        &mut self,
107        database: &crate::database::Database,
108    ) -> Result<(), anyhow::Error> {
109        for value in self.environment.values_mut() {
110            *value =
111                base32::encode(base32::Alphabet::Z, &database.encrypt(value.clone()).await?).into();
112        }
113
114        Ok(())
115    }
116
117    pub async fn decrypt(
118        &mut self,
119        database: &crate::database::Database,
120    ) -> Result<(), anyhow::Error> {
121        for value in self.environment.values_mut() {
122            *value = database
123                .decrypt(base32::decode(base32::Alphabet::Z, value).unwrap())
124                .await?;
125        }
126
127        Ok(())
128    }
129
130    pub fn censor(&mut self) {
131        for (key, value) in self.environment.iter_mut() {
132            if key == "RESTIC_PASSWORD" || key == "AWS_SECRET_ACCESS_KEY" {
133                *value = "".into();
134            }
135        }
136    }
137}
138
139#[derive(ToSchema, Serialize, Deserialize, Default, Validate, Clone)]
140pub struct BackupConfigs {
141    #[garde(dive)]
142    pub s3: Option<BackupConfigsS3>,
143    #[garde(dive)]
144    pub restic: Option<BackupConfigsRestic>,
145}
146
147impl BackupConfigs {
148    pub async fn encrypt(
149        &mut self,
150        database: &crate::database::Database,
151    ) -> Result<(), anyhow::Error> {
152        if let Some(s3) = &mut self.s3 {
153            s3.encrypt(database).await?;
154        }
155        if let Some(restic) = &mut self.restic {
156            restic.encrypt(database).await?;
157        }
158
159        Ok(())
160    }
161
162    pub async fn decrypt(
163        &mut self,
164        database: &crate::database::Database,
165    ) -> Result<(), anyhow::Error> {
166        if let Some(s3) = &mut self.s3 {
167            s3.decrypt(database).await?;
168        }
169        if let Some(restic) = &mut self.restic {
170            restic.decrypt(database).await?;
171        }
172
173        Ok(())
174    }
175
176    pub fn censor(&mut self) {
177        if let Some(s3) = &mut self.s3 {
178            s3.censor();
179        }
180        if let Some(restic) = &mut self.restic {
181            restic.censor();
182        }
183    }
184}
185
186#[derive(Serialize, Deserialize, Clone)]
187pub struct BackupConfiguration {
188    pub uuid: uuid::Uuid,
189
190    pub name: compact_str::CompactString,
191    pub description: Option<compact_str::CompactString>,
192
193    pub maintenance_enabled: bool,
194
195    pub backup_disk: super::server_backup::BackupDisk,
196    pub backup_configs: BackupConfigs,
197
198    pub created: chrono::NaiveDateTime,
199}
200
201impl BaseModel for BackupConfiguration {
202    const NAME: &'static str = "backup_configuration";
203
204    #[inline]
205    fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
206        let prefix = prefix.unwrap_or_default();
207
208        BTreeMap::from([
209            (
210                "backup_configurations.uuid",
211                compact_str::format_compact!("{prefix}uuid"),
212            ),
213            (
214                "backup_configurations.name",
215                compact_str::format_compact!("{prefix}name"),
216            ),
217            (
218                "backup_configurations.description",
219                compact_str::format_compact!("{prefix}description"),
220            ),
221            (
222                "backup_configurations.maintenance_enabled",
223                compact_str::format_compact!("{prefix}maintenance_enabled"),
224            ),
225            (
226                "backup_configurations.backup_disk",
227                compact_str::format_compact!("{prefix}backup_disk"),
228            ),
229            (
230                "backup_configurations.backup_configs",
231                compact_str::format_compact!("{prefix}backup_configs"),
232            ),
233            (
234                "backup_configurations.created",
235                compact_str::format_compact!("{prefix}created"),
236            ),
237        ])
238    }
239
240    #[inline]
241    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
242        let prefix = prefix.unwrap_or_default();
243
244        Ok(Self {
245            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
246            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
247            description: row
248                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
249            maintenance_enabled: row
250                .try_get(compact_str::format_compact!("{prefix}maintenance_enabled").as_str())?,
251            backup_disk: row
252                .try_get(compact_str::format_compact!("{prefix}backup_disk").as_str())?,
253            backup_configs: serde_json::from_value(
254                row.get(compact_str::format_compact!("{prefix}backup_configs").as_str()),
255            )
256            .unwrap_or_default(),
257            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
258        })
259    }
260}
261
262impl BackupConfiguration {
263    pub async fn all_with_pagination(
264        database: &crate::database::Database,
265        page: i64,
266        per_page: i64,
267        search: Option<&str>,
268    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
269        let offset = (page - 1) * per_page;
270
271        let rows = sqlx::query(&format!(
272            r#"
273            SELECT {}, COUNT(*) OVER() AS total_count
274            FROM backup_configurations
275            WHERE $1 IS NULL OR backup_configurations.name ILIKE '%' || $1 || '%'
276            ORDER BY backup_configurations.created
277            LIMIT $2 OFFSET $3
278            "#,
279            Self::columns_sql(None)
280        ))
281        .bind(search)
282        .bind(per_page)
283        .bind(offset)
284        .fetch_all(database.read())
285        .await?;
286
287        Ok(super::Pagination {
288            total: rows
289                .first()
290                .map_or(Ok(0), |row| row.try_get("total_count"))?,
291            per_page,
292            page,
293            data: rows
294                .into_iter()
295                .map(|row| Self::map(None, &row))
296                .try_collect_vec()?,
297        })
298    }
299
300    #[inline]
301    pub async fn into_admin_api_object(
302        mut self,
303        database: &crate::database::Database,
304    ) -> Result<AdminApiBackupConfiguration, crate::database::DatabaseError> {
305        self.backup_configs.decrypt(database).await?;
306
307        Ok(AdminApiBackupConfiguration {
308            uuid: self.uuid,
309            name: self.name,
310            maintenance_enabled: self.maintenance_enabled,
311            description: self.description,
312            backup_disk: self.backup_disk,
313            backup_configs: self.backup_configs,
314            created: self.created.and_utc(),
315        })
316    }
317}
318
319#[async_trait::async_trait]
320impl ByUuid for BackupConfiguration {
321    async fn by_uuid(
322        database: &crate::database::Database,
323        uuid: uuid::Uuid,
324    ) -> Result<Self, crate::database::DatabaseError> {
325        let row = sqlx::query(&format!(
326            r#"
327            SELECT {}
328            FROM backup_configurations
329            WHERE backup_configurations.uuid = $1
330            "#,
331            Self::columns_sql(None)
332        ))
333        .bind(uuid)
334        .fetch_one(database.read())
335        .await?;
336
337        Self::map(None, &row)
338    }
339}
340
341#[derive(ToSchema, Deserialize, Validate)]
342pub struct CreateBackupConfigurationOptions {
343    #[garde(length(chars, min = 3, max = 255))]
344    #[schema(min_length = 3, max_length = 255)]
345    pub name: compact_str::CompactString,
346    #[garde(length(chars, min = 1, max = 1024))]
347    #[schema(min_length = 1, max_length = 1024)]
348    pub description: Option<compact_str::CompactString>,
349    #[garde(skip)]
350    pub maintenance_enabled: bool,
351    #[garde(skip)]
352    pub backup_disk: super::server_backup::BackupDisk,
353    #[garde(dive)]
354    pub backup_configs: BackupConfigs,
355}
356
357#[async_trait::async_trait]
358impl CreatableModel for BackupConfiguration {
359    type CreateOptions<'a> = CreateBackupConfigurationOptions;
360    type CreateResult = Self;
361
362    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
363        static CREATE_LISTENERS: LazyLock<CreateListenerList<BackupConfiguration>> =
364            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
365
366        &CREATE_LISTENERS
367    }
368
369    async fn create(
370        state: &crate::State,
371        mut options: Self::CreateOptions<'_>,
372    ) -> Result<Self, crate::database::DatabaseError> {
373        options.validate()?;
374
375        let mut transaction = state.database.write().begin().await?;
376
377        let mut query_builder = InsertQueryBuilder::new("backup_configurations");
378
379        Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
380            .await?;
381
382        options.backup_configs.encrypt(&state.database).await?;
383
384        query_builder
385            .set("name", &options.name)
386            .set("description", &options.description)
387            .set("maintenance_enabled", options.maintenance_enabled)
388            .set("backup_disk", options.backup_disk)
389            .set(
390                "backup_configs",
391                serde_json::to_value(&options.backup_configs)?,
392            );
393
394        let row = query_builder
395            .returning(&Self::columns_sql(None))
396            .fetch_one(&mut *transaction)
397            .await?;
398        let backup_configuration = Self::map(None, &row)?;
399
400        transaction.commit().await?;
401
402        Ok(backup_configuration)
403    }
404}
405
406#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
407pub struct UpdateBackupConfigurationOptions {
408    #[garde(length(chars, min = 3, max = 255))]
409    #[schema(min_length = 3, max_length = 255)]
410    pub name: Option<compact_str::CompactString>,
411    #[garde(length(chars, min = 1, max = 1024))]
412    #[schema(min_length = 1, max_length = 1024)]
413    #[serde(
414        default,
415        skip_serializing_if = "Option::is_none",
416        with = "::serde_with::rust::double_option"
417    )]
418    pub description: Option<Option<compact_str::CompactString>>,
419    #[garde(skip)]
420    pub maintenance_enabled: Option<bool>,
421    #[garde(skip)]
422    pub backup_disk: Option<super::server_backup::BackupDisk>,
423    #[garde(dive)]
424    pub backup_configs: Option<BackupConfigs>,
425}
426
427#[async_trait::async_trait]
428impl UpdatableModel for BackupConfiguration {
429    type UpdateOptions = UpdateBackupConfigurationOptions;
430
431    fn get_update_handlers() -> &'static LazyLock<UpdateListenerList<Self>> {
432        static UPDATE_LISTENERS: LazyLock<UpdateListenerList<BackupConfiguration>> =
433            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
434
435        &UPDATE_LISTENERS
436    }
437
438    async fn update(
439        &mut self,
440        state: &crate::State,
441        mut options: Self::UpdateOptions,
442    ) -> Result<(), crate::database::DatabaseError> {
443        options.validate()?;
444
445        let mut transaction = state.database.write().begin().await?;
446
447        let mut query_builder = UpdateQueryBuilder::new("backup_configurations");
448
449        Self::run_update_handlers(
450            self,
451            &mut options,
452            &mut query_builder,
453            state,
454            &mut transaction,
455        )
456        .await?;
457
458        query_builder
459            .set("name", options.name.as_ref())
460            .set(
461                "description",
462                options.description.as_ref().map(|d| d.as_ref()),
463            )
464            .set("maintenance_enabled", options.maintenance_enabled)
465            .set("backup_disk", options.backup_disk)
466            .set(
467                "backup_configs",
468                if let Some(backup_configs) = &mut options.backup_configs {
469                    backup_configs.encrypt(&state.database).await?;
470
471                    Some(serde_json::to_value(backup_configs)?)
472                } else {
473                    None
474                },
475            )
476            .where_eq("uuid", self.uuid);
477
478        query_builder.execute(&mut *transaction).await?;
479
480        if let Some(name) = options.name {
481            self.name = name;
482        }
483        if let Some(description) = options.description {
484            self.description = description;
485        }
486        if let Some(maintenance_enabled) = options.maintenance_enabled {
487            self.maintenance_enabled = maintenance_enabled;
488        }
489        if let Some(backup_disk) = options.backup_disk {
490            self.backup_disk = backup_disk;
491        }
492        if let Some(backup_configs) = options.backup_configs {
493            self.backup_configs = backup_configs;
494        }
495
496        transaction.commit().await?;
497
498        Ok(())
499    }
500}
501
502#[async_trait::async_trait]
503impl DeletableModel for BackupConfiguration {
504    type DeleteOptions = ();
505
506    fn get_delete_handlers() -> &'static LazyLock<DeleteListenerList<Self>> {
507        static DELETE_LISTENERS: LazyLock<DeleteListenerList<BackupConfiguration>> =
508            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
509
510        &DELETE_LISTENERS
511    }
512
513    async fn delete(
514        &self,
515        state: &crate::State,
516        options: Self::DeleteOptions,
517    ) -> Result<(), anyhow::Error> {
518        let mut transaction = state.database.write().begin().await?;
519
520        self.run_delete_handlers(&options, state, &mut transaction)
521            .await?;
522
523        sqlx::query(
524            r#"
525            DELETE FROM backup_configurations
526            WHERE backup_configurations.uuid = $1
527            "#,
528        )
529        .bind(self.uuid)
530        .execute(&mut *transaction)
531        .await?;
532
533        transaction.commit().await?;
534
535        Ok(())
536    }
537}
538
539#[derive(ToSchema, Serialize)]
540#[schema(title = "BackupConfiguration")]
541pub struct AdminApiBackupConfiguration {
542    pub uuid: uuid::Uuid,
543
544    pub name: compact_str::CompactString,
545    pub maintenance_enabled: bool,
546    pub description: Option<compact_str::CompactString>,
547
548    pub backup_disk: super::server_backup::BackupDisk,
549    pub backup_configs: BackupConfigs,
550
551    pub created: chrono::DateTime<chrono::Utc>,
552}