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