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    fn cache_invalidation_keys(&self) -> Vec<compact_str::CompactString> {
535        vec![compact_str::format_compact!(
536            "{}::{}",
537            Self::NAME,
538            self.uuid
539        )]
540    }
541}
542
543impl BackupConfiguration {
544    pub async fn all_with_pagination(
545        database: &crate::database::Database,
546        page: i64,
547        per_page: i64,
548        search: Option<&str>,
549    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
550        super::PaginatedQueryBuilder::new(
551            Self::columns_sql(None),
552            format!(
553                r#"
554                FROM backup_configurations
555                WHERE {search}
556                "#,
557                search = super::search_sql(
558                    1,
559                    &["backup_configurations.name"],
560                    &["backup_configurations.uuid"]
561                ),
562            ),
563            "backup_configurations.created, backup_configurations.uuid",
564        )
565        .bind(search)
566        .fetch(database, page, per_page)
567        .await
568    }
569
570    pub async fn cleanup_uuid_arrays(
571        database: &crate::database::Database,
572    ) -> Result<u64, crate::database::DatabaseError> {
573        let result = sqlx::query(
574            "UPDATE backup_configurations
575            SET backup_configs = jsonb_set(
576                backup_configs,
577                '{restic,prune_jobs}',
578                (
579                    SELECT COALESCE(jsonb_agg(
580                        jsonb_set(
581                            job,
582                            '{nodes}',
583                            COALESCE(
584                                (
585                                    SELECT jsonb_agg(node)
586                                    FROM jsonb_array_elements_text(job->'nodes') AS node
587                                    WHERE EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
588                                ),
589                                '[]'::jsonb
590                            )
591                        )
592                    ), '[]'::jsonb)
593                    FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job
594                )
595            )
596            WHERE jsonb_typeof(backup_configs->'restic'->'prune_jobs') = 'array'
597            AND EXISTS (
598                SELECT 1
599                FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job,
600                     jsonb_array_elements_text(job->'nodes') AS node
601                WHERE NOT EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
602            )",
603        )
604        .execute(database.write())
605        .await?;
606
607        Ok(result.rows_affected())
608    }
609}
610
611#[async_trait::async_trait]
612impl IntoAdminApiObject for BackupConfiguration {
613    type AdminApiObject = AdminApiBackupConfiguration;
614    type ExtraArgs<'a> = ();
615
616    async fn into_admin_api_object<'a>(
617        mut self,
618        state: &crate::State,
619        _args: Self::ExtraArgs<'a>,
620    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
621        let api_object = AdminApiBackupConfiguration::init_hooks(&self, state).await?;
622
623        self.backup_configs.decrypt(&state.database).await?;
624
625        let api_object = finish_extendible!(
626            AdminApiBackupConfiguration {
627                uuid: self.uuid,
628                name: self.name,
629                description: self.description,
630                maintenance_enabled: self.maintenance_enabled,
631                shared: self.shared,
632                backup_disk: self.backup_disk,
633                backup_configs: self.backup_configs,
634                created: self.created.and_utc(),
635            },
636            api_object,
637            state
638        )?;
639
640        Ok(api_object)
641    }
642}
643
644#[async_trait::async_trait]
645impl ByUuid for BackupConfiguration {
646    async fn by_uuid(
647        database: &crate::database::Database,
648        uuid: uuid::Uuid,
649    ) -> Result<Self, crate::database::DatabaseError> {
650        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
651            r#"
652            SELECT {}
653            FROM backup_configurations
654            WHERE backup_configurations.uuid = $1
655            "#,
656            Self::columns_sql(None)
657        )))
658        .bind(uuid)
659        .fetch_one(database.read())
660        .await?;
661
662        Self::map(None, &row)
663    }
664
665    async fn by_uuid_with_transaction(
666        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
667        uuid: uuid::Uuid,
668    ) -> Result<Self, crate::database::DatabaseError> {
669        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
670            r#"
671            SELECT {}
672            FROM backup_configurations
673            WHERE backup_configurations.uuid = $1
674            "#,
675            Self::columns_sql(None)
676        )))
677        .bind(uuid)
678        .fetch_one(&mut **transaction)
679        .await?;
680
681        Self::map(None, &row)
682    }
683}
684
685#[derive(ToSchema, Deserialize, Validate)]
686pub struct CreateBackupConfigurationOptions {
687    #[garde(length(chars, min = 1, max = 255))]
688    #[schema(min_length = 1, max_length = 255)]
689    pub name: compact_str::CompactString,
690    #[garde(length(chars, min = 1, max = 1024))]
691    #[schema(min_length = 1, max_length = 1024)]
692    pub description: Option<compact_str::CompactString>,
693    #[garde(skip)]
694    pub maintenance_enabled: bool,
695    #[garde(skip)]
696    pub shared: bool,
697    #[garde(skip)]
698    pub backup_disk: super::server_backup::BackupDisk,
699    #[garde(dive)]
700    pub backup_configs: BackupConfigs,
701}
702
703#[async_trait::async_trait]
704impl CreatableModel for BackupConfiguration {
705    type CreateOptions<'a> = CreateBackupConfigurationOptions;
706    type CreateResult = Self;
707
708    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
709        static CREATE_LISTENERS: LazyLock<CreateListenerList<BackupConfiguration>> =
710            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
711
712        &CREATE_LISTENERS
713    }
714
715    async fn create_with_transaction(
716        state: &crate::State,
717        mut options: Self::CreateOptions<'_>,
718        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
719    ) -> Result<Self, crate::database::DatabaseError> {
720        options.validate()?;
721
722        let mut query_builder = InsertQueryBuilder::new("backup_configurations");
723
724        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
725
726        options.backup_configs.encrypt(&state.database).await?;
727
728        query_builder
729            .set("name", &options.name)
730            .set("description", &options.description)
731            .set("maintenance_enabled", options.maintenance_enabled)
732            .set("shared", options.shared)
733            .set("backup_disk", options.backup_disk)
734            .set(
735                "backup_configs",
736                serde_json::to_value(&options.backup_configs)?,
737            );
738
739        let row = query_builder
740            .returning(&Self::columns_sql(None))
741            .fetch_one(&mut **transaction)
742            .await?;
743        let mut backup_configuration = Self::map(None, &row)?;
744
745        Self::run_after_create_handlers(&mut backup_configuration, &options, state, transaction)
746            .await?;
747
748        Ok(backup_configuration)
749    }
750}
751
752#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
753pub struct UpdateBackupConfigurationOptions {
754    #[garde(length(chars, min = 1, max = 255))]
755    #[schema(min_length = 1, max_length = 255)]
756    pub name: Option<compact_str::CompactString>,
757    #[garde(length(chars, min = 1, max = 1024))]
758    #[schema(min_length = 1, max_length = 1024)]
759    #[serde(
760        default,
761        skip_serializing_if = "Option::is_none",
762        with = "::serde_with::rust::double_option"
763    )]
764    pub description: Option<Option<compact_str::CompactString>>,
765    #[garde(skip)]
766    pub maintenance_enabled: Option<bool>,
767    #[garde(skip)]
768    pub shared: Option<bool>,
769    #[garde(skip)]
770    pub backup_disk: Option<super::server_backup::BackupDisk>,
771    #[garde(dive)]
772    pub backup_configs: Option<BackupConfigs>,
773}
774
775#[async_trait::async_trait]
776impl UpdatableModel for BackupConfiguration {
777    type UpdateOptions = UpdateBackupConfigurationOptions;
778
779    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
780        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<BackupConfiguration>> =
781            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
782
783        &UPDATE_LISTENERS
784    }
785
786    async fn update_with_transaction(
787        &mut self,
788        state: &crate::State,
789        mut options: Self::UpdateOptions,
790        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
791    ) -> Result<(), crate::database::DatabaseError> {
792        options.validate()?;
793
794        let mut query_builder = UpdateQueryBuilder::new("backup_configurations");
795
796        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
797            .await?;
798
799        query_builder
800            .set("name", options.name.as_ref())
801            .set(
802                "description",
803                options.description.as_ref().map(|d| d.as_ref()),
804            )
805            .set("maintenance_enabled", options.maintenance_enabled)
806            .set("shared", options.shared)
807            .set("backup_disk", options.backup_disk)
808            .set(
809                "backup_configs",
810                if let Some(backup_configs) = &mut options.backup_configs {
811                    backup_configs.encrypt(&state.database).await?;
812
813                    Some(serde_json::to_value(backup_configs)?)
814                } else {
815                    None
816                },
817            )
818            .where_eq("uuid", self.uuid);
819
820        query_builder.execute(&mut **transaction).await?;
821
822        if let Some(name) = options.name {
823            self.name = name;
824        }
825        if let Some(description) = options.description {
826            self.description = description;
827        }
828        if let Some(maintenance_enabled) = options.maintenance_enabled {
829            self.maintenance_enabled = maintenance_enabled;
830        }
831        if let Some(shared) = options.shared {
832            self.shared = shared;
833        }
834        if let Some(backup_disk) = options.backup_disk {
835            self.backup_disk = backup_disk;
836        }
837        if let Some(backup_configs) = options.backup_configs {
838            self.backup_configs = backup_configs;
839        }
840
841        self.run_after_update_handlers(state, transaction).await?;
842
843        Ok(())
844    }
845}
846
847#[async_trait::async_trait]
848impl DeletableModel for BackupConfiguration {
849    type DeleteOptions = ();
850
851    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
852        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<BackupConfiguration>> =
853            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
854
855        &DELETE_LISTENERS
856    }
857
858    async fn delete_with_transaction(
859        &self,
860        state: &crate::State,
861        options: Self::DeleteOptions,
862        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
863    ) -> Result<(), anyhow::Error> {
864        self.run_delete_handlers(&options, state, transaction)
865            .await?;
866
867        sqlx::query(
868            r#"
869            DELETE FROM backup_configurations
870            WHERE backup_configurations.uuid = $1
871            "#,
872        )
873        .bind(self.uuid)
874        .execute(&mut **transaction)
875        .await?;
876
877        self.run_after_delete_handlers(&options, state, transaction)
878            .await?;
879
880        Ok(())
881    }
882}
883
884#[derive(Validate)]
885pub struct DuplicateBackupConfigurationOptions {
886    #[garde(length(chars, min = 1, max = 255))]
887    pub name: compact_str::CompactString,
888}
889
890#[async_trait::async_trait]
891impl DuplicableModel for BackupConfiguration {
892    type DuplicateOptions<'a> = DuplicateBackupConfigurationOptions;
893    type DuplicateResult = Self;
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}