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: croner::Cron,
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: &Option<compact_str::CompactString>,
177    _context: &(),
178) -> Result<(), garde::Error> {
179    let Some(fingerprint) = fingerprint else {
180        return Ok(());
181    };
182
183    if fingerprint.trim().is_empty() {
184        return Ok(());
185    }
186
187    let normalized = normalize_pbs_fingerprint(fingerprint);
188
189    if normalized.len() != 64 || !normalized.bytes().all(|b| b.is_ascii_hexdigit()) {
190        return Err(garde::Error::new(
191            "fingerprint must be a SHA-256 hash (64 hex characters, colons optional)",
192        ));
193    }
194
195    Ok(())
196}
197
198pub fn normalize_pbs_fingerprint(fingerprint: &str) -> compact_str::CompactString {
199    fingerprint
200        .chars()
201        .filter(|c| !c.is_whitespace() && *c != ':')
202        .map(|c| c.to_ascii_lowercase())
203        .collect()
204}
205
206fn validate_pbs_token_id(
207    token_id: &compact_str::CompactString,
208    _context: &(),
209) -> Result<(), garde::Error> {
210    static TOKEN_ID_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
211        regex::Regex::new(r"^[^\s:/!@]+@[A-Za-z][A-Za-z0-9._-]*![A-Za-z0-9._-]+$").unwrap()
212    });
213
214    if !TOKEN_ID_REGEX.is_match(token_id) {
215        return Err(garde::Error::new(
216            "token id must be in the form user@realm!token-name",
217        ));
218    }
219
220    Ok(())
221}
222
223#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
224pub struct BackupConfigsPbs {
225    #[garde(length(chars, min = 1, max = 255), url)]
226    #[schema(min_length = 1, max_length = 255, format = "uri")]
227    pub url: compact_str::CompactString,
228    #[garde(length(chars, min = 1, max = 255))]
229    #[schema(min_length = 1, max_length = 255)]
230    pub datastore: compact_str::CompactString,
231    #[garde(inner(length(chars, min = 1, max = 255)))]
232    #[schema(min_length = 1, max_length = 255)]
233    pub namespace: Option<compact_str::CompactString>,
234    #[garde(length(chars, min = 1, max = 255), custom(validate_pbs_token_id))]
235    #[schema(min_length = 1, max_length = 255)]
236    pub token_id: compact_str::CompactString,
237    #[garde(length(chars, min = 1, max = 255))]
238    #[schema(min_length = 1, max_length = 255)]
239    pub token_secret: compact_str::CompactString,
240    #[garde(custom(validate_fingerprint))]
241    #[schema(min_length = 64, max_length = 95)]
242    #[serde(default)]
243    pub fingerprint: Option<compact_str::CompactString>,
244    #[garde(inner(length(chars, min = 1, max = 255)))]
245    #[schema(min_length = 1, max_length = 255)]
246    pub backup_id_prefix: Option<compact_str::CompactString>,
247}
248
249impl BackupConfigsPbs {
250    pub async fn encrypt(
251        &mut self,
252        database: &crate::database::Database,
253    ) -> Result<(), anyhow::Error> {
254        self.token_secret = base32::encode(
255            base32::Alphabet::Z,
256            &database.encrypt(self.token_secret.clone()).await?,
257        )
258        .into();
259
260        Ok(())
261    }
262
263    pub async fn decrypt(
264        &mut self,
265        database: &crate::database::Database,
266    ) -> Result<(), anyhow::Error> {
267        if let Some(decoded) = base32::decode(base32::Alphabet::Z, &self.token_secret) {
268            self.token_secret = database.decrypt(decoded).await?;
269        }
270
271        Ok(())
272    }
273
274    pub fn censor(&mut self) {
275        self.token_secret = "".into();
276    }
277}
278
279fn validate_kopia_username(
280    username: &compact_str::CompactString,
281    _context: &(),
282) -> Result<(), garde::Error> {
283    static KOPIA_USERNAME_REGEX: std::sync::LazyLock<regex::Regex> =
284        std::sync::LazyLock::new(|| {
285            regex::Regex::new(r"^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9._-]*$").unwrap()
286        });
287
288    if !KOPIA_USERNAME_REGEX.is_match(username) {
289        return Err(garde::Error::new("username must be in the form user@host"));
290    }
291
292    Ok(())
293}
294
295fn validate_kopia_tags(
296    tags: &IndexMap<compact_str::CompactString, compact_str::CompactString>,
297    _context: &(),
298) -> Result<(), garde::Error> {
299    if tags.len() > 50 {
300        return Err(garde::Error::new("cannot have more than 50 tags"));
301    }
302
303    for (key, value) in tags.iter() {
304        if key.is_empty() || key.len() > 255 {
305            return Err(garde::Error::new(
306                "tag keys must be between 1 and 255 characters",
307            ));
308        }
309        if value.is_empty() || value.len() > 255 {
310            return Err(garde::Error::new(
311                "tag values must be between 1 and 255 characters",
312            ));
313        }
314    }
315
316    Ok(())
317}
318
319#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
320pub struct BackupConfigKopia {
321    #[garde(length(chars, min = 1, max = 255), url)]
322    #[schema(min_length = 1, max_length = 255, format = "uri")]
323    pub url: compact_str::CompactString,
324    #[garde(length(chars, min = 1, max = 255), custom(validate_kopia_username))]
325    #[schema(min_length = 1, max_length = 255)]
326    pub username: compact_str::CompactString,
327    #[garde(length(chars, min = 1, max = 255))]
328    #[schema(min_length = 1, max_length = 255)]
329    pub password: compact_str::CompactString,
330    #[garde(custom(validate_fingerprint))]
331    #[schema(min_length = 64, max_length = 95)]
332    #[serde(default)]
333    pub fingerprint: Option<compact_str::CompactString>,
334    #[garde(custom(validate_kopia_tags))]
335    pub tags: IndexMap<compact_str::CompactString, compact_str::CompactString>,
336}
337
338impl BackupConfigKopia {
339    pub async fn encrypt(
340        &mut self,
341        database: &crate::database::Database,
342    ) -> Result<(), anyhow::Error> {
343        self.password = base32::encode(
344            base32::Alphabet::Z,
345            &database.encrypt(self.password.clone()).await?,
346        )
347        .into();
348
349        Ok(())
350    }
351
352    pub async fn decrypt(
353        &mut self,
354        database: &crate::database::Database,
355    ) -> Result<(), anyhow::Error> {
356        if let Some(decoded) = base32::decode(base32::Alphabet::Z, &self.password) {
357            self.password = database.decrypt(decoded).await?;
358        }
359
360        Ok(())
361    }
362
363    pub fn censor(&mut self) {
364        self.password = "".into();
365    }
366}
367
368#[derive(ToSchema, Serialize, Deserialize, Default, Validate, Clone)]
369pub struct BackupConfigs {
370    #[garde(dive)]
371    pub s3: Option<BackupConfigsS3>,
372    #[garde(dive)]
373    pub restic: Option<BackupConfigsRestic>,
374    #[garde(dive)]
375    pub pbs: Option<BackupConfigsPbs>,
376    #[garde(dive)]
377    pub kopia: Option<BackupConfigKopia>,
378}
379
380impl BackupConfigs {
381    pub async fn encrypt(
382        &mut self,
383        database: &crate::database::Database,
384    ) -> Result<(), anyhow::Error> {
385        if let Some(s3) = &mut self.s3 {
386            s3.encrypt(database).await?;
387        }
388        if let Some(restic) = &mut self.restic {
389            restic.encrypt(database).await?;
390        }
391        if let Some(pbs) = &mut self.pbs {
392            pbs.encrypt(database).await?;
393        }
394        if let Some(kopia) = &mut self.kopia {
395            kopia.encrypt(database).await?;
396        }
397
398        Ok(())
399    }
400
401    pub async fn decrypt(
402        &mut self,
403        database: &crate::database::Database,
404    ) -> Result<(), anyhow::Error> {
405        if let Some(s3) = &mut self.s3 {
406            s3.decrypt(database).await?;
407        }
408        if let Some(restic) = &mut self.restic {
409            restic.decrypt(database).await?;
410        }
411        if let Some(pbs) = &mut self.pbs {
412            pbs.decrypt(database).await?;
413        }
414        if let Some(kopia) = &mut self.kopia {
415            kopia.decrypt(database).await?;
416        }
417
418        Ok(())
419    }
420
421    pub fn censor(&mut self) {
422        if let Some(s3) = &mut self.s3 {
423            s3.censor();
424        }
425        if let Some(restic) = &mut self.restic {
426            restic.censor();
427        }
428        if let Some(pbs) = &mut self.pbs {
429            pbs.censor();
430        }
431        if let Some(kopia) = &mut self.kopia {
432            kopia.censor();
433        }
434    }
435}
436
437#[derive(Serialize, Deserialize, Clone)]
438pub struct BackupConfiguration {
439    pub uuid: uuid::Uuid,
440
441    pub name: compact_str::CompactString,
442    pub description: Option<compact_str::CompactString>,
443
444    pub maintenance_enabled: bool,
445    pub shared: bool,
446
447    pub backup_disk: super::server_backup::BackupDisk,
448    pub backup_configs: BackupConfigs,
449
450    pub created: chrono::NaiveDateTime,
451
452    extension_data: super::ModelExtensionData,
453}
454
455impl BaseModel for BackupConfiguration {
456    const NAME: &'static str = "backup_configuration";
457
458    fn get_extension_list() -> &'static super::ModelExtensionList {
459        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
460            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
461
462        &EXTENSIONS
463    }
464
465    fn get_extension_data(&self) -> &super::ModelExtensionData {
466        &self.extension_data
467    }
468
469    #[inline]
470    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
471        let prefix = prefix.unwrap_or_default();
472
473        BTreeMap::from([
474            (
475                "backup_configurations.uuid",
476                compact_str::format_compact!("{prefix}uuid"),
477            ),
478            (
479                "backup_configurations.name",
480                compact_str::format_compact!("{prefix}name"),
481            ),
482            (
483                "backup_configurations.description",
484                compact_str::format_compact!("{prefix}description"),
485            ),
486            (
487                "backup_configurations.maintenance_enabled",
488                compact_str::format_compact!("{prefix}maintenance_enabled"),
489            ),
490            (
491                "backup_configurations.shared",
492                compact_str::format_compact!("{prefix}shared"),
493            ),
494            (
495                "backup_configurations.backup_disk",
496                compact_str::format_compact!("{prefix}backup_disk"),
497            ),
498            (
499                "backup_configurations.backup_configs",
500                compact_str::format_compact!("{prefix}backup_configs"),
501            ),
502            (
503                "backup_configurations.created",
504                compact_str::format_compact!("{prefix}created"),
505            ),
506        ])
507    }
508
509    #[inline]
510    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
511        let prefix = prefix.unwrap_or_default();
512
513        Ok(Self {
514            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
515            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
516            description: row
517                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
518            maintenance_enabled: row
519                .try_get(compact_str::format_compact!("{prefix}maintenance_enabled").as_str())?,
520            shared: row.try_get(compact_str::format_compact!("{prefix}shared").as_str())?,
521            backup_disk: row
522                .try_get(compact_str::format_compact!("{prefix}backup_disk").as_str())?,
523            backup_configs: serde_json::from_value(
524                row.get(compact_str::format_compact!("{prefix}backup_configs").as_str()),
525            )
526            .unwrap_or_default(),
527            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
528            extension_data: Self::map_extensions(prefix, row)?,
529        })
530    }
531}
532
533impl BackupConfiguration {
534    pub async fn all_with_pagination(
535        database: &crate::database::Database,
536        page: i64,
537        per_page: i64,
538        search: Option<&str>,
539    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
540        let offset = (page - 1) * per_page;
541
542        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
543            r#"
544            SELECT {}, COUNT(*) OVER() AS total_count
545            FROM backup_configurations
546            WHERE $1 IS NULL OR backup_configurations.name ILIKE '%' || $1 || '%'
547            ORDER BY backup_configurations.created
548            LIMIT $2 OFFSET $3
549            "#,
550            Self::columns_sql(None)
551        )))
552        .bind(search)
553        .bind(per_page)
554        .bind(offset)
555        .fetch_all(database.read())
556        .await?;
557
558        Ok(super::Pagination {
559            total: rows
560                .first()
561                .map_or(Ok(0), |row| row.try_get("total_count"))?,
562            per_page,
563            page,
564            data: rows
565                .into_iter()
566                .map(|row| Self::map(None, &row))
567                .try_collect_vec()?,
568        })
569    }
570
571    pub async fn cleanup_uuid_arrays(
572        database: &crate::database::Database,
573    ) -> Result<u64, crate::database::DatabaseError> {
574        let result = sqlx::query(
575            "UPDATE backup_configurations
576            SET backup_configs = jsonb_set(
577                backup_configs,
578                '{restic,prune_jobs}',
579                (
580                    SELECT COALESCE(jsonb_agg(
581                        jsonb_set(
582                            job,
583                            '{nodes}',
584                            COALESCE(
585                                (
586                                    SELECT jsonb_agg(node)
587                                    FROM jsonb_array_elements_text(job->'nodes') AS node
588                                    WHERE EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
589                                ),
590                                '[]'::jsonb
591                            )
592                        )
593                    ), '[]'::jsonb)
594                    FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job
595                )
596            )
597            WHERE jsonb_typeof(backup_configs->'restic'->'prune_jobs') = 'array'
598            AND EXISTS (
599                SELECT 1
600                FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job,
601                     jsonb_array_elements_text(job->'nodes') AS node
602                WHERE NOT EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
603            )",
604        )
605        .execute(database.write())
606        .await?;
607
608        Ok(result.rows_affected())
609    }
610}
611
612#[async_trait::async_trait]
613impl IntoAdminApiObject for BackupConfiguration {
614    type AdminApiObject = AdminApiBackupConfiguration;
615    type ExtraArgs<'a> = ();
616
617    async fn into_admin_api_object<'a>(
618        mut self,
619        state: &crate::State,
620        _args: Self::ExtraArgs<'a>,
621    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
622        let api_object = AdminApiBackupConfiguration::init_hooks(&self, state).await?;
623
624        self.backup_configs.decrypt(&state.database).await?;
625
626        let api_object = finish_extendible!(
627            AdminApiBackupConfiguration {
628                uuid: self.uuid,
629                name: self.name,
630                description: self.description,
631                maintenance_enabled: self.maintenance_enabled,
632                shared: self.shared,
633                backup_disk: self.backup_disk,
634                backup_configs: self.backup_configs,
635                created: self.created.and_utc(),
636            },
637            api_object,
638            state
639        )?;
640
641        Ok(api_object)
642    }
643}
644
645#[async_trait::async_trait]
646impl ByUuid for BackupConfiguration {
647    async fn by_uuid(
648        database: &crate::database::Database,
649        uuid: uuid::Uuid,
650    ) -> Result<Self, crate::database::DatabaseError> {
651        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
652            r#"
653            SELECT {}
654            FROM backup_configurations
655            WHERE backup_configurations.uuid = $1
656            "#,
657            Self::columns_sql(None)
658        )))
659        .bind(uuid)
660        .fetch_one(database.read())
661        .await?;
662
663        Self::map(None, &row)
664    }
665
666    async fn by_uuid_with_transaction(
667        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
668        uuid: uuid::Uuid,
669    ) -> Result<Self, crate::database::DatabaseError> {
670        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
671            r#"
672            SELECT {}
673            FROM backup_configurations
674            WHERE backup_configurations.uuid = $1
675            "#,
676            Self::columns_sql(None)
677        )))
678        .bind(uuid)
679        .fetch_one(&mut **transaction)
680        .await?;
681
682        Self::map(None, &row)
683    }
684}
685
686#[derive(ToSchema, Deserialize, Validate)]
687pub struct CreateBackupConfigurationOptions {
688    #[garde(length(chars, min = 1, max = 255))]
689    #[schema(min_length = 1, max_length = 255)]
690    pub name: compact_str::CompactString,
691    #[garde(length(chars, min = 1, max = 1024))]
692    #[schema(min_length = 1, max_length = 1024)]
693    pub description: Option<compact_str::CompactString>,
694    #[garde(skip)]
695    pub maintenance_enabled: bool,
696    #[garde(skip)]
697    pub shared: bool,
698    #[garde(skip)]
699    pub backup_disk: super::server_backup::BackupDisk,
700    #[garde(dive)]
701    pub backup_configs: BackupConfigs,
702}
703
704#[async_trait::async_trait]
705impl CreatableModel for BackupConfiguration {
706    type CreateOptions<'a> = CreateBackupConfigurationOptions;
707    type CreateResult = Self;
708
709    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
710        static CREATE_LISTENERS: LazyLock<CreateListenerList<BackupConfiguration>> =
711            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
712
713        &CREATE_LISTENERS
714    }
715
716    async fn create_with_transaction(
717        state: &crate::State,
718        mut options: Self::CreateOptions<'_>,
719        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
720    ) -> Result<Self, crate::database::DatabaseError> {
721        options.validate()?;
722
723        let mut query_builder = InsertQueryBuilder::new("backup_configurations");
724
725        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
726
727        options.backup_configs.encrypt(&state.database).await?;
728
729        query_builder
730            .set("name", &options.name)
731            .set("description", &options.description)
732            .set("maintenance_enabled", options.maintenance_enabled)
733            .set("shared", options.shared)
734            .set("backup_disk", options.backup_disk)
735            .set(
736                "backup_configs",
737                serde_json::to_value(&options.backup_configs)?,
738            );
739
740        let row = query_builder
741            .returning(&Self::columns_sql(None))
742            .fetch_one(&mut **transaction)
743            .await?;
744        let mut backup_configuration = Self::map(None, &row)?;
745
746        Self::run_after_create_handlers(&mut backup_configuration, &options, state, transaction)
747            .await?;
748
749        Ok(backup_configuration)
750    }
751}
752
753#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
754pub struct UpdateBackupConfigurationOptions {
755    #[garde(length(chars, min = 1, max = 255))]
756    #[schema(min_length = 1, max_length = 255)]
757    pub name: Option<compact_str::CompactString>,
758    #[garde(length(chars, min = 1, max = 1024))]
759    #[schema(min_length = 1, max_length = 1024)]
760    #[serde(
761        default,
762        skip_serializing_if = "Option::is_none",
763        with = "::serde_with::rust::double_option"
764    )]
765    pub description: Option<Option<compact_str::CompactString>>,
766    #[garde(skip)]
767    pub maintenance_enabled: Option<bool>,
768    #[garde(skip)]
769    pub shared: Option<bool>,
770    #[garde(skip)]
771    pub backup_disk: Option<super::server_backup::BackupDisk>,
772    #[garde(dive)]
773    pub backup_configs: Option<BackupConfigs>,
774}
775
776#[async_trait::async_trait]
777impl UpdatableModel for BackupConfiguration {
778    type UpdateOptions = UpdateBackupConfigurationOptions;
779
780    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
781        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<BackupConfiguration>> =
782            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
783
784        &UPDATE_LISTENERS
785    }
786
787    async fn update_with_transaction(
788        &mut self,
789        state: &crate::State,
790        mut options: Self::UpdateOptions,
791        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
792    ) -> Result<(), crate::database::DatabaseError> {
793        options.validate()?;
794
795        let mut query_builder = UpdateQueryBuilder::new("backup_configurations");
796
797        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
798            .await?;
799
800        query_builder
801            .set("name", options.name.as_ref())
802            .set(
803                "description",
804                options.description.as_ref().map(|d| d.as_ref()),
805            )
806            .set("maintenance_enabled", options.maintenance_enabled)
807            .set("shared", options.shared)
808            .set("backup_disk", options.backup_disk)
809            .set(
810                "backup_configs",
811                if let Some(backup_configs) = &mut options.backup_configs {
812                    backup_configs.encrypt(&state.database).await?;
813
814                    Some(serde_json::to_value(backup_configs)?)
815                } else {
816                    None
817                },
818            )
819            .where_eq("uuid", self.uuid);
820
821        query_builder.execute(&mut **transaction).await?;
822
823        if let Some(name) = options.name {
824            self.name = name;
825        }
826        if let Some(description) = options.description {
827            self.description = description;
828        }
829        if let Some(maintenance_enabled) = options.maintenance_enabled {
830            self.maintenance_enabled = maintenance_enabled;
831        }
832        if let Some(shared) = options.shared {
833            self.shared = shared;
834        }
835        if let Some(backup_disk) = options.backup_disk {
836            self.backup_disk = backup_disk;
837        }
838        if let Some(backup_configs) = options.backup_configs {
839            self.backup_configs = backup_configs;
840        }
841
842        self.run_after_update_handlers(state, transaction).await?;
843
844        Ok(())
845    }
846}
847
848#[async_trait::async_trait]
849impl DeletableModel for BackupConfiguration {
850    type DeleteOptions = ();
851
852    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
853        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<BackupConfiguration>> =
854            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
855
856        &DELETE_LISTENERS
857    }
858
859    async fn delete_with_transaction(
860        &self,
861        state: &crate::State,
862        options: Self::DeleteOptions,
863        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
864    ) -> Result<(), anyhow::Error> {
865        self.run_delete_handlers(&options, state, transaction)
866            .await?;
867
868        sqlx::query(
869            r#"
870            DELETE FROM backup_configurations
871            WHERE backup_configurations.uuid = $1
872            "#,
873        )
874        .bind(self.uuid)
875        .execute(&mut **transaction)
876        .await?;
877
878        self.run_after_delete_handlers(&options, state, transaction)
879            .await?;
880
881        Ok(())
882    }
883}
884
885#[derive(Validate)]
886pub struct DuplicateBackupConfigurationOptions {
887    #[garde(length(chars, min = 1, max = 255))]
888    pub name: compact_str::CompactString,
889}
890
891#[async_trait::async_trait]
892impl DuplicableModel for BackupConfiguration {
893    type DuplicateOptions<'a> = DuplicateBackupConfigurationOptions;
894
895    fn get_duplicate_handlers() -> &'static LazyLock<DuplicateHandlerList<Self>> {
896        static DUPLICATE_LISTENERS: LazyLock<DuplicateHandlerList<BackupConfiguration>> =
897            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
898
899        &DUPLICATE_LISTENERS
900    }
901
902    async fn duplicate_with_transaction(
903        &self,
904        state: &crate::State,
905        options: Self::DuplicateOptions<'_>,
906        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
907    ) -> Result<Self, crate::database::DatabaseError> {
908        options.validate()?;
909
910        self.run_duplicate_handlers(&options, state, transaction)
911            .await?;
912
913        let mut query_builder = InsertQueryBuilder::new("backup_configurations");
914
915        query_builder
916            .set("name", &options.name)
917            .set("description", &self.description)
918            .set("maintenance_enabled", self.maintenance_enabled)
919            .set("shared", self.shared)
920            .set("backup_disk", self.backup_disk)
921            .set(
922                "backup_configs",
923                serde_json::to_value(&self.backup_configs)?,
924            );
925
926        let row = query_builder
927            .returning(&Self::columns_sql(None))
928            .fetch_one(&mut **transaction)
929            .await?;
930        let mut backup_configuration = Self::map(None, &row)?;
931
932        self.run_after_duplicate_handlers(&mut backup_configuration, &options, state, transaction)
933            .await?;
934
935        Ok(backup_configuration)
936    }
937}
938
939#[schema_extension_derive::extendible]
940#[init_args(BackupConfiguration, crate::State)]
941#[hook_args(crate::State)]
942#[derive(ToSchema, Serialize)]
943#[schema(title = "BackupConfiguration")]
944pub struct AdminApiBackupConfiguration {
945    pub uuid: uuid::Uuid,
946
947    pub name: compact_str::CompactString,
948    pub description: Option<compact_str::CompactString>,
949
950    pub maintenance_enabled: bool,
951    pub shared: bool,
952
953    pub backup_disk: super::server_backup::BackupDisk,
954    pub backup_configs: BackupConfigs,
955
956    pub created: chrono::DateTime<chrono::Utc>,
957}