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