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