Skip to main content

shared/models/
egg_configuration.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use garde::Validate;
6use serde::{Deserialize, Serialize};
7use sqlx::{Row, postgres::PgRow};
8use std::{
9    collections::BTreeMap,
10    sync::{Arc, LazyLock},
11};
12use utoipa::ToSchema;
13
14pub fn validate_config_allocations(
15    config_allocations: &EggConfigAllocations,
16    _context: &(),
17) -> Result<(), garde::Error> {
18    if !config_allocations.user_self_assign.is_valid() {
19        return Err(garde::Error::new(
20            "port ranges must be 1024-65535 and start_port < end_port",
21        ));
22    }
23
24    Ok(())
25}
26
27#[derive(ToSchema, Serialize, Deserialize, Clone, Copy)]
28pub struct EggConfigAllocationsUserSelfAssign {
29    pub enabled: bool,
30    pub require_primary_allocation: bool,
31
32    pub start_port: u16,
33    pub end_port: u16,
34}
35
36impl Default for EggConfigAllocationsUserSelfAssign {
37    fn default() -> Self {
38        Self {
39            enabled: false,
40            require_primary_allocation: true,
41            start_port: 49152,
42            end_port: 65535,
43        }
44    }
45}
46
47impl EggConfigAllocationsUserSelfAssign {
48    #[inline]
49    pub fn is_valid(&self) -> bool {
50        self.start_port < self.end_port && self.start_port >= 1024
51    }
52}
53
54#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
55#[serde(tag = "type", rename_all = "snake_case")]
56pub enum EggConfigAllocationDeploymentAdditionalAllocationMode {
57    Random,
58    Range {
59        #[garde(range(min = 1024, max = 65535))]
60        start_port: u16,
61        #[garde(range(min = 1024, max = 65535))]
62        end_port: u16,
63    },
64    AddPrimary {
65        #[garde(skip)]
66        value: u16,
67    },
68    SubtractPrimary {
69        #[garde(skip)]
70        value: u16,
71    },
72    MultiplyPrimary {
73        #[garde(skip)]
74        value: f64,
75    },
76    DividePrimary {
77        #[garde(skip)]
78        value: f64,
79    },
80}
81
82#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
83pub struct EggConfigAllocationDeploymentAdditionalAllocation {
84    #[schema(inline)]
85    #[garde(dive)]
86    pub mode: EggConfigAllocationDeploymentAdditionalAllocationMode,
87    #[garde(length(chars, min = 1, max = 255))]
88    pub assign_to_variable: Option<compact_str::CompactString>,
89}
90
91#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
92pub struct EggConfigAllocationDeploymentPrimaryAllocation {
93    #[garde(range(min = 1024, max = 65535))]
94    pub start_port: u16,
95    #[garde(range(min = 1024, max = 65535))]
96    pub end_port: u16,
97
98    #[garde(length(chars, min = 1, max = 255))]
99    pub assign_to_variable: Option<compact_str::CompactString>,
100}
101
102#[derive(ToSchema, Validate, Serialize, Deserialize, Default, Clone)]
103pub struct EggConfigAllocationsDeployment {
104    #[garde(skip)]
105    pub dedicated: bool,
106
107    #[schema(inline)]
108    #[garde(dive)]
109    pub primary: Option<EggConfigAllocationDeploymentPrimaryAllocation>,
110    #[schema(inline)]
111    #[garde(dive)]
112    pub additional: Vec<EggConfigAllocationDeploymentAdditionalAllocation>,
113}
114
115#[derive(ToSchema, Serialize, Deserialize, Default, Clone)]
116pub struct EggConfigAllocations {
117    #[serde(default)]
118    pub user_self_assign: EggConfigAllocationsUserSelfAssign,
119    #[serde(default)]
120    pub deployment: EggConfigAllocationsDeployment,
121}
122
123#[derive(ToSchema, Validate, Serialize, Deserialize, Default, Clone)]
124pub struct EggConfigStartup {
125    #[garde(skip)]
126    pub allow_custom_startup_command: bool,
127}
128
129#[derive(ToSchema, Serialize, Deserialize, Clone)]
130#[serde(tag = "type", rename_all = "snake_case")]
131pub enum EggConfigRoutesRouteItem {
132    Route {
133        path: compact_str::CompactString,
134    },
135    Divider {
136        name: Option<compact_str::CompactString>,
137        name_translations: BTreeMap<compact_str::CompactString, compact_str::CompactString>,
138    },
139    Redirect {
140        name: compact_str::CompactString,
141        name_translations: BTreeMap<compact_str::CompactString, compact_str::CompactString>,
142        destination: compact_str::CompactString,
143    },
144}
145
146#[derive(ToSchema, Validate, Serialize, Deserialize, Default, Clone)]
147pub struct EggConfigRoutes {
148    #[garde(length(max = 100))]
149    #[schema(max_length = 100)]
150    pub order: Vec<EggConfigRoutesRouteItem>,
151}
152
153#[derive(Serialize, Deserialize, Clone)]
154pub struct EggConfiguration {
155    pub uuid: uuid::Uuid,
156
157    pub name: compact_str::CompactString,
158    pub description: Option<compact_str::CompactString>,
159    pub order: i16,
160
161    pub eggs: Vec<uuid::Uuid>,
162
163    pub config_allocations: Option<EggConfigAllocations>,
164    pub config_startup: Option<EggConfigStartup>,
165    pub config_routes: Option<EggConfigRoutes>,
166
167    pub created: chrono::NaiveDateTime,
168
169    extension_data: super::ModelExtensionData,
170}
171
172impl BaseModel for EggConfiguration {
173    const NAME: &'static str = "egg_configuration";
174
175    fn get_extension_list() -> &'static super::ModelExtensionList {
176        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
177            LazyLock::new(|| std::sync::RwLock::new(Vec::new()));
178
179        &EXTENSIONS
180    }
181
182    fn get_extension_data(&self) -> &super::ModelExtensionData {
183        &self.extension_data
184    }
185
186    #[inline]
187    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
188        let prefix = prefix.unwrap_or_default();
189
190        BTreeMap::from([
191            (
192                "egg_configurations.uuid",
193                compact_str::format_compact!("{prefix}uuid"),
194            ),
195            (
196                "egg_configurations.name",
197                compact_str::format_compact!("{prefix}name"),
198            ),
199            (
200                "egg_configurations.description",
201                compact_str::format_compact!("{prefix}description"),
202            ),
203            (
204                "egg_configurations.order_",
205                compact_str::format_compact!("{prefix}order_"),
206            ),
207            (
208                "egg_configurations.eggs",
209                compact_str::format_compact!("{prefix}eggs"),
210            ),
211            (
212                "egg_configurations.config_allocations",
213                compact_str::format_compact!("{prefix}config_allocations"),
214            ),
215            (
216                "egg_configurations.config_startup",
217                compact_str::format_compact!("{prefix}config_startup"),
218            ),
219            (
220                "egg_configurations.config_routes",
221                compact_str::format_compact!("{prefix}config_routes"),
222            ),
223            (
224                "egg_configurations.created",
225                compact_str::format_compact!("{prefix}created"),
226            ),
227        ])
228    }
229
230    #[inline]
231    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
232        let prefix = prefix.unwrap_or_default();
233
234        Ok(Self {
235            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
236            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
237            description: row
238                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
239            order: row.try_get(compact_str::format_compact!("{prefix}order_").as_str())?,
240            eggs: row.try_get(compact_str::format_compact!("{prefix}eggs").as_str())?,
241            config_allocations: row
242                .try_get::<Option<serde_json::Value>, _>(
243                    compact_str::format_compact!("{prefix}config_allocations").as_str(),
244                )?
245                .and_then(|v| serde_json::from_value(v).ok()),
246            config_startup: row
247                .try_get::<Option<serde_json::Value>, _>(
248                    compact_str::format_compact!("{prefix}config_startup").as_str(),
249                )?
250                .and_then(|v| serde_json::from_value(v).ok()),
251            config_routes: row
252                .try_get::<Option<serde_json::Value>, _>(
253                    compact_str::format_compact!("{prefix}config_routes").as_str(),
254                )?
255                .and_then(|v| serde_json::from_value(v).ok()),
256            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
257            extension_data: Self::map_extensions(prefix, row)?,
258        })
259    }
260}
261
262impl EggConfiguration {
263    pub async fn all_with_pagination(
264        database: &crate::database::Database,
265        page: i64,
266        per_page: i64,
267        search: Option<&str>,
268    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
269        let offset = (page - 1) * per_page;
270
271        let rows = sqlx::query(&format!(
272            r#"
273            SELECT {}, COUNT(*) OVER() AS total_count
274            FROM egg_configurations
275            WHERE ($1 IS NULL OR egg_configurations.name ILIKE '%' || $1 || '%')
276            ORDER BY egg_configurations.order_, egg_configurations.created
277            LIMIT $2 OFFSET $3
278            "#,
279            Self::columns_sql(None)
280        ))
281        .bind(search)
282        .bind(per_page)
283        .bind(offset)
284        .fetch_all(database.read())
285        .await?;
286
287        Ok(super::Pagination {
288            total: rows
289                .first()
290                .map_or(Ok(0), |row| row.try_get("total_count"))?,
291            per_page,
292            page,
293            data: rows
294                .into_iter()
295                .map(|row| Self::map(None, &row))
296                .try_collect_vec()?,
297        })
298    }
299
300    pub async fn merged_by_egg_uuid(
301        database: &crate::database::Database,
302        egg_uuid: uuid::Uuid,
303    ) -> Result<MergedEggConfiguration, crate::database::DatabaseError> {
304        let rows = sqlx::query(&format!(
305            r#"
306            SELECT {}
307            FROM egg_configurations
308            WHERE $1 = ANY(egg_configurations.eggs)
309            ORDER BY egg_configurations.order_, egg_configurations.created
310            "#,
311            Self::columns_sql(None)
312        ))
313        .bind(egg_uuid)
314        .fetch_all(database.read())
315        .await?;
316
317        let rows = rows
318            .into_iter()
319            .map(|row| Self::map(None, &row))
320            .try_collect_vec()?;
321
322        let mut base = MergedEggConfiguration {
323            config_allocations: None,
324            config_startup: None,
325            config_routes: None,
326        };
327
328        for row in rows {
329            if row.config_allocations.is_some() {
330                base.config_allocations = row.config_allocations;
331            }
332            if row.config_startup.is_some() {
333                base.config_startup = row.config_startup;
334            }
335            if row.config_routes.is_some() {
336                base.config_routes = row.config_routes;
337            }
338        }
339
340        Ok(base)
341    }
342}
343
344#[async_trait::async_trait]
345impl IntoAdminApiObject for EggConfiguration {
346    type AdminApiObject = AdminApiEggConfiguration;
347    type ExtraArgs<'a> = ();
348
349    async fn into_admin_api_object<'a>(
350        self,
351        state: &crate::State,
352        _args: Self::ExtraArgs<'a>,
353    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
354        let api_object = AdminApiEggConfiguration::init_hooks(&self, state).await?;
355
356        let api_object = finish_extendible!(
357            AdminApiEggConfiguration {
358                uuid: self.uuid,
359                name: self.name,
360                description: self.description,
361                order: self.order,
362                eggs: self.eggs,
363                config_allocations: self.config_allocations,
364                config_startup: self.config_startup,
365                config_routes: self.config_routes,
366                created: self.created.and_utc(),
367            },
368            api_object,
369            state
370        )?;
371
372        Ok(api_object)
373    }
374}
375
376#[async_trait::async_trait]
377impl ByUuid for EggConfiguration {
378    async fn by_uuid(
379        database: &crate::database::Database,
380        uuid: uuid::Uuid,
381    ) -> Result<Self, crate::database::DatabaseError> {
382        let row = sqlx::query(&format!(
383            r#"
384            SELECT {}
385            FROM egg_configurations
386            WHERE egg_configurations.uuid = $1
387            "#,
388            Self::columns_sql(None)
389        ))
390        .bind(uuid)
391        .fetch_one(database.read())
392        .await?;
393
394        Self::map(None, &row)
395    }
396
397    async fn by_uuid_with_transaction(
398        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
399        uuid: uuid::Uuid,
400    ) -> Result<Self, crate::database::DatabaseError> {
401        let row = sqlx::query(&format!(
402            r#"
403            SELECT {}
404            FROM egg_configurations
405            WHERE egg_configurations.uuid = $1
406            "#,
407            Self::columns_sql(None)
408        ))
409        .bind(uuid)
410        .fetch_one(&mut **transaction)
411        .await?;
412
413        Self::map(None, &row)
414    }
415}
416
417#[derive(ToSchema, Deserialize, Validate)]
418pub struct CreateEggConfigurationOptions {
419    #[garde(length(chars, min = 1, max = 255))]
420    #[schema(min_length = 1, max_length = 255)]
421    pub name: compact_str::CompactString,
422    #[garde(length(chars, min = 1, max = 1024))]
423    #[schema(min_length = 1, max_length = 1024)]
424    pub description: Option<compact_str::CompactString>,
425    #[garde(skip)]
426    pub order: i16,
427    #[garde(length(max = 100))]
428    #[schema(max_length = 100)]
429    pub eggs: Vec<uuid::Uuid>,
430    #[garde(inner(custom(validate_config_allocations)))]
431    #[schema(inline)]
432    pub config_allocations: Option<EggConfigAllocations>,
433    #[garde(dive)]
434    #[schema(inline)]
435    pub config_startup: Option<EggConfigStartup>,
436    #[garde(dive)]
437    #[schema(inline)]
438    pub config_routes: Option<EggConfigRoutes>,
439}
440
441#[async_trait::async_trait]
442impl CreatableModel for EggConfiguration {
443    type CreateOptions<'a> = CreateEggConfigurationOptions;
444    type CreateResult = Self;
445
446    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
447        static CREATE_LISTENERS: LazyLock<CreateListenerList<EggConfiguration>> =
448            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
449
450        &CREATE_LISTENERS
451    }
452
453    async fn create_with_transaction(
454        state: &crate::State,
455        mut options: Self::CreateOptions<'_>,
456        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
457    ) -> Result<Self, crate::database::DatabaseError> {
458        options.validate()?;
459
460        let mut query_builder = InsertQueryBuilder::new("egg_configurations");
461
462        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
463
464        query_builder
465            .set("name", &options.name)
466            .set("description", &options.description)
467            .set("order_", options.order)
468            .set("eggs", &options.eggs)
469            .set(
470                "config_allocations",
471                options
472                    .config_allocations
473                    .as_ref()
474                    .map(serde_json::to_value)
475                    .transpose()?,
476            )
477            .set(
478                "config_startup",
479                options
480                    .config_startup
481                    .as_ref()
482                    .map(serde_json::to_value)
483                    .transpose()?,
484            )
485            .set(
486                "config_routes",
487                options
488                    .config_routes
489                    .as_ref()
490                    .map(serde_json::to_value)
491                    .transpose()?,
492            );
493
494        let row = query_builder
495            .returning(&Self::columns_sql(None))
496            .fetch_one(&mut **transaction)
497            .await?;
498        let mut egg_configuration = Self::map(None, &row)?;
499
500        Self::run_after_create_handlers(&mut egg_configuration, &options, state, transaction)
501            .await?;
502
503        Ok(egg_configuration)
504    }
505}
506
507#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
508pub struct UpdateEggConfigurationOptions {
509    #[garde(length(chars, min = 1, max = 255))]
510    #[schema(min_length = 1, max_length = 255)]
511    pub name: Option<compact_str::CompactString>,
512    #[garde(length(chars, min = 1, max = 1024))]
513    #[schema(min_length = 1, max_length = 1024)]
514    #[serde(
515        default,
516        skip_serializing_if = "Option::is_none",
517        with = "::serde_with::rust::double_option"
518    )]
519    pub description: Option<Option<compact_str::CompactString>>,
520    #[garde(skip)]
521    pub order: Option<i16>,
522    #[garde(length(max = 100))]
523    #[schema(max_length = 100)]
524    pub eggs: Option<Vec<uuid::Uuid>>,
525
526    #[garde(inner(inner(custom(validate_config_allocations))))]
527    #[schema(inline)]
528    #[serde(
529        default,
530        skip_serializing_if = "Option::is_none",
531        with = "::serde_with::rust::double_option"
532    )]
533    pub config_allocations: Option<Option<EggConfigAllocations>>,
534    #[garde(dive)]
535    #[schema(inline)]
536    #[serde(
537        default,
538        skip_serializing_if = "Option::is_none",
539        with = "::serde_with::rust::double_option"
540    )]
541    pub config_startup: Option<Option<EggConfigStartup>>,
542    #[garde(dive)]
543    #[schema(inline)]
544    #[serde(
545        default,
546        skip_serializing_if = "Option::is_none",
547        with = "::serde_with::rust::double_option"
548    )]
549    pub config_routes: Option<Option<EggConfigRoutes>>,
550}
551
552#[async_trait::async_trait]
553impl UpdatableModel for EggConfiguration {
554    type UpdateOptions = UpdateEggConfigurationOptions;
555
556    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
557        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggConfiguration>> =
558            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
559
560        &UPDATE_LISTENERS
561    }
562
563    async fn update_with_transaction(
564        &mut self,
565        state: &crate::State,
566        mut options: Self::UpdateOptions,
567        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
568    ) -> Result<(), crate::database::DatabaseError> {
569        options.validate()?;
570
571        let mut query_builder = UpdateQueryBuilder::new("egg_configurations");
572
573        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
574            .await?;
575
576        query_builder
577            .set("name", options.name.as_ref())
578            .set(
579                "description",
580                options.description.as_ref().map(|d| d.as_ref()),
581            )
582            .set("order_", options.order)
583            .set("eggs", options.eggs.as_ref())
584            .set(
585                "config_allocations",
586                options
587                    .config_allocations
588                    .as_ref()
589                    .map(|c| c.as_ref().map(serde_json::to_value).transpose())
590                    .transpose()?,
591            )
592            .set(
593                "config_routes",
594                options
595                    .config_routes
596                    .as_ref()
597                    .map(|c| c.as_ref().map(serde_json::to_value).transpose())
598                    .transpose()?,
599            )
600            .where_eq("uuid", self.uuid);
601
602        query_builder.execute(&mut **transaction).await?;
603
604        if let Some(name) = options.name {
605            self.name = name;
606        }
607        if let Some(description) = options.description {
608            self.description = description;
609        }
610        if let Some(order) = options.order {
611            self.order = order;
612        }
613        if let Some(eggs) = options.eggs {
614            self.eggs = eggs;
615        }
616        if let Some(config_allocations) = options.config_allocations {
617            self.config_allocations = config_allocations;
618        }
619        if let Some(config_routes) = options.config_routes {
620            self.config_routes = config_routes;
621        }
622
623        self.run_after_update_handlers(state, transaction).await?;
624
625        Ok(())
626    }
627}
628
629#[async_trait::async_trait]
630impl DeletableModel for EggConfiguration {
631    type DeleteOptions = ();
632
633    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
634        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggConfiguration>> =
635            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
636
637        &DELETE_LISTENERS
638    }
639
640    async fn delete_with_transaction(
641        &self,
642        state: &crate::State,
643        options: Self::DeleteOptions,
644        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
645    ) -> Result<(), anyhow::Error> {
646        self.run_delete_handlers(&options, state, transaction)
647            .await?;
648
649        sqlx::query(
650            r#"
651            DELETE FROM egg_configurations
652            WHERE egg_configurations.uuid = $1
653            "#,
654        )
655        .bind(self.uuid)
656        .execute(&mut **transaction)
657        .await?;
658
659        self.run_after_delete_handlers(&options, state, transaction)
660            .await?;
661
662        Ok(())
663    }
664}
665
666#[schema_extension_derive::extendible]
667#[init_args(EggConfiguration, crate::State)]
668#[hook_args(crate::State)]
669#[derive(ToSchema, Serialize)]
670#[schema(title = "AdminEggConfiguration")]
671pub struct AdminApiEggConfiguration {
672    pub uuid: uuid::Uuid,
673
674    pub name: compact_str::CompactString,
675    pub description: Option<compact_str::CompactString>,
676    pub order: i16,
677
678    pub eggs: Vec<uuid::Uuid>,
679
680    #[schema(inline)]
681    pub config_allocations: Option<EggConfigAllocations>,
682    #[schema(inline)]
683    pub config_startup: Option<EggConfigStartup>,
684    #[schema(inline)]
685    pub config_routes: Option<EggConfigRoutes>,
686
687    pub created: chrono::DateTime<chrono::Utc>,
688}
689
690#[derive(Deserialize, Serialize)]
691pub struct MergedEggConfiguration {
692    pub config_allocations: Option<EggConfigAllocations>,
693    pub config_startup: Option<EggConfigStartup>,
694    pub config_routes: Option<EggConfigRoutes>,
695}
696
697impl MergedEggConfiguration {}
698
699#[async_trait::async_trait]
700impl IntoApiObject for MergedEggConfiguration {
701    type ApiObject = ApiEggConfiguration;
702    type ExtraArgs<'a> = ();
703
704    async fn into_api_object<'a>(
705        self,
706        state: &crate::State,
707        _args: Self::ExtraArgs<'a>,
708    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
709        let api_object = ApiEggConfiguration::init_hooks(&self, state).await?;
710
711        let api_object = finish_extendible!(
712            ApiEggConfiguration {
713                allocation_self_assign_enabled: self
714                    .config_allocations
715                    .as_ref()
716                    .is_some_and(|c| c.user_self_assign.enabled),
717                allocation_self_assign_require_primary: self
718                    .config_allocations
719                    .as_ref()
720                    .is_some_and(|c| c.user_self_assign.require_primary_allocation),
721                startup_allow_custom_command: self
722                    .config_startup
723                    .as_ref()
724                    .is_some_and(|c| c.allow_custom_startup_command),
725                route_order: self.config_routes.map(|c| c.order),
726            },
727            api_object,
728            state
729        )?;
730
731        Ok(api_object)
732    }
733}
734
735#[schema_extension_derive::extendible]
736#[init_args(MergedEggConfiguration, crate::State)]
737#[hook_args(crate::State)]
738#[derive(ToSchema, Serialize)]
739#[schema(title = "NestEggConfiguration")]
740pub struct ApiEggConfiguration {
741    pub allocation_self_assign_enabled: bool,
742    pub allocation_self_assign_require_primary: bool,
743    pub startup_allow_custom_command: bool,
744    pub route_order: Option<Vec<EggConfigRoutesRouteItem>>,
745}