Skip to main content

shared/models/
egg_repository.rs

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, database: &crate::database::Database) -> Result<usize, anyhow::Error> {
337        let git_repository = self.git_repository.clone();
338
339        let mut credentials = self.credentials.clone();
340        credentials.decrypt(database).await?;
341
342        struct FoundEgg {
343            path: PathBuf,
344            readme: Option<String>,
345            exported_egg: super::nest_egg::ExportedNestEgg,
346            updated: chrono::DateTime<chrono::Utc>,
347        }
348
349        let exported_eggs =
350            tokio::task::spawn_blocking(move || -> Result<Vec<FoundEgg>, anyhow::Error> {
351                let mut exported_eggs = Vec::new();
352                let temp_dir = tempfile::tempdir()?;
353                let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
354
355                let url = gix::url::parse(git_repository.as_str())?;
356
357                let mut prepare_fetch = gix::clone::PrepareFetch::new(
358                    url.clone(),
359                    temp_dir.path(),
360                    gix::create::Kind::WithWorktree,
361                    Default::default(),
362                    gix::open::Options::default().config_overrides(["credential.helper="]),
363                )?
364                .configure_connection(
365                    crate::git::GitCredentials::from(credentials).into_connection_configurator(url),
366                );
367
368                let (mut prepare_checkout, _) = prepare_fetch
369                    .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
370                let _ = prepare_checkout
371                    .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?;
372
373                tracing::info!(
374                    "cloned egg repository {} to temporary directory",
375                    git_repository
376                );
377
378                let mut walker = filesystem.walk_dir(".")?;
379                while let Some(Ok((is_dir, entry))) = walker.next_entry() {
380                    if is_dir
381                        || !matches!(
382                            entry.extension().and_then(|s| s.to_str()),
383                            Some("json") | Some("yml") | Some("yaml")
384                        )
385                    {
386                        continue;
387                    }
388
389                    let metadata = match filesystem.metadata(&entry) {
390                        Ok(metadata) => metadata,
391                        Err(_) => continue,
392                    };
393
394                    // if any egg is larger than 1 MiB, something went horribly wrong in development
395                    if !metadata.is_file() || metadata.len() > 1024 * 1024 {
396                        continue;
397                    }
398
399                    let file_content = match filesystem.read_to_string(&entry) {
400                        Ok(content) => content,
401                        Err(_) => continue,
402                    };
403                    let exported_egg: super::nest_egg::ExportedNestEgg =
404                        if entry.extension().and_then(|s| s.to_str()) == Some("json") {
405                            match serde_json::from_str(&file_content) {
406                                Ok(egg) => egg,
407                                Err(_) => continue,
408                            }
409                        } else {
410                            match serde_norway::from_str(&file_content) {
411                                Ok(egg) => egg,
412                                Err(_) => continue,
413                            }
414                        };
415
416                    let mut readme = None;
417                    let mut current_path = entry.parent();
418                    'readme: while let Some(path) = current_path {
419                        let mut dir = filesystem.read_dir(path)?;
420
421                        while let Some(Ok((is_dir, entry))) = dir.next_entry() {
422                            if is_dir {
423                                continue;
424                            }
425
426                            let path = path.join(&entry);
427
428                            if entry.to_lowercase().contains("readme")
429                                && filesystem
430                                    .metadata(&path)
431                                    .is_ok_and(|m| m.is_file() && m.len() <= 1024 * 1024)
432                                && let Ok(content) = filesystem.read_to_string(&path)
433                            {
434                                readme = Some(content);
435                                break 'readme;
436                            }
437                        }
438
439                        current_path = path.parent();
440                    }
441
442                    exported_eggs.push(FoundEgg {
443                        path: entry,
444                        readme,
445                        exported_egg,
446                        updated: chrono::DateTime::from_timestamp(
447                            metadata
448                                .modified()
449                                .map_or_else(|_| std::time::SystemTime::now(), |t| t.into_std())
450                                .duration_since(std::time::UNIX_EPOCH)
451                                .unwrap_or_default()
452                                .as_secs() as i64,
453                            0,
454                        )
455                        .unwrap_or_else(chrono::Utc::now),
456                    });
457                }
458
459                drop(prepare_fetch);
460
461                Ok(exported_eggs)
462            })
463            .await??;
464
465        super::egg_repository_egg::EggRepositoryEgg::delete_unused(
466            database,
467            self.uuid,
468            &exported_eggs
469                .iter()
470                .map(|egg| egg.path.to_string_lossy().to_compact_string())
471                .collect::<Vec<_>>(),
472        )
473        .await?;
474
475        let mut futures = Vec::new();
476        futures.reserve_exact(exported_eggs.len());
477
478        for egg in exported_eggs.iter() {
479            futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
480                database,
481                self.uuid,
482                egg.path.to_string_lossy(),
483                egg.readme.as_deref(),
484                &egg.exported_egg,
485                egg.updated.naive_utc(),
486            ));
487        }
488
489        let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
490        while let Some(result) = results_stream.next().await {
491            result?;
492        }
493
494        sqlx::query(
495            r#"
496            UPDATE egg_repositories
497            SET last_synced = NOW()
498            WHERE egg_repositories.uuid = $1
499            "#,
500        )
501        .bind(self.uuid)
502        .execute(database.write())
503        .await?;
504
505        Ok(exported_eggs.len())
506    }
507}
508
509#[async_trait::async_trait]
510impl IntoAdminApiObject for EggRepository {
511    type AdminApiObject = AdminApiEggRepository;
512    type ExtraArgs<'a> = ();
513
514    async fn into_admin_api_object<'a>(
515        mut self,
516        state: &crate::State,
517        _args: Self::ExtraArgs<'a>,
518    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
519        let api_object = AdminApiEggRepository::init_hooks(&self, state).await?;
520
521        self.credentials.censor();
522
523        let api_object = finish_extendible!(
524            AdminApiEggRepository {
525                uuid: self.uuid,
526                name: self.name,
527                description: self.description,
528                git_repository: self.git_repository,
529                credentials: self.credentials,
530                last_synced: self.last_synced.map(|dt| dt.and_utc()),
531                created: self.created.and_utc(),
532            },
533            api_object,
534            state
535        )?;
536
537        Ok(api_object)
538    }
539}
540
541#[derive(ToSchema, Deserialize, Validate)]
542pub struct CreateEggRepositoryOptions {
543    #[garde(length(chars, min = 1, max = 255))]
544    #[schema(min_length = 1, max_length = 255)]
545    pub name: compact_str::CompactString,
546    #[garde(length(max = 1024))]
547    #[schema(max_length = 1024)]
548    pub description: Option<compact_str::CompactString>,
549    #[garde(custom(validate_git_repository))]
550    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
551    pub git_repository: compact_str::CompactString,
552    #[garde(dive)]
553    pub credentials: Option<EggRepositoryCredentials>,
554}
555
556#[async_trait::async_trait]
557impl CreatableModel for EggRepository {
558    type CreateOptions<'a> = CreateEggRepositoryOptions;
559    type CreateResult = Self;
560
561    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
562        static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
563            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
564
565        &CREATE_LISTENERS
566    }
567
568    async fn create_with_transaction(
569        state: &crate::State,
570        mut options: Self::CreateOptions<'_>,
571        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
572    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
573        options.validate()?;
574
575        let mut query_builder = InsertQueryBuilder::new("egg_repositories");
576
577        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
578
579        let credentials = options
580            .credentials
581            .get_or_insert(EggRepositoryCredentials::None);
582        credentials.validate_scheme_for_url(&options.git_repository)?;
583        credentials.validate_key_material()?;
584        credentials.encrypt(&state.database).await?;
585
586        query_builder
587            .set("name", &options.name)
588            .set("description", &options.description)
589            .set("git_repository", &options.git_repository)
590            .set("credentials", serde_json::to_value(&credentials)?);
591
592        let row = query_builder
593            .returning(&Self::columns_sql(None))
594            .fetch_one(&mut **transaction)
595            .await?;
596        let mut egg_repository = Self::map(None, &row)?;
597
598        Self::run_after_create_handlers(&mut egg_repository, &options, state, transaction).await?;
599
600        Ok(egg_repository)
601    }
602}
603
604#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
605pub struct UpdateEggRepositoryOptions {
606    #[garde(length(chars, min = 1, max = 255))]
607    #[schema(min_length = 1, max_length = 255)]
608    pub name: Option<compact_str::CompactString>,
609    #[garde(length(max = 1024))]
610    #[schema(max_length = 1024)]
611    #[serde(
612        default,
613        skip_serializing_if = "Option::is_none",
614        with = "::serde_with::rust::double_option"
615    )]
616    pub description: Option<Option<compact_str::CompactString>>,
617    #[garde(inner(custom(validate_git_repository)))]
618    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
619    pub git_repository: Option<compact_str::CompactString>,
620    #[garde(dive)]
621    pub credentials: Option<EggRepositoryCredentials>,
622}
623
624#[async_trait::async_trait]
625impl UpdatableModel for EggRepository {
626    type UpdateOptions = UpdateEggRepositoryOptions;
627
628    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
629        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggRepository>> =
630            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
631
632        &UPDATE_LISTENERS
633    }
634
635    async fn update_with_transaction(
636        &mut self,
637        state: &crate::State,
638        mut options: Self::UpdateOptions,
639        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
640    ) -> Result<(), crate::database::DatabaseError> {
641        options.validate()?;
642
643        let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
644
645        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
646            .await?;
647
648        let git_repository = options
649            .git_repository
650            .as_deref()
651            .unwrap_or(&self.git_repository);
652
653        match &mut options.credentials {
654            Some(credentials) => {
655                credentials.validate_scheme_for_url(git_repository)?;
656                credentials.validate_key_material()?;
657                credentials.encrypt(&state.database).await?;
658            }
659            None => self.credentials.validate_scheme_for_url(git_repository)?,
660        }
661
662        query_builder
663            .set("name", options.name.as_ref())
664            .set(
665                "description",
666                options.description.as_ref().map(|d| d.as_ref()),
667            )
668            .set("git_repository", options.git_repository.as_ref())
669            .set(
670                "credentials",
671                options
672                    .credentials
673                    .as_ref()
674                    .map(serde_json::to_value)
675                    .transpose()?,
676            )
677            .where_eq("uuid", self.uuid);
678
679        query_builder.execute(&mut **transaction).await?;
680
681        if let Some(name) = options.name {
682            self.name = name;
683        }
684        if let Some(description) = options.description {
685            self.description = description;
686        }
687        if let Some(git_repository) = options.git_repository {
688            self.git_repository = git_repository;
689        }
690        if let Some(credentials) = options.credentials {
691            self.credentials = credentials;
692        }
693
694        self.run_after_update_handlers(state, transaction).await?;
695
696        Ok(())
697    }
698}
699
700#[async_trait::async_trait]
701impl ByUuid for EggRepository {
702    async fn by_uuid(
703        database: &crate::database::Database,
704        uuid: uuid::Uuid,
705    ) -> Result<Self, crate::database::DatabaseError> {
706        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
707            r#"
708            SELECT {}
709            FROM egg_repositories
710            WHERE egg_repositories.uuid = $1
711            "#,
712            Self::columns_sql(None)
713        )))
714        .bind(uuid)
715        .fetch_one(database.read())
716        .await?;
717
718        Self::map(None, &row)
719    }
720
721    async fn by_uuid_with_transaction(
722        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
723        uuid: uuid::Uuid,
724    ) -> Result<Self, crate::database::DatabaseError> {
725        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
726            r#"
727            SELECT {}
728            FROM egg_repositories
729            WHERE egg_repositories.uuid = $1
730            "#,
731            Self::columns_sql(None)
732        )))
733        .bind(uuid)
734        .fetch_one(&mut **transaction)
735        .await?;
736
737        Self::map(None, &row)
738    }
739}
740
741#[async_trait::async_trait]
742impl DeletableModel for EggRepository {
743    type DeleteOptions = ();
744
745    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
746        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggRepository>> =
747            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
748
749        &DELETE_LISTENERS
750    }
751
752    async fn delete_with_transaction(
753        &self,
754        state: &crate::State,
755        options: Self::DeleteOptions,
756        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
757    ) -> Result<(), anyhow::Error> {
758        self.run_delete_handlers(&options, state, transaction)
759            .await?;
760
761        sqlx::query(
762            r#"
763            DELETE FROM egg_repositories
764            WHERE egg_repositories.uuid = $1
765            "#,
766        )
767        .bind(self.uuid)
768        .execute(&mut **transaction)
769        .await?;
770
771        self.run_after_delete_handlers(&options, state, transaction)
772            .await?;
773
774        Ok(())
775    }
776}
777
778#[schema_extension_derive::extendible]
779#[init_args(EggRepository, crate::State)]
780#[hook_args(crate::State)]
781#[derive(ToSchema, Serialize)]
782#[schema(title = "EggRepository")]
783pub struct AdminApiEggRepository {
784    pub uuid: uuid::Uuid,
785
786    pub name: compact_str::CompactString,
787    pub description: Option<compact_str::CompactString>,
788    pub git_repository: compact_str::CompactString,
789    pub credentials: EggRepositoryCredentials,
790
791    pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
792    pub created: chrono::DateTime<chrono::Utc>,
793}