Skip to main content

shared/models/
backup_configuration.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use aws_sdk_s3::{
6    Client as S3Client,
7    config::{
8        BehaviorVersion, Config as S3Config, Credentials, Region, retry::RetryConfig,
9        timeout::TimeoutConfig,
10    },
11};
12use garde::Validate;
13use indexmap::IndexMap;
14use serde::{Deserialize, Serialize};
15use sqlx::{Row, postgres::PgRow};
16use std::{
17    collections::BTreeMap,
18    sync::{Arc, LazyLock},
19};
20use utoipa::ToSchema;
21
22fn default_compression_type() -> wings_api::CompressionType {
23    wings_api::CompressionType::Gz
24}
25
26#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
27pub struct BackupConfigsS3 {
28    #[garde(length(chars, min = 1, max = 255))]
29    #[schema(min_length = 1, max_length = 255)]
30    pub access_key: compact_str::CompactString,
31    #[garde(length(chars, min = 1, max = 255))]
32    #[schema(min_length = 1, max_length = 255)]
33    pub secret_key: compact_str::CompactString,
34    #[garde(length(chars, min = 1, max = 255))]
35    #[schema(min_length = 1, max_length = 255)]
36    pub bucket: compact_str::CompactString,
37    #[garde(length(chars, min = 1, max = 255))]
38    #[schema(min_length = 1, max_length = 255)]
39    pub region: compact_str::CompactString,
40    #[garde(length(chars, min = 1, max = 255), url)]
41    #[schema(min_length = 1, max_length = 255, format = "uri")]
42    pub endpoint: compact_str::CompactString,
43    #[garde(skip)]
44    pub path_style: bool,
45    #[garde(skip)]
46    #[serde(default = "default_compression_type")]
47    pub compression_type: wings_api::CompressionType,
48    #[garde(skip)]
49    pub part_size: u64,
50}
51
52impl BackupConfigsS3 {
53    pub async fn encrypt(
54        &mut self,
55        database: &crate::database::Database,
56    ) -> Result<(), anyhow::Error> {
57        self.secret_key = base32::encode(
58            base32::Alphabet::Z,
59            &database.encrypt(self.secret_key.clone()).await?,
60        )
61        .into();
62
63        Ok(())
64    }
65
66    pub async fn decrypt(
67        &mut self,
68        database: &crate::database::Database,
69    ) -> Result<(), anyhow::Error> {
70        if let Some(decoded) = base32::decode(base32::Alphabet::Z, &self.secret_key) {
71            self.secret_key = database.decrypt(decoded).await?;
72        }
73
74        Ok(())
75    }
76
77    pub fn censor(&mut self) {
78        self.secret_key = "".into();
79    }
80
81    pub fn into_client(self) -> (S3Client, compact_str::CompactString) {
82        let credentials = Credentials::new(
83            self.access_key,
84            self.secret_key,
85            None,
86            None,
87            "calagopus-static",
88        );
89
90        let timeout_config = TimeoutConfig::builder()
91            .connect_timeout(std::time::Duration::from_secs(10))
92            .build();
93
94        let config = S3Config::builder()
95            .behavior_version(BehaviorVersion::latest())
96            .credentials_provider(credentials)
97            .region(Region::new(self.region.to_string()))
98            .endpoint_url(self.endpoint)
99            .force_path_style(self.path_style)
100            .timeout_config(timeout_config)
101            .retry_config(RetryConfig::standard())
102            .build();
103
104        (S3Client::from_conf(config), self.bucket)
105    }
106}
107
108#[derive(ToSchema, Serialize, Deserialize, Clone)]
109pub struct BackupConfigsResticPruneJob {
110    #[schema(value_type = String, example = "0 0 0 * * *")]
111    pub cron: cron::Schedule,
112    pub nodes: Vec<uuid::Uuid>,
113}
114
115#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
116pub struct BackupConfigsRestic {
117    #[garde(length(chars, min = 3, max = 255))]
118    #[schema(min_length = 3, max_length = 255)]
119    pub repository: compact_str::CompactString,
120    #[garde(skip)]
121    pub retry_lock_seconds: u64,
122
123    #[garde(skip)]
124    pub environment: IndexMap<compact_str::CompactString, compact_str::CompactString>,
125    #[garde(length(max = 50))]
126    #[schema(inline, max_items = 50)]
127    #[serde(default)]
128    pub prune_jobs: Vec<BackupConfigsResticPruneJob>,
129}
130
131impl BackupConfigsRestic {
132    pub async fn encrypt(
133        &mut self,
134        database: &crate::database::Database,
135    ) -> Result<(), anyhow::Error> {
136        for value in self.environment.values_mut() {
137            *value =
138                base32::encode(base32::Alphabet::Z, &database.encrypt(value.clone()).await?).into();
139        }
140
141        Ok(())
142    }
143
144    pub async fn decrypt(
145        &mut self,
146        database: &crate::database::Database,
147    ) -> Result<(), anyhow::Error> {
148        for value in self.environment.values_mut() {
149            if let Some(decoded) = base32::decode(base32::Alphabet::Z, value) {
150                *value = database.decrypt(decoded).await?;
151            }
152        }
153
154        Ok(())
155    }
156
157    pub fn censor(&mut self) {
158        for (key, value) in self.environment.iter_mut() {
159            if key == "RESTIC_PASSWORD" || key == "AWS_SECRET_ACCESS_KEY" {
160                *value = "".into();
161            }
162        }
163    }
164
165    pub fn into_wings_configuration(self) -> wings_api::ResticBackupConfiguration {
166        wings_api::ResticBackupConfiguration {
167            repository: self.repository,
168            password_file: None,
169            retry_lock_seconds: self.retry_lock_seconds,
170            environment: self.environment,
171        }
172    }
173}
174
175fn validate_fingerprint(
176    fingerprint: &compact_str::CompactString,
177    _context: &(),
178) -> Result<(), garde::Error> {
179    let normalized = normalize_pbs_fingerprint(fingerprint);
180
181    if normalized.len() != 64 || !normalized.bytes().all(|b| b.is_ascii_hexdigit()) {
182        return Err(garde::Error::new(
183            "fingerprint must be a SHA-256 hash (64 hex characters, colons optional)",
184        ));
185    }
186
187    Ok(())
188}
189
190pub fn normalize_pbs_fingerprint(fingerprint: &str) -> compact_str::CompactString {
191    fingerprint
192        .chars()
193        .filter(|c| !c.is_whitespace() && *c != ':')
194        .map(|c| c.to_ascii_lowercase())
195        .collect()
196}
197
198fn validate_pbs_token_id(
199    token_id: &compact_str::CompactString,
200    _context: &(),
201) -> Result<(), garde::Error> {
202    static TOKEN_ID_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
203        regex::Regex::new(r"^[^\s:/!@]+@[A-Za-z][A-Za-z0-9._-]*![A-Za-z0-9._-]+$").unwrap()
204    });
205
206    if !TOKEN_ID_REGEX.is_match(token_id) {
207        return Err(garde::Error::new(
208            "token id must be in the form user@realm!token-name",
209        ));
210    }
211
212    Ok(())
213}
214
215#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
216pub struct BackupConfigsPbs {
217    #[garde(length(chars, min = 1, max = 255), url)]
218    #[schema(min_length = 1, max_length = 255, format = "uri")]
219    pub url: compact_str::CompactString,
220    #[garde(length(chars, min = 1, max = 255))]
221    #[schema(min_length = 1, max_length = 255)]
222    pub datastore: compact_str::CompactString,
223    #[garde(inner(length(chars, min = 1, max = 255)))]
224    #[schema(min_length = 1, max_length = 255)]
225    pub namespace: Option<compact_str::CompactString>,
226    #[garde(length(chars, min = 1, max = 255), custom(validate_pbs_token_id))]
227    #[schema(min_length = 1, max_length = 255)]
228    pub token_id: compact_str::CompactString,
229    #[garde(length(chars, min = 1, max = 255))]
230    #[schema(min_length = 1, max_length = 255)]
231    pub token_secret: compact_str::CompactString,
232    #[garde(custom(validate_fingerprint))]
233    #[schema(min_length = 64, max_length = 95)]
234    pub fingerprint: compact_str::CompactString,
235    #[garde(inner(length(chars, min = 1, max = 255)))]
236    #[schema(min_length = 1, max_length = 255)]
237    pub backup_id_prefix: Option<compact_str::CompactString>,
238}
239
240impl BackupConfigsPbs {
241    pub async fn encrypt(
242        &mut self,
243        database: &crate::database::Database,
244    ) -> Result<(), anyhow::Error> {
245        self.token_secret = base32::encode(
246            base32::Alphabet::Z,
247            &database.encrypt(self.token_secret.clone()).await?,
248        )
249        .into();
250
251        Ok(())
252    }
253
254    pub async fn decrypt(
255        &mut self,
256        database: &crate::database::Database,
257    ) -> Result<(), anyhow::Error> {
258        if let Some(decoded) = base32::decode(base32::Alphabet::Z, &self.token_secret) {
259            self.token_secret = database.decrypt(decoded).await?;
260        }
261
262        Ok(())
263    }
264
265    pub fn censor(&mut self) {
266        self.token_secret = "".into();
267    }
268}
269
270fn validate_kopia_username(
271    username: &compact_str::CompactString,
272    _context: &(),
273) -> Result<(), garde::Error> {
274    static KOPIA_USERNAME_REGEX: std::sync::LazyLock<regex::Regex> =
275        std::sync::LazyLock::new(|| {
276            regex::Regex::new(r"^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9._-]*$").unwrap()
277        });
278
279    if !KOPIA_USERNAME_REGEX.is_match(username) {
280        return Err(garde::Error::new("username must be in the form user@host"));
281    }
282
283    Ok(())
284}
285
286fn validate_kopia_tags(
287    tags: &IndexMap<compact_str::CompactString, compact_str::CompactString>,
288    _context: &(),
289) -> Result<(), garde::Error> {
290    if tags.len() > 50 {
291        return Err(garde::Error::new("cannot have more than 50 tags"));
292    }
293
294    for (key, value) in tags.iter() {
295        if key.is_empty() || key.len() > 255 {
296            return Err(garde::Error::new(
297                "tag keys must be between 1 and 255 characters",
298            ));
299        }
300        if value.is_empty() || value.len() > 255 {
301            return Err(garde::Error::new(
302                "tag values must be between 1 and 255 characters",
303            ));
304        }
305    }
306
307    Ok(())
308}
309
310#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
311pub struct BackupConfigKopia {
312    #[garde(length(chars, min = 1, max = 255), url)]
313    #[schema(min_length = 1, max_length = 255, format = "uri")]
314    pub url: compact_str::CompactString,
315    #[garde(length(chars, min = 1, max = 255), custom(validate_kopia_username))]
316    #[schema(min_length = 1, max_length = 255)]
317    pub username: compact_str::CompactString,
318    #[garde(length(chars, min = 1, max = 255))]
319    #[schema(min_length = 1, max_length = 255)]
320    pub password: compact_str::CompactString,
321    #[garde(custom(validate_fingerprint))]
322    #[schema(min_length = 64, max_length = 95)]
323    pub fingerprint: compact_str::CompactString,
324    #[garde(custom(validate_kopia_tags))]
325    pub tags: IndexMap<compact_str::CompactString, compact_str::CompactString>,
326}
327
328impl BackupConfigKopia {
329    pub async fn encrypt(
330        &mut self,
331        database: &crate::database::Database,
332    ) -> Result<(), anyhow::Error> {
333        self.password = base32::encode(
334            base32::Alphabet::Z,
335            &database.encrypt(self.password.clone()).await?,
336        )
337        .into();
338
339        Ok(())
340    }
341
342    pub async fn decrypt(
343        &mut self,
344        database: &crate::database::Database,
345    ) -> Result<(), anyhow::Error> {
346        if let Some(decoded) = base32::decode(base32::Alphabet::Z, &self.password) {
347            self.password = database.decrypt(decoded).await?;
348        }
349
350        Ok(())
351    }
352
353    pub fn censor(&mut self) {
354        self.password = "".into();
355    }
356}
357
358#[derive(ToSchema, Serialize, Deserialize, Default, Validate, Clone)]
359pub struct BackupConfigs {
360    #[garde(dive)]
361    pub s3: Option<BackupConfigsS3>,
362    #[garde(dive)]
363    pub restic: Option<BackupConfigsRestic>,
364    #[garde(dive)]
365    pub pbs: Option<BackupConfigsPbs>,
366    #[garde(dive)]
367    pub kopia: Option<BackupConfigKopia>,
368}
369
370impl BackupConfigs {
371    pub async fn encrypt(
372        &mut self,
373        database: &crate::database::Database,
374    ) -> Result<(), anyhow::Error> {
375        if let Some(s3) = &mut self.s3 {
376            s3.encrypt(database).await?;
377        }
378        if let Some(restic) = &mut self.restic {
379            restic.encrypt(database).await?;
380        }
381        if let Some(pbs) = &mut self.pbs {
382            pbs.encrypt(database).await?;
383        }
384        if let Some(kopia) = &mut self.kopia {
385            kopia.encrypt(database).await?;
386        }
387
388        Ok(())
389    }
390
391    pub async fn decrypt(
392        &mut self,
393        database: &crate::database::Database,
394    ) -> Result<(), anyhow::Error> {
395        if let Some(s3) = &mut self.s3 {
396            s3.decrypt(database).await?;
397        }
398        if let Some(restic) = &mut self.restic {
399            restic.decrypt(database).await?;
400        }
401        if let Some(pbs) = &mut self.pbs {
402            pbs.decrypt(database).await?;
403        }
404        if let Some(kopia) = &mut self.kopia {
405            kopia.decrypt(database).await?;
406        }
407
408        Ok(())
409    }
410
411    pub fn censor(&mut self) {
412        if let Some(s3) = &mut self.s3 {
413            s3.censor();
414        }
415        if let Some(restic) = &mut self.restic {
416            restic.censor();
417        }
418        if let Some(pbs) = &mut self.pbs {
419            pbs.censor();
420        }
421        if let Some(kopia) = &mut self.kopia {
422            kopia.censor();
423        }
424    }
425}
426
427#[derive(Serialize, Deserialize, Clone)]
428pub struct BackupConfiguration {
429    pub uuid: uuid::Uuid,
430
431    pub name: compact_str::CompactString,
432    pub description: Option<compact_str::CompactString>,
433
434    pub maintenance_enabled: bool,
435    pub shared: bool,
436
437    pub backup_disk: super::server_backup::BackupDisk,
438    pub backup_configs: BackupConfigs,
439
440    pub created: chrono::NaiveDateTime,
441
442    extension_data: super::ModelExtensionData,
443}
444
445impl BaseModel for BackupConfiguration {
446    const NAME: &'static str = "backup_configuration";
447
448    fn get_extension_list() -> &'static super::ModelExtensionList {
449        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
450            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
451
452        &EXTENSIONS
453    }
454
455    fn get_extension_data(&self) -> &super::ModelExtensionData {
456        &self.extension_data
457    }
458
459    #[inline]
460    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
461        let prefix = prefix.unwrap_or_default();
462
463        BTreeMap::from([
464            (
465                "backup_configurations.uuid",
466                compact_str::format_compact!("{prefix}uuid"),
467            ),
468            (
469                "backup_configurations.name",
470                compact_str::format_compact!("{prefix}name"),
471            ),
472            (
473                "backup_configurations.description",
474                compact_str::format_compact!("{prefix}description"),
475            ),
476            (
477                "backup_configurations.maintenance_enabled",
478                compact_str::format_compact!("{prefix}maintenance_enabled"),
479            ),
480            (
481                "backup_configurations.shared",
482                compact_str::format_compact!("{prefix}shared"),
483            ),
484            (
485                "backup_configurations.backup_disk",
486                compact_str::format_compact!("{prefix}backup_disk"),
487            ),
488            (
489                "backup_configurations.backup_configs",
490                compact_str::format_compact!("{prefix}backup_configs"),
491            ),
492            (
493                "backup_configurations.created",
494                compact_str::format_compact!("{prefix}created"),
495            ),
496        ])
497    }
498
499    #[inline]
500    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
501        let prefix = prefix.unwrap_or_default();
502
503        Ok(Self {
504            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
505            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
506            description: row
507                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
508            maintenance_enabled: row
509                .try_get(compact_str::format_compact!("{prefix}maintenance_enabled").as_str())?,
510            shared: row.try_get(compact_str::format_compact!("{prefix}shared").as_str())?,
511            backup_disk: row
512                .try_get(compact_str::format_compact!("{prefix}backup_disk").as_str())?,
513            backup_configs: serde_json::from_value(
514                row.get(compact_str::format_compact!("{prefix}backup_configs").as_str()),
515            )
516            .unwrap_or_default(),
517            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
518            extension_data: Self::map_extensions(prefix, row)?,
519        })
520    }
521}
522
523impl BackupConfiguration {
524    pub async fn all_with_pagination(
525        database: &crate::database::Database,
526        page: i64,
527        per_page: i64,
528        search: Option<&str>,
529    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
530        let offset = (page - 1) * per_page;
531
532        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
533            r#"
534            SELECT {}, COUNT(*) OVER() AS total_count
535            FROM backup_configurations
536            WHERE $1 IS NULL OR backup_configurations.name ILIKE '%' || $1 || '%'
537            ORDER BY backup_configurations.created
538            LIMIT $2 OFFSET $3
539            "#,
540            Self::columns_sql(None)
541        )))
542        .bind(search)
543        .bind(per_page)
544        .bind(offset)
545        .fetch_all(database.read())
546        .await?;
547
548        Ok(super::Pagination {
549            total: rows
550                .first()
551                .map_or(Ok(0), |row| row.try_get("total_count"))?,
552            per_page,
553            page,
554            data: rows
555                .into_iter()
556                .map(|row| Self::map(None, &row))
557                .try_collect_vec()?,
558        })
559    }
560
561    pub async fn cleanup_uuid_arrays(
562        database: &crate::database::Database,
563    ) -> Result<u64, crate::database::DatabaseError> {
564        let result = sqlx::query(
565            "UPDATE backup_configurations
566            SET backup_configs = jsonb_set(
567                backup_configs,
568                '{restic,prune_jobs}',
569                (
570                    SELECT COALESCE(jsonb_agg(
571                        jsonb_set(
572                            job,
573                            '{nodes}',
574                            COALESCE(
575                                (
576                                    SELECT jsonb_agg(node)
577                                    FROM jsonb_array_elements_text(job->'nodes') AS node
578                                    WHERE EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
579                                ),
580                                '[]'::jsonb
581                            )
582                        )
583                    ), '[]'::jsonb)
584                    FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job
585                )
586            )
587            WHERE jsonb_typeof(backup_configs->'restic'->'prune_jobs') = 'array'
588            AND EXISTS (
589                SELECT 1
590                FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job,
591                     jsonb_array_elements_text(job->'nodes') AS node
592                WHERE NOT EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
593            )",
594        )
595        .execute(database.write())
596        .await?;
597
598        Ok(result.rows_affected())
599    }
600}
601
602#[async_trait::async_trait]
603impl IntoAdminApiObject for BackupConfiguration {
604    type AdminApiObject = AdminApiBackupConfiguration;
605    type ExtraArgs<'a> = ();
606
607    async fn into_admin_api_object<'a>(
608        mut self,
609        state: &crate::State,
610        _args: Self::ExtraArgs<'a>,
611    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
612        let api_object = AdminApiBackupConfiguration::init_hooks(&self, state).await?;
613
614        self.backup_configs.decrypt(&state.database).await?;
615
616        let api_object = finish_extendible!(
617            AdminApiBackupConfiguration {
618                uuid: self.uuid,
619                name: self.name,
620                description: self.description,
621                maintenance_enabled: self.maintenance_enabled,
622                shared: self.shared,
623                backup_disk: self.backup_disk,
624                backup_configs: self.backup_configs,
625                created: self.created.and_utc(),
626            },
627            api_object,
628            state
629        )?;
630
631        Ok(api_object)
632    }
633}
634
635#[async_trait::async_trait]
636impl ByUuid for BackupConfiguration {
637    async fn by_uuid(
638        database: &crate::database::Database,
639        uuid: uuid::Uuid,
640    ) -> Result<Self, crate::database::DatabaseError> {
641        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
642            r#"
643            SELECT {}
644            FROM backup_configurations
645            WHERE backup_configurations.uuid = $1
646            "#,
647            Self::columns_sql(None)
648        )))
649        .bind(uuid)
650        .fetch_one(database.read())
651        .await?;
652
653        Self::map(None, &row)
654    }
655
656    async fn by_uuid_with_transaction(
657        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
658        uuid: uuid::Uuid,
659    ) -> Result<Self, crate::database::DatabaseError> {
660        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
661            r#"
662            SELECT {}
663            FROM backup_configurations
664            WHERE backup_configurations.uuid = $1
665            "#,
666            Self::columns_sql(None)
667        )))
668        .bind(uuid)
669        .fetch_one(&mut **transaction)
670        .await?;
671
672        Self::map(None, &row)
673    }
674}
675
676#[derive(ToSchema, Deserialize, Validate)]
677pub struct CreateBackupConfigurationOptions {
678    #[garde(length(chars, min = 1, max = 255))]
679    #[schema(min_length = 1, max_length = 255)]
680    pub name: compact_str::CompactString,
681    #[garde(length(chars, min = 1, max = 1024))]
682    #[schema(min_length = 1, max_length = 1024)]
683    pub description: Option<compact_str::CompactString>,
684    #[garde(skip)]
685    pub maintenance_enabled: bool,
686    #[garde(skip)]
687    pub shared: bool,
688    #[garde(skip)]
689    pub backup_disk: super::server_backup::BackupDisk,
690    #[garde(dive)]
691    pub backup_configs: BackupConfigs,
692}
693
694#[async_trait::async_trait]
695impl CreatableModel for BackupConfiguration {
696    type CreateOptions<'a> = CreateBackupConfigurationOptions;
697    type CreateResult = Self;
698
699    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
700        static CREATE_LISTENERS: LazyLock<CreateListenerList<BackupConfiguration>> =
701            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
702
703        &CREATE_LISTENERS
704    }
705
706    async fn create_with_transaction(
707        state: &crate::State,
708        mut options: Self::CreateOptions<'_>,
709        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
710    ) -> Result<Self, crate::database::DatabaseError> {
711        options.validate()?;
712
713        let mut query_builder = InsertQueryBuilder::new("backup_configurations");
714
715        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
716
717        options.backup_configs.encrypt(&state.database).await?;
718
719        query_builder
720            .set("name", &options.name)
721            .set("description", &options.description)
722            .set("maintenance_enabled", options.maintenance_enabled)
723            .set("shared", options.shared)
724            .set("backup_disk", options.backup_disk)
725            .set(
726                "backup_configs",
727                serde_json::to_value(&options.backup_configs)?,
728            );
729
730        let row = query_builder
731            .returning(&Self::columns_sql(None))
732            .fetch_one(&mut **transaction)
733            .await?;
734        let mut backup_configuration = Self::map(None, &row)?;
735
736        Self::run_after_create_handlers(&mut backup_configuration, &options, state, transaction)
737            .await?;
738
739        Ok(backup_configuration)
740    }
741}
742
743#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
744pub struct UpdateBackupConfigurationOptions {
745    #[garde(length(chars, min = 1, max = 255))]
746    #[schema(min_length = 1, max_length = 255)]
747    pub name: Option<compact_str::CompactString>,
748    #[garde(length(chars, min = 1, max = 1024))]
749    #[schema(min_length = 1, max_length = 1024)]
750    #[serde(
751        default,
752        skip_serializing_if = "Option::is_none",
753        with = "::serde_with::rust::double_option"
754    )]
755    pub description: Option<Option<compact_str::CompactString>>,
756    #[garde(skip)]
757    pub maintenance_enabled: Option<bool>,
758    #[garde(skip)]
759    pub shared: Option<bool>,
760    #[garde(skip)]
761    pub backup_disk: Option<super::server_backup::BackupDisk>,
762    #[garde(dive)]
763    pub backup_configs: Option<BackupConfigs>,
764}
765
766#[async_trait::async_trait]
767impl UpdatableModel for BackupConfiguration {
768    type UpdateOptions = UpdateBackupConfigurationOptions;
769
770    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
771        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<BackupConfiguration>> =
772            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
773
774        &UPDATE_LISTENERS
775    }
776
777    async fn update_with_transaction(
778        &mut self,
779        state: &crate::State,
780        mut options: Self::UpdateOptions,
781        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
782    ) -> Result<(), crate::database::DatabaseError> {
783        options.validate()?;
784
785        let mut query_builder = UpdateQueryBuilder::new("backup_configurations");
786
787        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
788            .await?;
789
790        query_builder
791            .set("name", options.name.as_ref())
792            .set(
793                "description",
794                options.description.as_ref().map(|d| d.as_ref()),
795            )
796            .set("maintenance_enabled", options.maintenance_enabled)
797            .set("shared", options.shared)
798            .set("backup_disk", options.backup_disk)
799            .set(
800                "backup_configs",
801                if let Some(backup_configs) = &mut options.backup_configs {
802                    backup_configs.encrypt(&state.database).await?;
803
804                    Some(serde_json::to_value(backup_configs)?)
805                } else {
806                    None
807                },
808            )
809            .where_eq("uuid", self.uuid);
810
811        query_builder.execute(&mut **transaction).await?;
812
813        if let Some(name) = options.name {
814            self.name = name;
815        }
816        if let Some(description) = options.description {
817            self.description = description;
818        }
819        if let Some(maintenance_enabled) = options.maintenance_enabled {
820            self.maintenance_enabled = maintenance_enabled;
821        }
822        if let Some(shared) = options.shared {
823            self.shared = shared;
824        }
825        if let Some(backup_disk) = options.backup_disk {
826            self.backup_disk = backup_disk;
827        }
828        if let Some(backup_configs) = options.backup_configs {
829            self.backup_configs = backup_configs;
830        }
831
832        self.run_after_update_handlers(state, transaction).await?;
833
834        Ok(())
835    }
836}
837
838#[async_trait::async_trait]
839impl DeletableModel for BackupConfiguration {
840    type DeleteOptions = ();
841
842    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
843        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<BackupConfiguration>> =
844            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
845
846        &DELETE_LISTENERS
847    }
848
849    async fn delete_with_transaction(
850        &self,
851        state: &crate::State,
852        options: Self::DeleteOptions,
853        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
854    ) -> Result<(), anyhow::Error> {
855        self.run_delete_handlers(&options, state, transaction)
856            .await?;
857
858        sqlx::query(
859            r#"
860            DELETE FROM backup_configurations
861            WHERE backup_configurations.uuid = $1
862            "#,
863        )
864        .bind(self.uuid)
865        .execute(&mut **transaction)
866        .await?;
867
868        self.run_after_delete_handlers(&options, state, transaction)
869            .await?;
870
871        Ok(())
872    }
873}
874
875#[schema_extension_derive::extendible]
876#[init_args(BackupConfiguration, crate::State)]
877#[hook_args(crate::State)]
878#[derive(ToSchema, Serialize)]
879#[schema(title = "BackupConfiguration")]
880pub struct AdminApiBackupConfiguration {
881    pub uuid: uuid::Uuid,
882
883    pub name: compact_str::CompactString,
884    pub description: Option<compact_str::CompactString>,
885
886    pub maintenance_enabled: bool,
887    pub shared: bool,
888
889    pub backup_disk: super::server_backup::BackupDisk,
890    pub backup_configs: BackupConfigs,
891
892    pub created: chrono::DateTime<chrono::Utc>,
893}