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 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}