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