Skip to main content

shared/models/
backup_configuration.rs

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