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