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