1use crate::{
2 models::{InsertQueryBuilder, UpdateQueryBuilder},
3 prelude::*,
4};
5use compact_str::ToCompactString;
6use futures_util::StreamExt;
7use garde::Validate;
8use serde::{Deserialize, Serialize};
9use sqlx::{Row, postgres::PgRow};
10use std::{
11 collections::BTreeMap,
12 path::PathBuf,
13 sync::{Arc, LazyLock},
14};
15use utoipa::ToSchema;
16
17fn validate_git_repository(
18 git_repository: &compact_str::CompactString,
19 _context: &(),
20) -> Result<(), garde::Error> {
21 let url = match gix::url::parse(git_repository.as_str()) {
22 Ok(url) => url,
23 Err(err) => return Err(garde::Error::new(format!("Invalid git repository: {err}"))),
24 };
25
26 match url.scheme {
27 gix::url::Scheme::Http | gix::url::Scheme::Https | gix::url::Scheme::Ssh => {}
28 _ => {
29 return Err(garde::Error::new(
30 "Invalid git repository: only http, https and ssh urls are supported, scp-style urls have to be written as ssh://user@host/path",
31 ));
32 }
33 }
34
35 if url.host().is_none() {
36 return Err(garde::Error::new("Invalid git repository: missing host"));
37 }
38
39 Ok(())
40}
41
42#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
43#[serde(tag = "type", rename_all = "snake_case")]
44pub enum EggRepositoryCredentials {
45 None,
46 Password {
47 #[garde(length(chars, min = 1, max = 255))]
48 #[schema(min_length = 1, max_length = 255)]
49 username: compact_str::CompactString,
50 #[garde(length(chars, min = 1, max = 255))]
51 #[schema(min_length = 1, max_length = 255)]
52 password: compact_str::CompactString,
53 },
54 PrivateKey {
55 #[garde(length(chars, min = 1, max = 255))]
56 #[schema(min_length = 1, max_length = 255)]
57 username: compact_str::CompactString,
58 #[garde(
59 length(chars, min = 1, max = 16384),
60 custom(crate::git::validate_private_key)
61 )]
62 #[schema(min_length = 1, max_length = 16384)]
63 private_key: String,
64 #[garde(inner(length(chars, min = 1, max = 255)))]
65 #[schema(min_length = 1, max_length = 255)]
66 passphrase: Option<compact_str::CompactString>,
67 },
68}
69
70impl EggRepositoryCredentials {
71 fn validate_scheme_for_url(&self, git_repository: &str) -> Result<(), anyhow::Error> {
72 let scheme = gix::url::parse(git_repository)
73 .map(|url| url.scheme)
74 .unwrap_or(gix::url::Scheme::Https);
75
76 match self {
77 EggRepositoryCredentials::None => {}
78 EggRepositoryCredentials::Password { .. } => {
79 if scheme == gix::url::Scheme::Http {
80 return Err(crate::response::DisplayError::new(
81 "password credentials cannot be sent over plain http, use an https repository url",
82 )
83 .into());
84 }
85 }
86 EggRepositoryCredentials::PrivateKey { .. } => {
87 if scheme != gix::url::Scheme::Ssh {
88 return Err(crate::response::DisplayError::new(
89 "private key credentials can only be used with ssh repositories",
90 )
91 .into());
92 }
93 }
94 }
95
96 Ok(())
97 }
98
99 fn validate_key_material(&self) -> Result<(), anyhow::Error> {
100 if let EggRepositoryCredentials::PrivateKey {
101 private_key,
102 passphrase,
103 ..
104 } = self
105 {
106 crate::git::parse_private_key(private_key, passphrase.as_deref()).map_err(|err| {
107 crate::response::DisplayError::new(format!("private key is unusable: {err}"))
108 })?;
109 }
110
111 Ok(())
112 }
113
114 pub async fn encrypt(
115 &mut self,
116 database: &crate::database::Database,
117 ) -> Result<(), anyhow::Error> {
118 match self {
119 EggRepositoryCredentials::None => {}
120 EggRepositoryCredentials::Password { password, .. } => {
121 *password = database.encrypt_base64(password.clone()).await?;
122 }
123 EggRepositoryCredentials::PrivateKey {
124 private_key,
125 passphrase,
126 ..
127 } => {
128 *private_key = database.encrypt_base64(private_key.clone()).await?.into();
129
130 if let Some(passphrase) = passphrase {
131 *passphrase = database.encrypt_base64(passphrase.clone()).await?;
132 }
133 }
134 }
135
136 Ok(())
137 }
138
139 pub async fn decrypt(
140 &mut self,
141 database: &crate::database::Database,
142 ) -> Result<(), anyhow::Error> {
143 match self {
144 EggRepositoryCredentials::None => {}
145 EggRepositoryCredentials::Password { password, .. } => {
146 if let Some(decrypted) = database.decrypt_base64_optional(&password).await? {
147 *password = decrypted;
148 }
149 }
150 EggRepositoryCredentials::PrivateKey {
151 private_key,
152 passphrase,
153 ..
154 } => {
155 if let Some(decrypted) = database.decrypt_base64_optional(&private_key).await? {
156 *private_key = decrypted.into();
157 }
158
159 if let Some(passphrase) = passphrase
160 && let Some(decrypted) = database.decrypt_base64_optional(&passphrase).await?
161 {
162 *passphrase = decrypted;
163 }
164 }
165 }
166
167 Ok(())
168 }
169
170 pub fn censor(&mut self) {
171 match self {
172 EggRepositoryCredentials::None => {}
173 EggRepositoryCredentials::Password { password, .. } => {
174 *password = "".into();
175 }
176 EggRepositoryCredentials::PrivateKey {
177 private_key,
178 passphrase,
179 ..
180 } => {
181 *private_key = "".into();
182
183 if let Some(passphrase) = passphrase {
184 *passphrase = "".into();
185 }
186 }
187 }
188 }
189}
190
191impl From<EggRepositoryCredentials> for crate::git::GitCredentials {
192 fn from(value: EggRepositoryCredentials) -> Self {
193 match value {
194 EggRepositoryCredentials::None => Self::None,
195 EggRepositoryCredentials::Password { username, password } => {
196 Self::Password { username, password }
197 }
198 EggRepositoryCredentials::PrivateKey {
199 username,
200 private_key,
201 passphrase,
202 } => Self::PrivateKey {
203 username,
204 private_key,
205 passphrase,
206 },
207 }
208 }
209}
210
211#[derive(Serialize, Deserialize, Clone)]
212pub struct EggRepository {
213 pub uuid: uuid::Uuid,
214
215 pub name: compact_str::CompactString,
216 pub description: Option<compact_str::CompactString>,
217 pub git_repository: compact_str::CompactString,
218 pub credentials: EggRepositoryCredentials,
219
220 pub last_synced: Option<chrono::NaiveDateTime>,
221 pub created: chrono::NaiveDateTime,
222
223 extension_data: super::ModelExtensionData,
224}
225
226impl BaseModel for EggRepository {
227 const NAME: &'static str = "egg_repository";
228
229 fn get_extension_list() -> &'static super::ModelExtensionList {
230 static EXTENSIONS: LazyLock<super::ModelExtensionList> =
231 LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
232
233 &EXTENSIONS
234 }
235
236 fn get_extension_data(&self) -> &super::ModelExtensionData {
237 &self.extension_data
238 }
239
240 #[inline]
241 fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
242 let prefix = prefix.unwrap_or_default();
243
244 BTreeMap::from([
245 (
246 "egg_repositories.uuid",
247 compact_str::format_compact!("{prefix}uuid"),
248 ),
249 (
250 "egg_repositories.name",
251 compact_str::format_compact!("{prefix}name"),
252 ),
253 (
254 "egg_repositories.description",
255 compact_str::format_compact!("{prefix}description"),
256 ),
257 (
258 "egg_repositories.git_repository",
259 compact_str::format_compact!("{prefix}git_repository"),
260 ),
261 (
262 "egg_repositories.credentials",
263 compact_str::format_compact!("{prefix}credentials"),
264 ),
265 (
266 "egg_repositories.last_synced",
267 compact_str::format_compact!("{prefix}last_synced"),
268 ),
269 (
270 "egg_repositories.created",
271 compact_str::format_compact!("{prefix}created"),
272 ),
273 ])
274 }
275
276 #[inline]
277 fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
278 let prefix = prefix.unwrap_or_default();
279
280 Ok(Self {
281 uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
282 name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
283 description: row
284 .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
285 git_repository: row
286 .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
287 credentials: serde_json::from_value(
288 row.try_get(compact_str::format_compact!("{prefix}credentials").as_str())?,
289 )?,
290 last_synced: row
291 .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
292 created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
293 extension_data: Self::map_extensions(prefix, row)?,
294 })
295 }
296}
297
298impl EggRepository {
299 pub async fn all_with_pagination(
300 database: &crate::database::Database,
301 page: i64,
302 per_page: i64,
303 search: Option<&str>,
304 ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
305 let offset = (page - 1) * per_page;
306
307 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
308 r#"
309 SELECT {}, COUNT(*) OVER() AS total_count
310 FROM egg_repositories
311 WHERE ($1 IS NULL OR egg_repositories.name ILIKE '%' || $1 || '%')
312 ORDER BY egg_repositories.created
313 LIMIT $2 OFFSET $3
314 "#,
315 Self::columns_sql(None)
316 )))
317 .bind(search)
318 .bind(per_page)
319 .bind(offset)
320 .fetch_all(database.read())
321 .await?;
322
323 Ok(super::Pagination {
324 total: rows
325 .first()
326 .map_or(Ok(0), |row| row.try_get("total_count"))?,
327 per_page,
328 page,
329 data: rows
330 .into_iter()
331 .map(|row| Self::map(None, &row))
332 .try_collect_vec()?,
333 })
334 }
335
336 pub async fn sync(&self, state: &crate::State) -> Result<usize, anyhow::Error> {
337 let git_repository = self.git_repository.clone();
338
339 let mut credentials = self.credentials.clone();
340 credentials.decrypt(&state.database).await?;
341
342 let url = gix::url::parse(git_repository.as_str())?;
343 crate::git::resolve_addresses(&state.env, &url).await?;
344
345 let env = Arc::clone(&state.env);
346
347 struct FoundEgg {
348 path: PathBuf,
349 readme: Option<String>,
350 exported_egg: super::nest_egg::ExportedNestEgg,
351 updated: chrono::DateTime<chrono::Utc>,
352 }
353
354 let exported_eggs =
355 tokio::task::spawn_blocking(move || -> Result<Vec<FoundEgg>, anyhow::Error> {
356 let mut exported_eggs = Vec::new();
357 let temp_dir = tempfile::tempdir()?;
358 let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
359
360 let mut prepare_fetch = gix::clone::PrepareFetch::new(
361 url.clone(),
362 temp_dir.path(),
363 gix::create::Kind::WithWorktree,
364 Default::default(),
365 gix::open::Options::default().config_overrides(["credential.helper="]),
366 )?
367 .configure_connection(
368 crate::git::GitCredentials::from(credentials)
369 .into_connection_configurator(env, url),
370 );
371
372 let (mut prepare_checkout, _) = prepare_fetch
373 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
374 let _ = prepare_checkout
375 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
376
377 tracing::info!(
378 "cloned egg repository {} to temporary directory",
379 git_repository
380 );
381
382 let mut walker = filesystem.walk_dir(".")?;
383 while let Some(Ok((is_dir, entry))) = walker.next_entry() {
384 if is_dir
385 || !matches!(
386 entry.extension().and_then(|s| s.to_str()),
387 Some("json") | Some("yml") | Some("yaml")
388 )
389 {
390 continue;
391 }
392
393 let metadata = match filesystem.metadata(&entry) {
394 Ok(metadata) => metadata,
395 Err(_) => continue,
396 };
397
398 if !metadata.is_file()
399 || metadata.len() > super::nest_egg::NestEgg::MAX_EXPORTED_SIZE as u64
400 {
401 continue;
402 }
403
404 let file_content = match filesystem.read_to_string(&entry) {
405 Ok(content) => content,
406 Err(_) => continue,
407 };
408 let exported_egg: super::nest_egg::ExportedNestEgg =
409 if entry.extension().and_then(|s| s.to_str()) == Some("json") {
410 match serde_json::from_str(file_content.trim()) {
411 Ok(egg) => egg,
412 Err(_) => continue,
413 }
414 } else {
415 match serde_norway::from_str(file_content.trim()) {
416 Ok(egg) => egg,
417 Err(_) => continue,
418 }
419 };
420
421 let mut readme = None;
422 let mut current_path = entry.parent();
423 'readme: while let Some(path) = current_path {
424 let mut dir = filesystem.read_dir(path)?;
425
426 while let Some(Ok((is_dir, entry))) = dir.next_entry() {
427 if is_dir {
428 continue;
429 }
430
431 let path = path.join(&entry);
432
433 if entry.to_lowercase().contains("readme")
434 && filesystem
435 .metadata(&path)
436 .is_ok_and(|m| m.is_file() && m.len() <= 1024 * 1024)
437 && let Ok(content) = filesystem.read_to_string(&path)
438 {
439 readme = Some(content);
440 break 'readme;
441 }
442 }
443
444 current_path = path.parent();
445 }
446
447 exported_eggs.push(FoundEgg {
448 path: entry,
449 readme,
450 exported_egg,
451 updated: chrono::DateTime::from_timestamp(
452 metadata
453 .modified()
454 .map_or_else(|_| std::time::SystemTime::now(), |t| t.into_std())
455 .duration_since(std::time::UNIX_EPOCH)
456 .unwrap_or_default()
457 .as_secs() as i64,
458 0,
459 )
460 .unwrap_or_else(chrono::Utc::now),
461 });
462 }
463
464 drop(prepare_fetch);
465
466 Ok(exported_eggs)
467 })
468 .await??;
469
470 super::egg_repository_egg::EggRepositoryEgg::delete_unused(
471 &state.database,
472 self.uuid,
473 &exported_eggs
474 .iter()
475 .map(|egg| egg.path.to_string_lossy().to_compact_string())
476 .collect::<Vec<_>>(),
477 )
478 .await?;
479
480 let mut futures = Vec::new();
481 futures.reserve_exact(exported_eggs.len());
482
483 for egg in exported_eggs.iter() {
484 futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
485 &state.database,
486 self.uuid,
487 egg.path.to_string_lossy(),
488 egg.readme.as_deref(),
489 &egg.exported_egg,
490 egg.updated.naive_utc(),
491 ));
492 }
493
494 let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
495 while let Some(result) = results_stream.next().await {
496 result?;
497 }
498
499 sqlx::query(
500 r#"
501 UPDATE egg_repositories
502 SET last_synced = NOW()
503 WHERE egg_repositories.uuid = $1
504 "#,
505 )
506 .bind(self.uuid)
507 .execute(state.database.write())
508 .await?;
509
510 Ok(exported_eggs.len())
511 }
512}
513
514#[async_trait::async_trait]
515impl IntoAdminApiObject for EggRepository {
516 type AdminApiObject = AdminApiEggRepository;
517 type ExtraArgs<'a> = ();
518
519 async fn into_admin_api_object<'a>(
520 mut self,
521 state: &crate::State,
522 _args: Self::ExtraArgs<'a>,
523 ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
524 let api_object = AdminApiEggRepository::init_hooks(&self, state).await?;
525
526 self.credentials.censor();
527
528 let api_object = finish_extendible!(
529 AdminApiEggRepository {
530 uuid: self.uuid,
531 name: self.name,
532 description: self.description,
533 git_repository: self.git_repository,
534 credentials: self.credentials,
535 last_synced: self.last_synced.map(|dt| dt.and_utc()),
536 created: self.created.and_utc(),
537 },
538 api_object,
539 state
540 )?;
541
542 Ok(api_object)
543 }
544}
545
546#[derive(ToSchema, Deserialize, Validate)]
547pub struct CreateEggRepositoryOptions {
548 #[garde(length(chars, min = 1, max = 255))]
549 #[schema(min_length = 1, max_length = 255)]
550 pub name: compact_str::CompactString,
551 #[garde(length(max = 1024))]
552 #[schema(max_length = 1024)]
553 pub description: Option<compact_str::CompactString>,
554 #[garde(custom(validate_git_repository))]
555 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
556 pub git_repository: compact_str::CompactString,
557 #[garde(dive)]
558 pub credentials: Option<EggRepositoryCredentials>,
559}
560
561#[async_trait::async_trait]
562impl CreatableModel for EggRepository {
563 type CreateOptions<'a> = CreateEggRepositoryOptions;
564 type CreateResult = Self;
565
566 fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
567 static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
568 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
569
570 &CREATE_LISTENERS
571 }
572
573 async fn create_with_transaction(
574 state: &crate::State,
575 mut options: Self::CreateOptions<'_>,
576 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
577 ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
578 options.validate()?;
579
580 let mut query_builder = InsertQueryBuilder::new("egg_repositories");
581
582 Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
583
584 let credentials = options
585 .credentials
586 .get_or_insert(EggRepositoryCredentials::None);
587 credentials.validate_scheme_for_url(&options.git_repository)?;
588 credentials.validate_key_material()?;
589 credentials.encrypt(&state.database).await?;
590
591 query_builder
592 .set("name", &options.name)
593 .set("description", &options.description)
594 .set("git_repository", &options.git_repository)
595 .set("credentials", serde_json::to_value(&credentials)?);
596
597 let row = query_builder
598 .returning(&Self::columns_sql(None))
599 .fetch_one(&mut **transaction)
600 .await?;
601 let mut egg_repository = Self::map(None, &row)?;
602
603 Self::run_after_create_handlers(&mut egg_repository, &options, state, transaction).await?;
604
605 Ok(egg_repository)
606 }
607}
608
609#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
610pub struct UpdateEggRepositoryOptions {
611 #[garde(length(chars, min = 1, max = 255))]
612 #[schema(min_length = 1, max_length = 255)]
613 pub name: Option<compact_str::CompactString>,
614 #[garde(length(max = 1024))]
615 #[schema(max_length = 1024)]
616 #[serde(
617 default,
618 skip_serializing_if = "Option::is_none",
619 with = "::serde_with::rust::double_option"
620 )]
621 pub description: Option<Option<compact_str::CompactString>>,
622 #[garde(inner(custom(validate_git_repository)))]
623 #[schema(example = "https://github.com/example/repo.git", format = "uri")]
624 pub git_repository: Option<compact_str::CompactString>,
625 #[garde(dive)]
626 pub credentials: Option<EggRepositoryCredentials>,
627}
628
629#[async_trait::async_trait]
630impl UpdatableModel for EggRepository {
631 type UpdateOptions = UpdateEggRepositoryOptions;
632
633 fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
634 static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggRepository>> =
635 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
636
637 &UPDATE_LISTENERS
638 }
639
640 async fn update_with_transaction(
641 &mut self,
642 state: &crate::State,
643 mut options: Self::UpdateOptions,
644 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
645 ) -> Result<(), crate::database::DatabaseError> {
646 options.validate()?;
647
648 let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
649
650 self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
651 .await?;
652
653 let git_repository = options
654 .git_repository
655 .as_deref()
656 .unwrap_or(&self.git_repository);
657
658 match &mut options.credentials {
659 Some(credentials) => {
660 credentials.validate_scheme_for_url(git_repository)?;
661 credentials.validate_key_material()?;
662 credentials.encrypt(&state.database).await?;
663 }
664 None => self.credentials.validate_scheme_for_url(git_repository)?,
665 }
666
667 query_builder
668 .set("name", options.name.as_ref())
669 .set(
670 "description",
671 options.description.as_ref().map(|d| d.as_ref()),
672 )
673 .set("git_repository", options.git_repository.as_ref())
674 .set(
675 "credentials",
676 options
677 .credentials
678 .as_ref()
679 .map(serde_json::to_value)
680 .transpose()?,
681 )
682 .where_eq("uuid", self.uuid);
683
684 query_builder.execute(&mut **transaction).await?;
685
686 if let Some(name) = options.name {
687 self.name = name;
688 }
689 if let Some(description) = options.description {
690 self.description = description;
691 }
692 if let Some(git_repository) = options.git_repository {
693 self.git_repository = git_repository;
694 }
695 if let Some(credentials) = options.credentials {
696 self.credentials = credentials;
697 }
698
699 self.run_after_update_handlers(state, transaction).await?;
700
701 Ok(())
702 }
703}
704
705#[async_trait::async_trait]
706impl ByUuid for EggRepository {
707 async fn by_uuid(
708 database: &crate::database::Database,
709 uuid: uuid::Uuid,
710 ) -> Result<Self, crate::database::DatabaseError> {
711 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
712 r#"
713 SELECT {}
714 FROM egg_repositories
715 WHERE egg_repositories.uuid = $1
716 "#,
717 Self::columns_sql(None)
718 )))
719 .bind(uuid)
720 .fetch_one(database.read())
721 .await?;
722
723 Self::map(None, &row)
724 }
725
726 async fn by_uuid_with_transaction(
727 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
728 uuid: uuid::Uuid,
729 ) -> Result<Self, crate::database::DatabaseError> {
730 let row = sqlx::query(sqlx::AssertSqlSafe(format!(
731 r#"
732 SELECT {}
733 FROM egg_repositories
734 WHERE egg_repositories.uuid = $1
735 "#,
736 Self::columns_sql(None)
737 )))
738 .bind(uuid)
739 .fetch_one(&mut **transaction)
740 .await?;
741
742 Self::map(None, &row)
743 }
744}
745
746#[async_trait::async_trait]
747impl DeletableModel for EggRepository {
748 type DeleteOptions = ();
749
750 fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
751 static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggRepository>> =
752 LazyLock::new(|| Arc::new(ModelHandlerList::default()));
753
754 &DELETE_LISTENERS
755 }
756
757 async fn delete_with_transaction(
758 &self,
759 state: &crate::State,
760 options: Self::DeleteOptions,
761 transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
762 ) -> Result<(), anyhow::Error> {
763 self.run_delete_handlers(&options, state, transaction)
764 .await?;
765
766 sqlx::query(
767 r#"
768 DELETE FROM egg_repositories
769 WHERE egg_repositories.uuid = $1
770 "#,
771 )
772 .bind(self.uuid)
773 .execute(&mut **transaction)
774 .await?;
775
776 self.run_after_delete_handlers(&options, state, transaction)
777 .await?;
778
779 Ok(())
780 }
781}
782
783#[schema_extension_derive::extendible]
784#[init_args(EggRepository, crate::State)]
785#[hook_args(crate::State)]
786#[derive(ToSchema, Serialize)]
787#[schema(title = "EggRepository")]
788pub struct AdminApiEggRepository {
789 pub uuid: uuid::Uuid,
790
791 pub name: compact_str::CompactString,
792 pub description: Option<compact_str::CompactString>,
793 pub git_repository: compact_str::CompactString,
794 pub credentials: EggRepositoryCredentials,
795
796 pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
797 pub created: chrono::DateTime<chrono::Utc>,
798}