Skip to main content

shared/models/
nest_egg.rs

1use crate::{
2    models::{
3        InsertQueryBuilder, UpdateQueryBuilder, nest_egg_variable::CreateNestEggVariableOptions,
4    },
5    prelude::*,
6};
7use futures_util::TryStreamExt;
8use garde::Validate;
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11use sqlx::{Row, postgres::PgRow};
12use std::{
13    collections::{BTreeMap, HashSet},
14    sync::{Arc, LazyLock},
15};
16use utoipa::ToSchema;
17
18pub fn validate_startup_commands(
19    startup_commands: &IndexMap<compact_str::CompactString, compact_str::CompactString>,
20    _context: &(),
21) -> Result<(), garde::Error> {
22    if startup_commands.is_empty() {
23        return Err(garde::Error::new(compact_str::format_compact!(
24            "at least one startup command is required"
25        )));
26    }
27
28    let mut seen_commands = HashSet::new();
29    for command in startup_commands.values() {
30        if !seen_commands.insert(command) {
31            return Err(garde::Error::new(compact_str::format_compact!(
32                "duplicate startup command: {}",
33                command
34            )));
35        }
36    }
37
38    Ok(())
39}
40
41pub fn validate_docker_images(
42    docker_images: &IndexMap<compact_str::CompactString, compact_str::CompactString>,
43    _context: &(),
44) -> Result<(), garde::Error> {
45    let mut seen_images = HashSet::new();
46    for image in docker_images.values() {
47        if !seen_images.insert(image) {
48            return Err(garde::Error::new(compact_str::format_compact!(
49                "duplicate docker image: {}",
50                image
51            )));
52        }
53    }
54
55    Ok(())
56}
57
58fn true_fn() -> bool {
59    true
60}
61
62#[derive(ToSchema, Serialize, Deserialize, Clone, Copy)]
63#[serde(rename_all = "snake_case")]
64pub enum ServerConfigurationFileParser {
65    File,
66    Yaml,
67    Properties,
68    Ini,
69    Json,
70    Xml,
71    Toml,
72}
73
74#[derive(ToSchema, Serialize, Deserialize, Clone)]
75pub struct ProcessConfigurationFileReplacement {
76    pub r#match: compact_str::CompactString,
77    #[serde(default)]
78    pub insert_new: bool,
79    #[serde(default = "true_fn")]
80    pub update_existing: bool,
81    pub if_value: Option<compact_str::CompactString>,
82    pub replace_with: serde_json::Value,
83}
84
85#[derive(ToSchema, Serialize, Deserialize, Clone)]
86pub struct ProcessConfigurationFile {
87    pub file: compact_str::CompactString,
88    #[serde(default = "true_fn")]
89    pub create_new: bool,
90    #[schema(inline)]
91    pub parser: ServerConfigurationFileParser,
92    #[schema(inline)]
93    pub replace: Vec<ProcessConfigurationFileReplacement>,
94}
95
96#[derive(ToSchema, Serialize, Clone)]
97pub struct ProcessConfiguration {
98    #[schema(inline)]
99    pub startup: crate::models::nest_egg::NestEggConfigStartup,
100    #[schema(inline)]
101    pub stop: crate::models::nest_egg::NestEggConfigStop,
102    #[schema(inline)]
103    pub configs: Vec<ProcessConfigurationFile>,
104}
105
106#[derive(ToSchema, Serialize, Deserialize, Clone, Default)]
107pub struct NestEggConfigStartup {
108    #[serde(
109        default,
110        deserialize_with = "crate::deserialize::deserialize_array_or_not"
111    )]
112    pub done: Vec<compact_str::CompactString>,
113    #[serde(default)]
114    pub strip_ansi: bool,
115}
116
117#[derive(ToSchema, Serialize, Deserialize, Clone, Default)]
118pub struct NestEggConfigStop {
119    pub r#type: compact_str::CompactString,
120    pub value: Option<compact_str::CompactString>,
121}
122
123#[derive(ToSchema, Serialize, Deserialize, Clone)]
124pub struct NestEggConfigScript {
125    pub container: compact_str::CompactString,
126    pub entrypoint: compact_str::CompactString,
127    #[serde(
128        alias = "script",
129        deserialize_with = "crate::deserialize::deserialize_defaultable"
130    )]
131    pub content: String,
132}
133
134#[derive(ToSchema, Serialize, Deserialize, Clone)]
135pub struct ExportedNestEggConfigsFilesFile {
136    #[serde(default = "true_fn", alias = "create_file")]
137    pub create_new: bool,
138    #[schema(inline)]
139    pub parser: ServerConfigurationFileParser,
140    #[schema(inline)]
141    pub replace: Vec<ProcessConfigurationFileReplacement>,
142}
143
144#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
145pub struct ExportedNestEggConfigs {
146    #[garde(skip)]
147    #[schema(inline)]
148    #[serde(
149        default,
150        deserialize_with = "crate::deserialize::deserialize_nest_egg_config_files"
151    )]
152    pub files: IndexMap<compact_str::CompactString, ExportedNestEggConfigsFilesFile>,
153    #[garde(skip)]
154    #[schema(inline)]
155    #[serde(
156        default,
157        deserialize_with = "crate::deserialize::deserialize_pre_stringified"
158    )]
159    pub startup: NestEggConfigStartup,
160    #[garde(skip)]
161    #[schema(inline)]
162    #[serde(
163        default,
164        deserialize_with = "crate::deserialize::deserialize_nest_egg_config_stop"
165    )]
166    pub stop: NestEggConfigStop,
167}
168
169#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
170pub struct ExportedNestEggScripts {
171    #[garde(skip)]
172    #[schema(inline)]
173    pub installation: NestEggConfigScript,
174}
175
176#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
177pub struct ExportedNestEgg {
178    #[garde(skip)]
179    #[serde(default = "uuid::Uuid::new_v4")]
180    pub uuid: uuid::Uuid,
181    #[garde(length(chars, min = 1, max = 255))]
182    #[schema(min_length = 1, max_length = 255)]
183    pub name: compact_str::CompactString,
184    #[garde(length(max = 1024))]
185    #[schema(max_length = 1024)]
186    #[serde(deserialize_with = "crate::deserialize::deserialize_string_option")]
187    pub description: Option<compact_str::CompactString>,
188    #[garde(length(chars, min = 2, max = 255))]
189    #[schema(min_length = 2, max_length = 255)]
190    pub author: compact_str::CompactString,
191
192    #[garde(skip)]
193    #[schema(inline)]
194    pub config: ExportedNestEggConfigs,
195    #[garde(skip)]
196    #[schema(inline)]
197    pub scripts: ExportedNestEggScripts,
198
199    #[garde(custom(validate_startup_commands))]
200    #[serde(
201        deserialize_with = "crate::deserialize::deserialize_map_or_not",
202        alias = "startup"
203    )]
204    pub startup_commands: IndexMap<compact_str::CompactString, compact_str::CompactString>,
205    #[garde(skip)]
206    #[serde(default)]
207    pub force_outgoing_ip: bool,
208    #[garde(skip)]
209    #[serde(default)]
210    pub separate_port: bool,
211
212    #[garde(skip)]
213    #[serde(
214        default,
215        deserialize_with = "crate::deserialize::deserialize_defaultable"
216    )]
217    pub features: Vec<compact_str::CompactString>,
218    #[garde(custom(validate_docker_images))]
219    pub docker_images: IndexMap<compact_str::CompactString, compact_str::CompactString>,
220    #[garde(skip)]
221    #[serde(
222        default,
223        deserialize_with = "crate::deserialize::deserialize_defaultable"
224    )]
225    pub file_denylist: Vec<compact_str::CompactString>,
226
227    #[garde(skip)]
228    #[schema(inline)]
229    pub variables: Vec<super::nest_egg_variable::ExportedNestEggVariable>,
230}
231
232impl ExportedNestEgg {
233    pub async fn fetch(state: &crate::State, url: &reqwest::Url) -> Result<Self, anyhow::Error> {
234        let response = crate::net::outbound_client(&state.env)
235            .get(url.clone())
236            .send()
237            .await?;
238
239        if !response.status().is_success() {
240            return Err(anyhow::anyhow!(
241                "server responded with status {}",
242                response.status()
243            ));
244        }
245
246        let mut content = Vec::new();
247        let mut stream = response.bytes_stream();
248
249        while let Some(chunk) = stream.try_next().await? {
250            if content.len() + chunk.len() > NestEgg::MAX_EXPORTED_SIZE {
251                return Err(anyhow::anyhow!("egg is too large"));
252            }
253
254            content.extend_from_slice(&chunk);
255        }
256
257        let content = content.trim_ascii();
258
259        Ok(if content.starts_with(b"{") {
260            serde_json::from_slice(content)?
261        } else {
262            serde_norway::from_slice(content)?
263        })
264    }
265}
266
267#[derive(Serialize, Deserialize, Clone)]
268pub struct NestEgg {
269    pub uuid: uuid::Uuid,
270    pub nest: Fetchable<super::nest::Nest>,
271    pub egg_repository_egg: Option<Fetchable<super::egg_repository_egg::EggRepositoryEgg>>,
272
273    pub name: compact_str::CompactString,
274    pub description: Option<compact_str::CompactString>,
275    pub author: compact_str::CompactString,
276
277    pub config_files: Vec<ProcessConfigurationFile>,
278    pub config_startup: NestEggConfigStartup,
279    pub config_stop: NestEggConfigStop,
280    pub config_script: NestEggConfigScript,
281
282    pub startup_commands: IndexMap<compact_str::CompactString, compact_str::CompactString>,
283    pub force_outgoing_ip: bool,
284    pub separate_port: bool,
285
286    pub features: Vec<compact_str::CompactString>,
287    pub docker_images: IndexMap<compact_str::CompactString, compact_str::CompactString>,
288    pub file_denylist: Vec<compact_str::CompactString>,
289
290    pub created: chrono::NaiveDateTime,
291
292    extension_data: super::ModelExtensionData,
293}
294
295impl BaseModel for NestEgg {
296    const NAME: &'static str = "nest_egg";
297
298    fn get_extension_list() -> &'static super::ModelExtensionList {
299        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
300            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
301
302        &EXTENSIONS
303    }
304
305    fn get_extension_data(&self) -> &super::ModelExtensionData {
306        &self.extension_data
307    }
308
309    #[inline]
310    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
311        let prefix = prefix.unwrap_or_default();
312
313        BTreeMap::from([
314            (
315                "nest_eggs.uuid",
316                compact_str::format_compact!("{prefix}uuid"),
317            ),
318            (
319                "nest_eggs.nest_uuid",
320                compact_str::format_compact!("{prefix}nest_uuid"),
321            ),
322            (
323                "nest_eggs.egg_repository_egg_uuid",
324                compact_str::format_compact!("{prefix}egg_repository_egg_uuid"),
325            ),
326            (
327                "nest_eggs.name",
328                compact_str::format_compact!("{prefix}name"),
329            ),
330            (
331                "nest_eggs.description",
332                compact_str::format_compact!("{prefix}description"),
333            ),
334            (
335                "nest_eggs.author",
336                compact_str::format_compact!("{prefix}author"),
337            ),
338            (
339                "nest_eggs.config_files",
340                compact_str::format_compact!("{prefix}config_files"),
341            ),
342            (
343                "nest_eggs.config_startup",
344                compact_str::format_compact!("{prefix}config_startup"),
345            ),
346            (
347                "nest_eggs.config_stop",
348                compact_str::format_compact!("{prefix}config_stop"),
349            ),
350            (
351                "nest_eggs.config_script",
352                compact_str::format_compact!("{prefix}config_script"),
353            ),
354            (
355                "nest_eggs.startup_commands",
356                compact_str::format_compact!("{prefix}startup_commands"),
357            ),
358            (
359                "nest_eggs.force_outgoing_ip",
360                compact_str::format_compact!("{prefix}force_outgoing_ip"),
361            ),
362            (
363                "nest_eggs.separate_port",
364                compact_str::format_compact!("{prefix}separate_port"),
365            ),
366            (
367                "nest_eggs.features",
368                compact_str::format_compact!("{prefix}features"),
369            ),
370            (
371                "nest_eggs.docker_images",
372                compact_str::format_compact!("{prefix}docker_images"),
373            ),
374            (
375                "nest_eggs.file_denylist",
376                compact_str::format_compact!("{prefix}file_denylist"),
377            ),
378            (
379                "nest_eggs.created",
380                compact_str::format_compact!("{prefix}created"),
381            ),
382        ])
383    }
384
385    #[inline]
386    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
387        let prefix = prefix.unwrap_or_default();
388
389        Ok(Self {
390            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
391            nest: super::nest::Nest::get_fetchable(
392                row.try_get(compact_str::format_compact!("{prefix}nest_uuid").as_str())?,
393            ),
394            egg_repository_egg: row
395                .try_get::<Option<uuid::Uuid>, _>(
396                    compact_str::format_compact!("{prefix}egg_repository_egg_uuid").as_str(),
397                )?
398                .map(super::egg_repository_egg::EggRepositoryEgg::get_fetchable),
399            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
400            description: row
401                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
402            author: row.try_get(compact_str::format_compact!("{prefix}author").as_str())?,
403            config_files: serde_json::from_value(
404                row.try_get(compact_str::format_compact!("{prefix}config_files").as_str())?,
405            )?,
406            config_startup: serde_json::from_value(
407                row.try_get(compact_str::format_compact!("{prefix}config_startup").as_str())?,
408            )?,
409            config_stop: serde_json::from_value(
410                row.try_get(compact_str::format_compact!("{prefix}config_stop").as_str())?,
411            )?,
412            config_script: serde_json::from_value(
413                row.try_get(compact_str::format_compact!("{prefix}config_script").as_str())?,
414            )?,
415            startup_commands: serde_json::from_value(
416                row.try_get(compact_str::format_compact!("{prefix}startup_commands").as_str())?,
417            )?,
418            force_outgoing_ip: row
419                .try_get(compact_str::format_compact!("{prefix}force_outgoing_ip").as_str())?,
420            separate_port: row
421                .try_get(compact_str::format_compact!("{prefix}separate_port").as_str())?,
422            features: row.try_get(compact_str::format_compact!("{prefix}features").as_str())?,
423            docker_images: serde_json::from_value(
424                row.try_get(compact_str::format_compact!("{prefix}docker_images").as_str())?,
425            )?,
426            file_denylist: row
427                .try_get(compact_str::format_compact!("{prefix}file_denylist").as_str())?,
428            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
429            extension_data: Self::map_extensions(prefix, row)?,
430        })
431    }
432}
433
434impl NestEgg {
435    /// if any egg is larger than 1 MiB, something went horribly wrong in development
436    pub const MAX_EXPORTED_SIZE: usize = 1024 * 1024;
437
438    pub async fn import(
439        state: &crate::State,
440        nest_uuid: uuid::Uuid,
441        egg_repository_egg_uuid: Option<uuid::Uuid>,
442        exported_egg: ExportedNestEgg,
443    ) -> Result<Self, crate::database::DatabaseError> {
444        let egg = Self::create(
445            state,
446            CreateNestEggOptions {
447                nest_uuid,
448                egg_repository_egg_uuid,
449                author: exported_egg.author,
450                name: exported_egg.name,
451                description: exported_egg.description,
452                config_files: exported_egg
453                    .config
454                    .files
455                    .into_iter()
456                    .map(|(file, config)| ProcessConfigurationFile {
457                        file,
458                        create_new: config.create_new,
459                        parser: config.parser,
460                        replace: config.replace,
461                    })
462                    .collect(),
463                config_startup: exported_egg.config.startup,
464                config_stop: exported_egg.config.stop,
465                config_script: exported_egg.scripts.installation,
466                startup_commands: exported_egg.startup_commands,
467                force_outgoing_ip: exported_egg.force_outgoing_ip,
468                separate_port: exported_egg.separate_port,
469                features: exported_egg.features,
470                docker_images: exported_egg.docker_images,
471                file_denylist: exported_egg.file_denylist,
472            },
473        )
474        .await?;
475
476        for mut variable in exported_egg.variables {
477            if rule_validator::validate_rules(&variable.rules, &()).is_err() {
478                continue;
479            }
480
481            if variable.description.as_ref().is_some_and(|d| d.is_empty()) {
482                variable.description = None;
483            }
484
485            if let Err(err) = super::nest_egg_variable::NestEggVariable::create(
486                state,
487                CreateNestEggVariableOptions {
488                    egg_uuid: egg.uuid,
489                    name: variable.name,
490                    name_translations: variable.name_translations,
491                    description: variable.description,
492                    description_translations: variable.description_translations,
493                    order: variable.order,
494                    env_variable: variable.env_variable,
495                    default_value: variable.default_value,
496                    user_viewable: variable.user_viewable,
497                    user_editable: variable.user_editable,
498                    secret: variable.secret,
499                    rules: variable.rules,
500                },
501            )
502            .await
503            {
504                tracing::warn!("error while importing nest egg variable: {:?}", err);
505            }
506        }
507
508        Ok(egg)
509    }
510
511    pub async fn import_update(
512        &self,
513        database: &crate::database::Database,
514        mut exported_egg: ExportedNestEgg,
515    ) -> Result<(), crate::database::DatabaseError> {
516        sqlx::query!(
517            "UPDATE nest_eggs
518            SET
519                author = $2, name = $3, description = $4,
520                config_files = $5, config_startup = $6, config_stop = $7,
521                config_script = $8, startup_commands = $9::json,
522                force_outgoing_ip = $10, separate_port = $11, features = $12,
523                docker_images = $13::json, file_denylist = $14
524            WHERE nest_eggs.uuid = $1",
525            self.uuid,
526            &exported_egg.author,
527            &exported_egg.name,
528            exported_egg.description.as_deref(),
529            serde_json::to_value(
530                &exported_egg
531                    .config
532                    .files
533                    .into_iter()
534                    .map(|(file, config)| ProcessConfigurationFile {
535                        file,
536                        create_new: config.create_new,
537                        parser: config.parser,
538                        replace: config.replace,
539                    })
540                    .collect::<Vec<_>>(),
541            )?,
542            serde_json::to_value(&exported_egg.config.startup)?,
543            serde_json::to_value(&exported_egg.config.stop)?,
544            serde_json::to_value(&exported_egg.scripts.installation)?,
545            serde_json::to_string(&exported_egg.startup_commands)? as String,
546            exported_egg.force_outgoing_ip,
547            exported_egg.separate_port,
548            &exported_egg
549                .features
550                .into_iter()
551                .map(|f| f.into())
552                .collect::<Vec<_>>(),
553            serde_json::to_string(&exported_egg.docker_images)? as String,
554            &exported_egg
555                .file_denylist
556                .into_iter()
557                .map(|f| f.into())
558                .collect::<Vec<_>>(),
559        )
560        .execute(database.write())
561        .await?;
562
563        let unused_variables = sqlx::query!(
564            "SELECT nest_egg_variables.uuid
565            FROM nest_egg_variables
566            WHERE nest_egg_variables.egg_uuid = $1 AND nest_egg_variables.env_variable != ALL($2)",
567            self.uuid,
568            &exported_egg
569                .variables
570                .iter()
571                .map(|v| v.env_variable.as_str())
572                .collect::<Vec<_>>() as &[&str]
573        )
574        .fetch_all(database.read())
575        .await?;
576
577        for (i, variable) in exported_egg.variables.iter_mut().enumerate() {
578            if rule_validator::validate_rules(&variable.rules, &()).is_err() {
579                continue;
580            }
581
582            if variable.description.as_ref().is_some_and(|d| d.is_empty()) {
583                variable.description = None;
584            }
585
586            if let Err(err) = sqlx::query!(
587                "INSERT INTO nest_egg_variables (
588                    egg_uuid, name, name_translations, description, description_translations, order_, env_variable,
589                    default_value, user_viewable, user_editable, rules
590                )
591                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
592                ON CONFLICT (egg_uuid, env_variable) DO UPDATE SET
593                    name = EXCLUDED.name,
594                    name_translations = EXCLUDED.name_translations,
595                    description = EXCLUDED.description,
596                    description_translations = EXCLUDED.description_translations,
597                    order_ = EXCLUDED.order_,
598                    default_value = EXCLUDED.default_value,
599                    user_viewable = EXCLUDED.user_viewable,
600                    user_editable = EXCLUDED.user_editable,
601                    rules = EXCLUDED.rules",
602                self.uuid,
603                &variable.name,
604                serde_json::to_value(&variable.name_translations)?,
605                variable.description.as_deref(),
606                serde_json::to_value(&variable.description_translations)?,
607                if variable.order == 0 {
608                    i as i16 + 1
609                } else {
610                    variable.order
611                },
612                &variable.env_variable,
613                variable.default_value.as_deref(),
614                variable.user_viewable,
615                variable.user_editable,
616                &variable
617                    .rules
618                    .iter()
619                    .map(|r| r.as_str())
620                    .collect::<Vec<_>>() as &[&str]
621            )
622            .execute(database.read())
623            .await
624            {
625                tracing::warn!("error while importing nest egg variable: {:?}", err);
626            }
627        }
628
629        let order_base = exported_egg.variables.len() as i16
630            + exported_egg
631                .variables
632                .iter()
633                .map(|v| v.order)
634                .max()
635                .unwrap_or_default();
636
637        sqlx::query!(
638            "UPDATE nest_egg_variables
639            SET order_ = $1 + array_position($2, nest_egg_variables.uuid)
640            WHERE nest_egg_variables.uuid = ANY($2) AND nest_egg_variables.egg_uuid = $3",
641            order_base as i32,
642            &unused_variables
643                .into_iter()
644                .map(|v| v.uuid)
645                .collect::<Vec<_>>(),
646            self.uuid,
647        )
648        .execute(database.write())
649        .await?;
650
651        Ok(())
652    }
653
654    pub async fn all(
655        database: &crate::database::Database,
656    ) -> Result<Vec<Self>, crate::database::DatabaseError> {
657        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
658            r#"
659            SELECT {}
660            FROM nest_eggs
661            ORDER BY nest_eggs.created
662            "#,
663            Self::columns_sql(None)
664        )))
665        .fetch_all(database.read())
666        .await?;
667
668        rows.into_iter()
669            .map(|row| Self::map(None, &row))
670            .try_collect_vec()
671    }
672
673    pub async fn by_nest_uuid_with_pagination(
674        database: &crate::database::Database,
675        nest_uuid: uuid::Uuid,
676        page: i64,
677        per_page: i64,
678        search: Option<&str>,
679    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
680        let offset = (page - 1) * per_page;
681
682        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
683            r#"
684            SELECT {}, COUNT(*) OVER() AS total_count
685            FROM nest_eggs
686            WHERE nest_eggs.nest_uuid = $1 AND ($2 IS NULL OR nest_eggs.name ILIKE '%' || $2 || '%')
687            ORDER BY nest_eggs.created
688            LIMIT $3 OFFSET $4
689            "#,
690            Self::columns_sql(None)
691        )))
692        .bind(nest_uuid)
693        .bind(search)
694        .bind(per_page)
695        .bind(offset)
696        .fetch_all(database.read())
697        .await?;
698
699        Ok(super::Pagination {
700            total: rows
701                .first()
702                .map_or(Ok(0), |row| row.try_get("total_count"))?,
703            per_page,
704            page,
705            data: rows
706                .into_iter()
707                .map(|row| Self::map(None, &row))
708                .try_collect_vec()?,
709        })
710    }
711
712    pub async fn by_user_with_pagination(
713        database: &crate::database::Database,
714        user: &super::user::User,
715        page: i64,
716        per_page: i64,
717        search: Option<&str>,
718    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
719        let offset = (page - 1) * per_page;
720
721        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
722            r#"
723            SELECT *, COUNT(*) OVER() AS total_count
724            FROM (
725                SELECT DISTINCT ON (nest_eggs.uuid) {}
726                FROM servers
727                JOIN nest_eggs ON nest_eggs.uuid = servers.egg_uuid
728                LEFT JOIN server_subusers ON server_subusers.server_uuid = servers.uuid AND server_subusers.user_uuid = $1
729                JOIN nests ON nests.uuid = nest_eggs.nest_uuid
730                WHERE (servers.owner_uuid = $1 OR server_subusers.user_uuid = $1 OR $2)
731                    AND ($3 IS NULL OR nest_eggs.name ILIKE '%' || $3 || '%')
732                ORDER BY nest_eggs.uuid
733            ) AS eggs
734            ORDER BY eggs.created
735            LIMIT $4 OFFSET $5
736            "#,
737            Self::columns_sql(None)
738        )))
739        .bind(user.uuid)
740        .bind(user.role.as_ref().map_or(user.admin, |r| r.admin_permissions.iter().any(|p| p == "servers.read")))
741        .bind(search)
742        .bind(per_page)
743        .bind(offset)
744        .fetch_all(database.read())
745        .await?;
746
747        Ok(super::Pagination {
748            total: rows
749                .first()
750                .map_or(Ok(0), |row| row.try_get("total_count"))?,
751            per_page,
752            page,
753            data: rows
754                .into_iter()
755                .map(|row| Self::map(None, &row))
756                .try_collect_vec()?,
757        })
758    }
759
760    pub async fn by_nest_uuid_uuid(
761        database: &crate::database::Database,
762        nest_uuid: uuid::Uuid,
763        uuid: uuid::Uuid,
764    ) -> Result<Option<Self>, crate::database::DatabaseError> {
765        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
766            r#"
767            SELECT {}
768            FROM nest_eggs
769            WHERE nest_eggs.nest_uuid = $1 AND nest_eggs.uuid = $2
770            "#,
771            Self::columns_sql(None)
772        )))
773        .bind(nest_uuid)
774        .bind(uuid)
775        .fetch_optional(database.read())
776        .await?;
777
778        row.try_map(|row| Self::map(None, &row))
779    }
780
781    pub async fn by_nest_uuid_name(
782        database: &crate::database::Database,
783        nest_uuid: uuid::Uuid,
784        name: &str,
785    ) -> Result<Option<Self>, crate::database::DatabaseError> {
786        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
787            r#"
788            SELECT {}
789            FROM nest_eggs
790            WHERE nest_eggs.nest_uuid = $1 AND nest_eggs.name = $2
791            "#,
792            Self::columns_sql(None)
793        )))
794        .bind(nest_uuid)
795        .bind(name)
796        .fetch_optional(database.read())
797        .await?;
798
799        row.try_map(|row| Self::map(None, &row))
800    }
801
802    pub async fn all_by_nest_uuid(
803        database: &crate::database::Database,
804        nest_uuid: uuid::Uuid,
805    ) -> Result<Vec<Self>, crate::database::DatabaseError> {
806        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
807            r#"
808            SELECT {}
809            FROM nest_eggs
810            WHERE nest_eggs.nest_uuid = $1
811            "#,
812            Self::columns_sql(None)
813        )))
814        .bind(nest_uuid)
815        .fetch_all(database.read())
816        .await?;
817
818        rows.into_iter()
819            .map(|row| Self::map(None, &row))
820            .try_collect_vec()
821    }
822
823    pub async fn count_by_nest_uuid(
824        database: &crate::database::Database,
825        nest_uuid: uuid::Uuid,
826    ) -> Result<i64, sqlx::Error> {
827        sqlx::query_scalar(
828            r#"
829            SELECT COUNT(*)
830            FROM nest_eggs
831            WHERE nest_eggs.nest_uuid = $1
832            "#,
833        )
834        .bind(nest_uuid)
835        .fetch_one(database.read())
836        .await
837    }
838
839    pub async fn configuration(
840        &self,
841        database: &crate::database::Database,
842    ) -> Result<super::egg_configuration::MergedEggConfiguration, anyhow::Error> {
843        database
844            .cache
845            .cached(
846                &format!("nest_egg::{}::configuration", self.uuid),
847                10,
848                || async {
849                    super::egg_configuration::EggConfiguration::merged_by_egg_uuid(
850                        database, self.uuid,
851                    )
852                    .await
853                },
854            )
855            .await
856    }
857
858    #[inline]
859    pub async fn into_exported(
860        self,
861        database: &crate::database::Database,
862    ) -> Result<ExportedNestEgg, crate::database::DatabaseError> {
863        Ok(ExportedNestEgg {
864            uuid: self.uuid,
865            author: self.author,
866            name: self.name,
867            description: self.description,
868            config: ExportedNestEggConfigs {
869                files: self
870                    .config_files
871                    .into_iter()
872                    .map(|file| {
873                        (
874                            file.file,
875                            ExportedNestEggConfigsFilesFile {
876                                create_new: file.create_new,
877                                parser: file.parser,
878                                replace: file.replace,
879                            },
880                        )
881                    })
882                    .collect(),
883                startup: self.config_startup,
884                stop: self.config_stop,
885            },
886            scripts: ExportedNestEggScripts {
887                installation: self.config_script,
888            },
889            startup_commands: self.startup_commands,
890            force_outgoing_ip: self.force_outgoing_ip,
891            separate_port: self.separate_port,
892            features: self.features,
893            docker_images: self.docker_images,
894            file_denylist: self.file_denylist,
895            variables: super::nest_egg_variable::NestEggVariable::all_by_egg_uuid(
896                database, self.uuid,
897            )
898            .await?
899            .into_iter()
900            .map(|variable| variable.into_exported())
901            .collect(),
902        })
903    }
904}
905
906#[async_trait::async_trait]
907impl IntoAdminApiObject for NestEgg {
908    type AdminApiObject = AdminApiNestEgg;
909    type ExtraArgs<'a> = ();
910
911    async fn into_admin_api_object<'a>(
912        self,
913        state: &crate::State,
914        _args: Self::ExtraArgs<'a>,
915    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
916        let api_object = AdminApiNestEgg::init_hooks(&self, state).await?;
917
918        let api_object = finish_extendible!(
919            AdminApiNestEgg {
920                uuid: self.uuid,
921                egg_repository_egg: match self.egg_repository_egg {
922                    Some(egg_repository_egg) => Some(
923                        egg_repository_egg
924                            .fetch_cached(&state.database)
925                            .await?
926                            .into_admin_egg_api_object(state, ())
927                            .await?,
928                    ),
929                    None => None,
930                },
931                name: self.name,
932                description: self.description,
933                author: self.author,
934                config_files: self.config_files,
935                config_startup: self.config_startup,
936                config_stop: self.config_stop,
937                config_script: self.config_script,
938                startup_commands: self.startup_commands,
939                force_outgoing_ip: self.force_outgoing_ip,
940                separate_port: self.separate_port,
941                features: self.features,
942                docker_images: self.docker_images,
943                file_denylist: self.file_denylist,
944                created: self.created.and_utc(),
945            },
946            api_object,
947            state
948        )?;
949
950        Ok(api_object)
951    }
952}
953
954#[async_trait::async_trait]
955impl IntoApiObject for NestEgg {
956    type ApiObject = ApiNestEgg;
957    type ExtraArgs<'a> = ();
958
959    async fn into_api_object<'a>(
960        self,
961        state: &crate::State,
962        _args: Self::ExtraArgs<'a>,
963    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
964        let api_object = ApiNestEgg::init_hooks(&self, state).await?;
965
966        let api_object = finish_extendible!(
967            ApiNestEgg {
968                uuid: self.uuid,
969                name: self.name,
970                description: self.description,
971                startup_commands: self.startup_commands,
972                separate_port: self.separate_port,
973                features: self.features,
974                docker_images: self.docker_images,
975                created: self.created.and_utc(),
976            },
977            api_object,
978            state
979        )?;
980
981        Ok(api_object)
982    }
983}
984
985#[async_trait::async_trait]
986impl ByUuid for NestEgg {
987    async fn by_uuid(
988        database: &crate::database::Database,
989        uuid: uuid::Uuid,
990    ) -> Result<Self, crate::database::DatabaseError> {
991        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
992            r#"
993            SELECT {}
994            FROM nest_eggs
995            WHERE nest_eggs.uuid = $1
996            "#,
997            Self::columns_sql(None)
998        )))
999        .bind(uuid)
1000        .fetch_one(database.read())
1001        .await?;
1002
1003        Self::map(None, &row)
1004    }
1005
1006    async fn by_uuid_with_transaction(
1007        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1008        uuid: uuid::Uuid,
1009    ) -> Result<Self, crate::database::DatabaseError> {
1010        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
1011            r#"
1012            SELECT {}
1013            FROM nest_eggs
1014            WHERE nest_eggs.uuid = $1
1015            "#,
1016            Self::columns_sql(None)
1017        )))
1018        .bind(uuid)
1019        .fetch_one(&mut **transaction)
1020        .await?;
1021
1022        Self::map(None, &row)
1023    }
1024}
1025
1026#[derive(ToSchema, Deserialize, Validate)]
1027pub struct CreateNestEggOptions {
1028    #[garde(skip)]
1029    pub nest_uuid: uuid::Uuid,
1030    #[garde(skip)]
1031    pub egg_repository_egg_uuid: Option<uuid::Uuid>,
1032    #[garde(length(chars, min = 2, max = 255))]
1033    #[schema(min_length = 2, max_length = 255)]
1034    pub author: compact_str::CompactString,
1035    #[garde(length(chars, min = 1, max = 255))]
1036    #[schema(min_length = 1, max_length = 255)]
1037    pub name: compact_str::CompactString,
1038    #[garde(length(chars, min = 1, max = 1024))]
1039    #[schema(min_length = 1, max_length = 1024)]
1040    pub description: Option<compact_str::CompactString>,
1041    #[garde(skip)]
1042    #[schema(inline)]
1043    pub config_files: Vec<ProcessConfigurationFile>,
1044    #[garde(skip)]
1045    #[schema(inline)]
1046    pub config_startup: NestEggConfigStartup,
1047    #[garde(skip)]
1048    #[schema(inline)]
1049    pub config_stop: NestEggConfigStop,
1050    #[garde(skip)]
1051    #[schema(inline)]
1052    pub config_script: NestEggConfigScript,
1053    #[garde(custom(validate_startup_commands))]
1054    pub startup_commands: IndexMap<compact_str::CompactString, compact_str::CompactString>,
1055    #[garde(skip)]
1056    pub force_outgoing_ip: bool,
1057    #[garde(skip)]
1058    pub separate_port: bool,
1059    #[garde(skip)]
1060    pub features: Vec<compact_str::CompactString>,
1061    #[garde(custom(validate_docker_images))]
1062    pub docker_images: IndexMap<compact_str::CompactString, compact_str::CompactString>,
1063    #[garde(skip)]
1064    pub file_denylist: Vec<compact_str::CompactString>,
1065}
1066
1067#[async_trait::async_trait]
1068impl CreatableModel for NestEgg {
1069    type CreateOptions<'a> = CreateNestEggOptions;
1070    type CreateResult = Self;
1071
1072    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
1073        static CREATE_LISTENERS: LazyLock<CreateListenerList<NestEgg>> =
1074            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1075
1076        &CREATE_LISTENERS
1077    }
1078
1079    async fn create_with_transaction(
1080        state: &crate::State,
1081        mut options: Self::CreateOptions<'_>,
1082        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1083    ) -> Result<Self, crate::database::DatabaseError> {
1084        options.validate()?;
1085
1086        if let Some(egg_repository_egg_uuid) = options.egg_repository_egg_uuid {
1087            super::egg_repository_egg::EggRepositoryEgg::by_uuid_optional_cached(
1088                &state.database,
1089                egg_repository_egg_uuid,
1090            )
1091            .await?
1092            .ok_or(crate::database::InvalidRelationError("egg_repository_egg"))?;
1093        }
1094
1095        let mut query_builder = InsertQueryBuilder::new("nest_eggs");
1096
1097        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
1098
1099        query_builder
1100            .set("nest_uuid", options.nest_uuid)
1101            .set("egg_repository_egg_uuid", options.egg_repository_egg_uuid)
1102            .set("author", &options.author)
1103            .set("name", &options.name)
1104            .set("description", &options.description)
1105            .set("config_files", serde_json::to_value(&options.config_files)?)
1106            .set(
1107                "config_startup",
1108                serde_json::to_value(&options.config_startup)?,
1109            )
1110            .set("config_stop", serde_json::to_value(&options.config_stop)?)
1111            .set(
1112                "config_script",
1113                serde_json::to_value(&options.config_script)?,
1114            )
1115            .set("startup_commands", OrderedJson(&options.startup_commands))
1116            .set("force_outgoing_ip", options.force_outgoing_ip)
1117            .set("separate_port", options.separate_port)
1118            .set("features", &options.features)
1119            .set("docker_images", OrderedJson(&options.docker_images))
1120            .set("file_denylist", &options.file_denylist);
1121
1122        let row = query_builder
1123            .returning(&Self::columns_sql(None))
1124            .fetch_one(&mut **transaction)
1125            .await?;
1126        let mut nest_egg = Self::map(None, &row)?;
1127
1128        Self::run_after_create_handlers(&mut nest_egg, &options, state, transaction).await?;
1129
1130        Ok(nest_egg)
1131    }
1132}
1133
1134#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
1135pub struct UpdateNestEggOptions {
1136    #[garde(skip)]
1137    #[serde(
1138        default,
1139        skip_serializing_if = "Option::is_none",
1140        with = "::serde_with::rust::double_option"
1141    )]
1142    pub egg_repository_egg_uuid: Option<Option<uuid::Uuid>>,
1143    #[garde(length(chars, min = 2, max = 255))]
1144    #[schema(min_length = 2, max_length = 255)]
1145    pub author: Option<compact_str::CompactString>,
1146    #[garde(length(chars, min = 3, max = 255))]
1147    #[schema(min_length = 3, max_length = 255)]
1148    pub name: Option<compact_str::CompactString>,
1149    #[garde(length(chars, min = 1, max = 1024))]
1150    #[schema(min_length = 1, max_length = 1024)]
1151    #[serde(
1152        default,
1153        skip_serializing_if = "Option::is_none",
1154        with = "::serde_with::rust::double_option"
1155    )]
1156    pub description: Option<Option<compact_str::CompactString>>,
1157    #[garde(skip)]
1158    #[schema(inline)]
1159    pub config_files: Option<Vec<ProcessConfigurationFile>>,
1160    #[garde(skip)]
1161    #[schema(inline)]
1162    pub config_startup: Option<NestEggConfigStartup>,
1163    #[garde(skip)]
1164    #[schema(inline)]
1165    pub config_stop: Option<NestEggConfigStop>,
1166    #[garde(skip)]
1167    #[schema(inline)]
1168    pub config_script: Option<NestEggConfigScript>,
1169    #[garde(inner(custom(validate_startup_commands)))]
1170    pub startup_commands: Option<IndexMap<compact_str::CompactString, compact_str::CompactString>>,
1171    #[garde(skip)]
1172    pub force_outgoing_ip: Option<bool>,
1173    #[garde(skip)]
1174    pub separate_port: Option<bool>,
1175    #[garde(skip)]
1176    pub features: Option<Vec<compact_str::CompactString>>,
1177    #[garde(inner(custom(validate_docker_images)))]
1178    pub docker_images: Option<IndexMap<compact_str::CompactString, compact_str::CompactString>>,
1179    #[garde(skip)]
1180    pub file_denylist: Option<Vec<compact_str::CompactString>>,
1181}
1182
1183#[async_trait::async_trait]
1184impl UpdatableModel for NestEgg {
1185    type UpdateOptions = UpdateNestEggOptions;
1186
1187    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
1188        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<NestEgg>> =
1189            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1190
1191        &UPDATE_LISTENERS
1192    }
1193
1194    async fn update_with_transaction(
1195        &mut self,
1196        state: &crate::State,
1197        mut options: Self::UpdateOptions,
1198        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1199    ) -> Result<(), crate::database::DatabaseError> {
1200        options.validate()?;
1201
1202        let egg_repository_egg =
1203            if let Some(egg_repository_egg_uuid) = &options.egg_repository_egg_uuid {
1204                match egg_repository_egg_uuid {
1205                    Some(uuid) => {
1206                        super::egg_repository_egg::EggRepositoryEgg::by_uuid_optional_cached(
1207                            &state.database,
1208                            *uuid,
1209                        )
1210                        .await?
1211                        .ok_or(crate::database::InvalidRelationError("egg_repository_egg"))?;
1212                        Some(Some(
1213                            super::egg_repository_egg::EggRepositoryEgg::get_fetchable(*uuid),
1214                        ))
1215                    }
1216                    None => Some(None),
1217                }
1218            } else {
1219                None
1220            };
1221
1222        let mut query_builder = UpdateQueryBuilder::new("nest_eggs");
1223
1224        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
1225            .await?;
1226
1227        query_builder
1228            .set(
1229                "egg_repository_egg_uuid",
1230                options.egg_repository_egg_uuid.as_ref().map(|o| o.as_ref()),
1231            )
1232            .set("author", options.author.as_ref())
1233            .set("name", options.name.as_ref())
1234            .set(
1235                "description",
1236                options.description.as_ref().map(|d| d.as_ref()),
1237            )
1238            .set(
1239                "config_files",
1240                options
1241                    .config_files
1242                    .as_ref()
1243                    .map(serde_json::to_value)
1244                    .transpose()?,
1245            )
1246            .set(
1247                "config_startup",
1248                options
1249                    .config_startup
1250                    .as_ref()
1251                    .map(serde_json::to_value)
1252                    .transpose()?,
1253            )
1254            .set(
1255                "config_stop",
1256                options
1257                    .config_stop
1258                    .as_ref()
1259                    .map(serde_json::to_value)
1260                    .transpose()?,
1261            )
1262            .set(
1263                "config_script",
1264                options
1265                    .config_script
1266                    .as_ref()
1267                    .map(serde_json::to_value)
1268                    .transpose()?,
1269            )
1270            .set(
1271                "startup_commands",
1272                options.startup_commands.as_ref().map(OrderedJson),
1273            )
1274            .set("force_outgoing_ip", options.force_outgoing_ip)
1275            .set("separate_port", options.separate_port)
1276            .set("features", options.features.as_ref())
1277            .set(
1278                "docker_images",
1279                options.docker_images.as_ref().map(OrderedJson),
1280            )
1281            .set("file_denylist", options.file_denylist.as_ref())
1282            .where_eq("uuid", self.uuid);
1283
1284        query_builder.execute(&mut **transaction).await?;
1285
1286        if let Some(egg_repository_egg) = egg_repository_egg {
1287            self.egg_repository_egg = egg_repository_egg;
1288        }
1289        if let Some(author) = options.author {
1290            self.author = author;
1291        }
1292        if let Some(name) = options.name {
1293            self.name = name;
1294        }
1295        if let Some(description) = options.description {
1296            self.description = description;
1297        }
1298        if let Some(config_files) = options.config_files {
1299            self.config_files = config_files;
1300        }
1301        if let Some(config_startup) = options.config_startup {
1302            self.config_startup = config_startup;
1303        }
1304        if let Some(config_stop) = options.config_stop {
1305            self.config_stop = config_stop;
1306        }
1307        if let Some(config_script) = options.config_script {
1308            self.config_script = config_script;
1309        }
1310        if let Some(startup_commands) = options.startup_commands {
1311            self.startup_commands = startup_commands;
1312        }
1313        if let Some(force_outgoing_ip) = options.force_outgoing_ip {
1314            self.force_outgoing_ip = force_outgoing_ip;
1315        }
1316        if let Some(separate_port) = options.separate_port {
1317            self.separate_port = separate_port;
1318        }
1319        if let Some(features) = options.features {
1320            self.features = features;
1321        }
1322        if let Some(docker_images) = options.docker_images {
1323            self.docker_images = docker_images;
1324        }
1325        if let Some(file_denylist) = options.file_denylist {
1326            self.file_denylist = file_denylist;
1327        }
1328
1329        self.run_after_update_handlers(state, transaction).await?;
1330
1331        Ok(())
1332    }
1333}
1334
1335#[async_trait::async_trait]
1336impl DeletableModel for NestEgg {
1337    type DeleteOptions = ();
1338
1339    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
1340        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<NestEgg>> =
1341            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1342
1343        &DELETE_LISTENERS
1344    }
1345
1346    async fn delete_with_transaction(
1347        &self,
1348        state: &crate::State,
1349        options: Self::DeleteOptions,
1350        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1351    ) -> Result<(), anyhow::Error> {
1352        self.run_delete_handlers(&options, state, transaction)
1353            .await?;
1354
1355        sqlx::query(
1356            r#"
1357            DELETE FROM nest_eggs
1358            WHERE nest_eggs.uuid = $1
1359            "#,
1360        )
1361        .bind(self.uuid)
1362        .execute(&mut **transaction)
1363        .await?;
1364
1365        self.run_after_delete_handlers(&options, state, transaction)
1366            .await?;
1367
1368        Ok(())
1369    }
1370}
1371
1372#[derive(Validate)]
1373pub struct DuplicateNestEggOptions {
1374    #[garde(skip)]
1375    pub nest_uuid: uuid::Uuid,
1376    #[garde(length(chars, min = 1, max = 255))]
1377    pub name: compact_str::CompactString,
1378}
1379
1380#[async_trait::async_trait]
1381impl DuplicableModel for NestEgg {
1382    type DuplicateOptions<'a> = DuplicateNestEggOptions;
1383
1384    fn get_duplicate_handlers() -> &'static LazyLock<DuplicateHandlerList<Self>> {
1385        static DUPLICATE_LISTENERS: LazyLock<DuplicateHandlerList<NestEgg>> =
1386            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
1387
1388        &DUPLICATE_LISTENERS
1389    }
1390
1391    async fn duplicate_with_transaction(
1392        &self,
1393        state: &crate::State,
1394        options: Self::DuplicateOptions<'_>,
1395        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1396    ) -> Result<Self, crate::database::DatabaseError> {
1397        options.validate()?;
1398
1399        self.run_duplicate_handlers(&options, state, transaction)
1400            .await?;
1401
1402        let mut query_builder = InsertQueryBuilder::new("nest_eggs");
1403
1404        query_builder
1405            .set("nest_uuid", options.nest_uuid)
1406            .set("egg_repository_egg_uuid", None::<uuid::Uuid>)
1407            .set("author", &self.author)
1408            .set("name", &options.name)
1409            .set("description", &self.description)
1410            .set("config_files", serde_json::to_value(&self.config_files)?)
1411            .set(
1412                "config_startup",
1413                serde_json::to_value(&self.config_startup)?,
1414            )
1415            .set("config_stop", serde_json::to_value(&self.config_stop)?)
1416            .set("config_script", serde_json::to_value(&self.config_script)?)
1417            .set("startup_commands", OrderedJson(&self.startup_commands))
1418            .set("force_outgoing_ip", self.force_outgoing_ip)
1419            .set("separate_port", self.separate_port)
1420            .set("features", &self.features)
1421            .set("docker_images", OrderedJson(&self.docker_images))
1422            .set("file_denylist", &self.file_denylist);
1423
1424        let row = query_builder
1425            .returning(&Self::columns_sql(None))
1426            .fetch_one(&mut **transaction)
1427            .await?;
1428        let mut nest_egg = Self::map(None, &row)?;
1429
1430        sqlx::query!(
1431            "INSERT INTO nest_egg_variables (
1432                egg_uuid, name, name_translations, description, description_translations,
1433                order_, env_variable, default_value, user_viewable, user_editable, secret, rules
1434            )
1435            SELECT
1436                $1, nest_egg_variables.name, nest_egg_variables.name_translations,
1437                nest_egg_variables.description, nest_egg_variables.description_translations,
1438                nest_egg_variables.order_, nest_egg_variables.env_variable,
1439                nest_egg_variables.default_value, nest_egg_variables.user_viewable,
1440                nest_egg_variables.user_editable, nest_egg_variables.secret, nest_egg_variables.rules
1441            FROM nest_egg_variables
1442            WHERE nest_egg_variables.egg_uuid = $2",
1443            nest_egg.uuid,
1444            self.uuid,
1445        )
1446        .execute(&mut **transaction)
1447        .await?;
1448
1449        sqlx::query!(
1450            "INSERT INTO nest_egg_mounts (egg_uuid, mount_uuid)
1451            SELECT $1, nest_egg_mounts.mount_uuid
1452            FROM nest_egg_mounts
1453            WHERE nest_egg_mounts.egg_uuid = $2",
1454            nest_egg.uuid,
1455            self.uuid,
1456        )
1457        .execute(&mut **transaction)
1458        .await?;
1459
1460        self.run_after_duplicate_handlers(&mut nest_egg, &options, state, transaction)
1461            .await?;
1462
1463        Ok(nest_egg)
1464    }
1465}
1466
1467#[schema_extension_derive::extendible]
1468#[init_args(NestEgg, crate::State)]
1469#[hook_args(crate::State)]
1470#[derive(ToSchema, Serialize)]
1471#[schema(title = "AdminNestEgg")]
1472pub struct AdminApiNestEgg {
1473    pub uuid: uuid::Uuid,
1474    pub egg_repository_egg: Option<super::egg_repository_egg::AdminApiEggEggRepositoryEgg>,
1475
1476    pub name: compact_str::CompactString,
1477    pub description: Option<compact_str::CompactString>,
1478    pub author: compact_str::CompactString,
1479
1480    #[schema(inline)]
1481    pub config_files: Vec<ProcessConfigurationFile>,
1482    #[schema(inline)]
1483    pub config_startup: NestEggConfigStartup,
1484    #[schema(inline)]
1485    pub config_stop: NestEggConfigStop,
1486    #[schema(inline)]
1487    pub config_script: NestEggConfigScript,
1488
1489    pub startup_commands: IndexMap<compact_str::CompactString, compact_str::CompactString>,
1490    pub force_outgoing_ip: bool,
1491    pub separate_port: bool,
1492
1493    pub features: Vec<compact_str::CompactString>,
1494    pub docker_images: IndexMap<compact_str::CompactString, compact_str::CompactString>,
1495    pub file_denylist: Vec<compact_str::CompactString>,
1496
1497    pub created: chrono::DateTime<chrono::Utc>,
1498}
1499
1500#[schema_extension_derive::extendible]
1501#[init_args(NestEgg, crate::State)]
1502#[hook_args(crate::State)]
1503#[derive(ToSchema, Serialize)]
1504#[schema(title = "NestEgg")]
1505pub struct ApiNestEgg {
1506    pub uuid: uuid::Uuid,
1507
1508    pub name: compact_str::CompactString,
1509    pub description: Option<compact_str::CompactString>,
1510
1511    pub startup_commands: IndexMap<compact_str::CompactString, compact_str::CompactString>,
1512    pub separate_port: bool,
1513
1514    pub features: Vec<compact_str::CompactString>,
1515    pub docker_images: IndexMap<compact_str::CompactString, compact_str::CompactString>,
1516
1517    pub created: chrono::DateTime<chrono::Utc>,
1518}