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: cron::Schedule,
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: &compact_str::CompactString,
177 _context: &(),
178) -> Result<(), garde::Error> {
179 let normalized = normalize_pbs_fingerprint(fingerprint);
180
181 if normalized.len() != 64 || !normalized.bytes().all(|b| b.is_ascii_hexdigit()) {
182 return Err(garde::Error::new(
183 "fingerprint must be a SHA-256 hash (64 hex characters, colons optional)",
184 ));
185 }
186
187 Ok(())
188}
189
190pub fn normalize_pbs_fingerprint(fingerprint: &str) -> compact_str::CompactString {
191 fingerprint
192 .chars()
193 .filter(|c| !c.is_whitespace() && *c != ':')
194 .map(|c| c.to_ascii_lowercase())
195 .collect()
196}
197
198fn validate_pbs_token_id(
199 token_id: &compact_str::CompactString,
200 _context: &(),
201) -> Result<(), garde::Error> {
202 static TOKEN_ID_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
203 regex::Regex::new(r"^[^\s:/!@]+@[A-Za-z][A-Za-z0-9._-]*![A-Za-z0-9._-]+$").unwrap()
204 });
205
206 if !TOKEN_ID_REGEX.is_match(token_id) {
207 return Err(garde::Error::new(
208 "token id must be in the form user@realm!token-name",
209 ));
210 }
211
212 Ok(())
213}
214
215#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
216pub struct BackupConfigsPbs {
217 #[garde(length(chars, min = 1, max = 255), url)]
218 #[schema(min_length = 1, max_length = 255, format = "uri")]
219 pub url: compact_str::CompactString,
220 #[garde(length(chars, min = 1, max = 255))]
221 #[schema(min_length = 1, max_length = 255)]
222 pub datastore: compact_str::CompactString,
223 #[garde(inner(length(chars, min = 1, max = 255)))]
224 #[schema(min_length = 1, max_length = 255)]
225 pub namespace: Option<compact_str::CompactString>,
226 #[garde(length(chars, min = 1, max = 255), custom(validate_pbs_token_id))]
227 #[schema(min_length = 1, max_length = 255)]
228 pub token_id: compact_str::CompactString,
229 #[garde(length(chars, min = 1, max = 255))]
230 #[schema(min_length = 1, max_length = 255)]
231 pub token_secret: compact_str::CompactString,
232 #[garde(custom(validate_fingerprint))]
233 #[schema(min_length = 64, max_length = 95)]
234 pub fingerprint: compact_str::CompactString,
235 #[garde(inner(length(chars, min = 1, max = 255)))]
236 #[schema(min_length = 1, max_length = 255)]
237 pub backup_id_prefix: Option<compact_str::CompactString>,
238}
239
240impl BackupConfigsPbs {
241 pub async fn encrypt(
242 &mut self,
243 database: &crate::database::Database,
244 ) -> Result<(), anyhow::Error> {
245 self.token_secret = base32::encode(
246 base32::Alphabet::Z,
247 &database.encrypt(self.token_secret.clone()).await?,
248 )
249 .into();
250
251 Ok(())
252 }
253
254 pub async fn decrypt(
255 &mut self,
256 database: &crate::database::Database,
257 ) -> Result<(), anyhow::Error> {
258 if let Some(decoded) = base32::decode(base32::Alphabet::Z, &self.token_secret) {
259 self.token_secret = database.decrypt(decoded).await?;
260 }
261
262 Ok(())
263 }
264
265 pub fn censor(&mut self) {
266 self.token_secret = "".into();
267 }
268}
269
270fn validate_kopia_username(
271 username: &compact_str::CompactString,
272 _context: &(),
273) -> Result<(), garde::Error> {
274 static KOPIA_USERNAME_REGEX: std::sync::LazyLock<regex::Regex> =
275 std::sync::LazyLock::new(|| {
276 regex::Regex::new(r"^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9._-]*$").unwrap()
277 });
278
279 if !KOPIA_USERNAME_REGEX.is_match(username) {
280 return Err(garde::Error::new("username must be in the form user@host"));
281 }
282
283 Ok(())
284}
285
286fn validate_kopia_tags(
287 tags: &IndexMap<compact_str::CompactString, compact_str::CompactString>,
288 _context: &(),
289) -> Result<(), garde::Error> {
290 if tags.len() > 50 {
291 return Err(garde::Error::new("cannot have more than 50 tags"));
292 }
293
294 for (key, value) in tags.iter() {
295 if key.is_empty() || key.len() > 255 {
296 return Err(garde::Error::new(
297 "tag keys must be between 1 and 255 characters",
298 ));
299 }
300 if value.is_empty() || value.len() > 255 {
301 return Err(garde::Error::new(
302 "tag values must be between 1 and 255 characters",
303 ));
304 }
305 }
306
307 Ok(())
308}
309
310#[derive(ToSchema, Serialize, Deserialize, Validate, Clone)]
311pub struct BackupConfigKopia {
312 #[garde(length(chars, min = 1, max = 255), url)]
313 #[schema(min_length = 1, max_length = 255, format = "uri")]
314 pub url: compact_str::CompactString,
315 #[garde(length(chars, min = 1, max = 255), custom(validate_kopia_username))]
316 #[schema(min_length = 1, max_length = 255)]
317 pub username: compact_str::CompactString,
318 #[garde(length(chars, min = 1, max = 255))]
319 #[schema(min_length = 1, max_length = 255)]
320 pub password: compact_str::CompactString,
321 #[garde(custom(validate_fingerprint))]
322 #[schema(min_length = 64, max_length = 95)]
323 pub fingerprint: compact_str::CompactString,
324 #[garde(custom(validate_kopia_tags))]
325 pub tags: IndexMap<compact_str::CompactString, compact_str::CompactString>,
326}
327
328impl BackupConfigKopia {
329 pub async fn encrypt(
330 &mut self,
331 database: &crate::database::Database,
332 ) -> Result<(), anyhow::Error> {
333 self.password = base32::encode(
334 base32::Alphabet::Z,
335 &database.encrypt(self.password.clone()).await?,
336 )
337 .into();
338
339 Ok(())
340 }
341
342 pub async fn decrypt(
343 &mut self,
344 database: &crate::database::Database,
345 ) -> Result<(), anyhow::Error> {
346 if let Some(decoded) = base32::decode(base32::Alphabet::Z, &self.password) {
347 self.password = database.decrypt(decoded).await?;
348 }
349
350 Ok(())
351 }
352
353 pub fn censor(&mut self) {
354 self.password = "".into();
355 }
356}
357
358#[derive(ToSchema, Serialize, Deserialize, Default, Validate, Clone)]
359pub struct BackupConfigs {
360 #[garde(dive)]
361 pub s3: Option<BackupConfigsS3>,
362 #[garde(dive)]
363 pub restic: Option<BackupConfigsRestic>,
364 #[garde(dive)]
365 pub pbs: Option<BackupConfigsPbs>,
366 #[garde(dive)]
367 pub kopia: Option<BackupConfigKopia>,
368}
369
370impl BackupConfigs {
371 pub async fn encrypt(
372 &mut self,
373 database: &crate::database::Database,
374 ) -> Result<(), anyhow::Error> {
375 if let Some(s3) = &mut self.s3 {
376 s3.encrypt(database).await?;
377 }
378 if let Some(restic) = &mut self.restic {
379 restic.encrypt(database).await?;
380 }
381 if let Some(pbs) = &mut self.pbs {
382 pbs.encrypt(database).await?;
383 }
384 if let Some(kopia) = &mut self.kopia {
385 kopia.encrypt(database).await?;
386 }
387
388 Ok(())
389 }
390
391 pub async fn decrypt(
392 &mut self,
393 database: &crate::database::Database,
394 ) -> Result<(), anyhow::Error> {
395 if let Some(s3) = &mut self.s3 {
396 s3.decrypt(database).await?;
397 }
398 if let Some(restic) = &mut self.restic {
399 restic.decrypt(database).await?;
400 }
401 if let Some(pbs) = &mut self.pbs {
402 pbs.decrypt(database).await?;
403 }
404 if let Some(kopia) = &mut self.kopia {
405 kopia.decrypt(database).await?;
406 }
407
408 Ok(())
409 }
410
411 pub fn censor(&mut self) {
412 if let Some(s3) = &mut self.s3 {
413 s3.censor();
414 }
415 if let Some(restic) = &mut self.restic {
416 restic.censor();
417 }
418 if let Some(pbs) = &mut self.pbs {
419 pbs.censor();
420 }
421 if let Some(kopia) = &mut self.kopia {
422 kopia.censor();
423 }
424 }
425}
426
427#[derive(Serialize, Deserialize, Clone)]
428pub struct BackupConfiguration {
429 pub uuid: uuid::Uuid,
430
431 pub name: compact_str::CompactString,
432 pub description: Option<compact_str::CompactString>,
433
434 pub maintenance_enabled: bool,
435 pub shared: bool,
436
437 pub backup_disk: super::server_backup::BackupDisk,
438 pub backup_configs: BackupConfigs,
439
440 pub created: chrono::NaiveDateTime,
441
442 extension_data: super::ModelExtensionData,
443}
444
445impl BaseModel for BackupConfiguration {
446 const NAME: &'static str = "backup_configuration";
447
448 fn get_extension_list() -> &'static super::ModelExtensionList {
449 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
450 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
451
452 &EXTENSIONS
453 }
454
455 fn get_extension_data(&self) -> &super::ModelExtensionData {
456 &self.extension_data
457 }
458
459 #[inline]
460 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
461 let prefix = prefix.unwrap_or_default();
462
463 BTreeMap::from([
464 (
465 "backup_configurations.uuid",
466 compact_str::format_compact!("{prefix}uuid"),
467 ),
468 (
469 "backup_configurations.name",
470 compact_str::format_compact!("{prefix}name"),
471 ),
472 (
473 "backup_configurations.description",
474 compact_str::format_compact!("{prefix}description"),
475 ),
476 (
477 "backup_configurations.maintenance_enabled",
478 compact_str::format_compact!("{prefix}maintenance_enabled"),
479 ),
480 (
481 "backup_configurations.shared",
482 compact_str::format_compact!("{prefix}shared"),
483 ),
484 (
485 "backup_configurations.backup_disk",
486 compact_str::format_compact!("{prefix}backup_disk"),
487 ),
488 (
489 "backup_configurations.backup_configs",
490 compact_str::format_compact!("{prefix}backup_configs"),
491 ),
492 (
493 "backup_configurations.created",
494 compact_str::format_compact!("{prefix}created"),
495 ),
496 ])
497 }
498
499 #[inline]
500 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
501 let prefix = prefix.unwrap_or_default();
502
503 Ok(Self {
504 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
505 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
506 description: row
507 .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
508 maintenance_enabled: row
509 .try_get(compact_str::format_compact!("{prefix}maintenance_enabled").as_str())?,
510 shared: row.try_get(compact_str::format_compact!("{prefix}shared").as_str())?,
511 backup_disk: row
512 .try_get(compact_str::format_compact!("{prefix}backup_disk").as_str())?,
513 backup_configs: serde_json::from_value(
514 row.get(compact_str::format_compact!("{prefix}backup_configs").as_str()),
515 )
516 .unwrap_or_default(),
517 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
518 extension_data: Self::map_extensions(prefix, row)?,
519 })
520 }
521}
522
523impl BackupConfiguration {
524 pub async fn all_with_pagination(
525 database: &crate::database::Database,
526 page: i64,
527 per_page: i64,
528 search: Option<&str>,
529 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
530 let offset = (page - 1) * per_page;
531
532 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
533 r#"
534 SELECT {}, COUNT(*) OVER() AS total_count
535 FROM backup_configurations
536 WHERE $1 IS NULL OR backup_configurations.name ILIKE '%' || $1 || '%'
537 ORDER BY backup_configurations.created
538 LIMIT $2 OFFSET $3
539 "#,
540 Self::columns_sql(None)
541 )))
542 .bind(search)
543 .bind(per_page)
544 .bind(offset)
545 .fetch_all(database.read())
546 .await?;
547
548 Ok(super::Pagination {
549 total: rows
550 .first()
551 .map_or(Ok(0), |row| row.try_get("total_count"))?,
552 per_page,
553 page,
554 data: rows
555 .into_iter()
556 .map(|row| Self::map(None, &row))
557 .try_collect_vec()?,
558 })
559 }
560
561 pub async fn cleanup_uuid_arrays(
562 database: &crate::database::Database,
563 ) -> Result<u64, crate::database::DatabaseError> {
564 let result = sqlx::query(
565 "UPDATE backup_configurations
566 SET backup_configs = jsonb_set(
567 backup_configs,
568 '{restic,prune_jobs}',
569 (
570 SELECT COALESCE(jsonb_agg(
571 jsonb_set(
572 job,
573 '{nodes}',
574 COALESCE(
575 (
576 SELECT jsonb_agg(node)
577 FROM jsonb_array_elements_text(job->'nodes') AS node
578 WHERE EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
579 ),
580 '[]'::jsonb
581 )
582 )
583 ), '[]'::jsonb)
584 FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job
585 )
586 )
587 WHERE jsonb_typeof(backup_configs->'restic'->'prune_jobs') = 'array'
588 AND EXISTS (
589 SELECT 1
590 FROM jsonb_array_elements(backup_configs->'restic'->'prune_jobs') AS job,
591 jsonb_array_elements_text(job->'nodes') AS node
592 WHERE NOT EXISTS (SELECT 1 FROM nodes WHERE uuid = node::uuid)
593 )",
594 )
595 .execute(database.write())
596 .await?;
597
598 Ok(result.rows_affected())
599 }
600}
601
602#[async_trait::async_trait]
603impl IntoAdminApiObject for BackupConfiguration {
604 type AdminApiObject = AdminApiBackupConfiguration;
605 type ExtraArgs<'a> = ();
606
607 async fn into_admin_api_object<'a>(
608 mut self,
609 state: &crate::State,
610 _args: Self::ExtraArgs<'a>,
611 ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
612 let api_object = AdminApiBackupConfiguration::init_hooks(&self, state).await?;
613
614 self.backup_configs.decrypt(&state.database).await?;
615
616 let api_object = finish_extendible!(
617 AdminApiBackupConfiguration {
618 uuid: self.uuid,
619 name: self.name,
620 description: self.description,
621 maintenance_enabled: self.maintenance_enabled,
622 shared: self.shared,
623 backup_disk: self.backup_disk,
624 backup_configs: self.backup_configs,
625 created: self.created.and_utc(),
626 },
627 api_object,
628 state
629 )?;
630
631 Ok(api_object)
632 }
633}
634
635#[async_trait::async_trait]
636impl ByUuid for BackupConfiguration {
637 async fn by_uuid(
638 database: &crate::database::Database,
639 uuid: uuid::Uuid,
640 ) -> Result<Self, crate::database::DatabaseError> {
641 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
642 r#"
643 SELECT {}
644 FROM backup_configurations
645 WHERE backup_configurations.uuid = $1
646 "#,
647 Self::columns_sql(None)
648 )))
649 .bind(uuid)
650 .fetch_one(database.read())
651 .await?;
652
653 Self::map(None, &row)
654 }
655
656 async fn by_uuid_with_transaction(
657 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
658 uuid: uuid::Uuid,
659 ) -> Result<Self, crate::database::DatabaseError> {
660 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
661 r#"
662 SELECT {}
663 FROM backup_configurations
664 WHERE backup_configurations.uuid = $1
665 "#,
666 Self::columns_sql(None)
667 )))
668 .bind(uuid)
669 .fetch_one(&mut **transaction)
670 .await?;
671
672 Self::map(None, &row)
673 }
674}
675
676#[derive(ToSchema, Deserialize, Validate)]
677pub struct CreateBackupConfigurationOptions {
678 #[garde(length(chars, min = 1, max = 255))]
679 #[schema(min_length = 1, max_length = 255)]
680 pub name: compact_str::CompactString,
681 #[garde(length(chars, min = 1, max = 1024))]
682 #[schema(min_length = 1, max_length = 1024)]
683 pub description: Option<compact_str::CompactString>,
684 #[garde(skip)]
685 pub maintenance_enabled: bool,
686 #[garde(skip)]
687 pub shared: bool,
688 #[garde(skip)]
689 pub backup_disk: super::server_backup::BackupDisk,
690 #[garde(dive)]
691 pub backup_configs: BackupConfigs,
692}
693
694#[async_trait::async_trait]
695impl CreatableModel for BackupConfiguration {
696 type CreateOptions<'a> = CreateBackupConfigurationOptions;
697 type CreateResult = Self;
698
699 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
700 static CREATE_LISTENERS: LazyLock<CreateListenerList<BackupConfiguration>> =
701 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
702
703 &CREATE_LISTENERS
704 }
705
706 async fn create_with_transaction(
707 state: &crate::State,
708 mut options: Self::CreateOptions<'_>,
709 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
710 ) -> Result<Self, crate::database::DatabaseError> {
711 options.validate()?;
712
713 let mut query_builder = InsertQueryBuilder::new("backup_configurations");
714
715 Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
716
717 options.backup_configs.encrypt(&state.database).await?;
718
719 query_builder
720 .set("name", &options.name)
721 .set("description", &options.description)
722 .set("maintenance_enabled", options.maintenance_enabled)
723 .set("shared", options.shared)
724 .set("backup_disk", options.backup_disk)
725 .set(
726 "backup_configs",
727 serde_json::to_value(&options.backup_configs)?,
728 );
729
730 let row = query_builder
731 .returning(&Self::columns_sql(None))
732 .fetch_one(&mut **transaction)
733 .await?;
734 let mut backup_configuration = Self::map(None, &row)?;
735
736 Self::run_after_create_handlers(&mut backup_configuration, &options, state, transaction)
737 .await?;
738
739 Ok(backup_configuration)
740 }
741}
742
743#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
744pub struct UpdateBackupConfigurationOptions {
745 #[garde(length(chars, min = 1, max = 255))]
746 #[schema(min_length = 1, max_length = 255)]
747 pub name: Option<compact_str::CompactString>,
748 #[garde(length(chars, min = 1, max = 1024))]
749 #[schema(min_length = 1, max_length = 1024)]
750 #[serde(
751 default,
752 skip_serializing_if = "Option::is_none",
753 with = "::serde_with::rust::double_option"
754 )]
755 pub description: Option<Option<compact_str::CompactString>>,
756 #[garde(skip)]
757 pub maintenance_enabled: Option<bool>,
758 #[garde(skip)]
759 pub shared: Option<bool>,
760 #[garde(skip)]
761 pub backup_disk: Option<super::server_backup::BackupDisk>,
762 #[garde(dive)]
763 pub backup_configs: Option<BackupConfigs>,
764}
765
766#[async_trait::async_trait]
767impl UpdatableModel for BackupConfiguration {
768 type UpdateOptions = UpdateBackupConfigurationOptions;
769
770 fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
771 static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<BackupConfiguration>> =
772 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
773
774 &UPDATE_LISTENERS
775 }
776
777 async fn update_with_transaction(
778 &mut self,
779 state: &crate::State,
780 mut options: Self::UpdateOptions,
781 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
782 ) -> Result<(), crate::database::DatabaseError> {
783 options.validate()?;
784
785 let mut query_builder = UpdateQueryBuilder::new("backup_configurations");
786
787 self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
788 .await?;
789
790 query_builder
791 .set("name", options.name.as_ref())
792 .set(
793 "description",
794 options.description.as_ref().map(|d| d.as_ref()),
795 )
796 .set("maintenance_enabled", options.maintenance_enabled)
797 .set("shared", options.shared)
798 .set("backup_disk", options.backup_disk)
799 .set(
800 "backup_configs",
801 if let Some(backup_configs) = &mut options.backup_configs {
802 backup_configs.encrypt(&state.database).await?;
803
804 Some(serde_json::to_value(backup_configs)?)
805 } else {
806 None
807 },
808 )
809 .where_eq("uuid", self.uuid);
810
811 query_builder.execute(&mut **transaction).await?;
812
813 if let Some(name) = options.name {
814 self.name = name;
815 }
816 if let Some(description) = options.description {
817 self.description = description;
818 }
819 if let Some(maintenance_enabled) = options.maintenance_enabled {
820 self.maintenance_enabled = maintenance_enabled;
821 }
822 if let Some(shared) = options.shared {
823 self.shared = shared;
824 }
825 if let Some(backup_disk) = options.backup_disk {
826 self.backup_disk = backup_disk;
827 }
828 if let Some(backup_configs) = options.backup_configs {
829 self.backup_configs = backup_configs;
830 }
831
832 self.run_after_update_handlers(state, transaction).await?;
833
834 Ok(())
835 }
836}
837
838#[async_trait::async_trait]
839impl DeletableModel for BackupConfiguration {
840 type DeleteOptions = ();
841
842 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
843 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<BackupConfiguration>> =
844 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
845
846 &DELETE_LISTENERS
847 }
848
849 async fn delete_with_transaction(
850 &self,
851 state: &crate::State,
852 options: Self::DeleteOptions,
853 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
854 ) -> Result<(), anyhow::Error> {
855 self.run_delete_handlers(&options, state, transaction)
856 .await?;
857
858 sqlx::query(
859 r#"
860 DELETE FROM backup_configurations
861 WHERE backup_configurations.uuid = $1
862 "#,
863 )
864 .bind(self.uuid)
865 .execute(&mut **transaction)
866 .await?;
867
868 self.run_after_delete_handlers(&options, state, transaction)
869 .await?;
870
871 Ok(())
872 }
873}
874
875#[schema_extension_derive::extendible]
876#[init_args(BackupConfiguration, crate::State)]
877#[hook_args(crate::State)]
878#[derive(ToSchema, Serialize)]
879#[schema(title = "BackupConfiguration")]
880pub struct AdminApiBackupConfiguration {
881 pub uuid: uuid::Uuid,
882
883 pub name: compact_str::CompactString,
884 pub description: Option<compact_str::CompactString>,
885
886 pub maintenance_enabled: bool,
887 pub shared: bool,
888
889 pub backup_disk: super::server_backup::BackupDisk,
890 pub backup_configs: BackupConfigs,
891
892 pub created: chrono::DateTime<chrono::Utc>,
893}