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, HashSet},
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 1-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 >= 1
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 = 1, max = 65535))]
60        start_port: u16,
61        #[garde(range(min = 1, 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 = 1, max = 65535))]
94    pub start_port: u16,
95    #[garde(range(min = 1, 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, Validate, Serialize, Deserialize, Default, Clone)]
130pub struct EggConfigRoutes {
131    #[garde(length(max = 100))]
132    #[schema(max_length = 100)]
133    pub order: Vec<crate::settings::RouteOrderItem>,
134}
135
136/// Unlike the other sections this one merges per field: `None` leaves the value of an earlier
137/// configuration in place, so a broad configuration can disable a feature without a narrower one
138/// having to repeat it.
139#[derive(ToSchema, Validate, Serialize, Deserialize, Default, Clone, Copy)]
140pub struct EggConfigFeatures {
141    #[garde(skip)]
142    #[serde(default)]
143    pub firewall: Option<bool>,
144    #[garde(skip)]
145    #[serde(default)]
146    pub private_network: Option<bool>,
147}
148
149#[derive(ToSchema, Validate, Serialize, Deserialize, Clone)]
150#[serde(tag = "type", rename_all = "snake_case")]
151#[schema(no_recursion)]
152pub enum EggConfigurationMatcher {
153    Never,
154    All,
155    And {
156        #[garde(dive)]
157        matchers: Vec<EggConfigurationMatcher>,
158    },
159    Or {
160        #[garde(dive)]
161        matchers: Vec<EggConfigurationMatcher>,
162    },
163    Not {
164        #[garde(dive)]
165        matcher: Box<EggConfigurationMatcher>,
166    },
167    Eggs {
168        #[garde(length(max = 100))]
169        #[schema(max_length = 100)]
170        eggs: Vec<uuid::Uuid>,
171    },
172    Nests {
173        #[garde(length(max = 100))]
174        #[schema(max_length = 100)]
175        nests: Vec<uuid::Uuid>,
176    },
177    Nodes {
178        #[garde(length(max = 100))]
179        #[schema(max_length = 100)]
180        nodes: Vec<uuid::Uuid>,
181    },
182    Locations {
183        #[garde(length(max = 100))]
184        #[schema(max_length = 100)]
185        locations: Vec<uuid::Uuid>,
186    },
187}
188
189impl EggConfigurationMatcher {
190    pub const MAX_NESTING_DEPTH: usize = 3;
191
192    fn nested_within_limit(&self, depth: usize) -> bool {
193        match self {
194            EggConfigurationMatcher::And { matchers }
195            | EggConfigurationMatcher::Or { matchers } => {
196                depth < Self::MAX_NESTING_DEPTH
197                    && matchers.iter().all(|m| m.nested_within_limit(depth + 1))
198            }
199            EggConfigurationMatcher::Not { matcher } => {
200                depth < Self::MAX_NESTING_DEPTH && matcher.nested_within_limit(depth + 1)
201            }
202            _ => true,
203        }
204    }
205
206    pub fn validate_nesting(value: &Self, _context: &()) -> garde::Result {
207        if value.nested_within_limit(0) {
208            Ok(())
209        } else {
210            Err(garde::Error::new(format!(
211                "matcher may not nest groups more than {} levels deep",
212                Self::MAX_NESTING_DEPTH
213            )))
214        }
215    }
216
217    pub fn validate_optional_nesting(value: &Option<Self>, context: &()) -> garde::Result {
218        match value {
219            Some(matcher) => Self::validate_nesting(matcher, context),
220            None => Ok(()),
221        }
222    }
223
224    pub fn matches(&self, target: &EggConfigurationTarget) -> bool {
225        match self {
226            EggConfigurationMatcher::Never => false,
227            EggConfigurationMatcher::All => true,
228            EggConfigurationMatcher::And { matchers } => matchers.iter().all(|m| m.matches(target)),
229            EggConfigurationMatcher::Or { matchers } => matchers.iter().any(|m| m.matches(target)),
230            EggConfigurationMatcher::Not { matcher } => !matcher.matches(target),
231            EggConfigurationMatcher::Eggs { eggs } => eggs.contains(&target.egg_uuid),
232            EggConfigurationMatcher::Nests { nests } => nests.contains(&target.nest_uuid),
233            EggConfigurationMatcher::Nodes { nodes } => nodes.contains(&target.node_uuid),
234            EggConfigurationMatcher::Locations { locations } => {
235                locations.contains(&target.location_uuid)
236            }
237        }
238    }
239}
240
241#[derive(Clone, Copy)]
242pub struct EggConfigurationTarget {
243    pub egg_uuid: uuid::Uuid,
244    pub nest_uuid: uuid::Uuid,
245    pub node_uuid: uuid::Uuid,
246    pub location_uuid: uuid::Uuid,
247}
248
249impl EggConfigurationTarget {
250    pub fn new(egg: &super::nest_egg::NestEgg, node: &super::node::Node) -> Self {
251        Self {
252            egg_uuid: egg.uuid,
253            nest_uuid: egg.nest.uuid,
254            node_uuid: node.uuid,
255            location_uuid: node.location.uuid,
256        }
257    }
258
259    pub fn map(row: &PgRow) -> Result<Self, sqlx::Error> {
260        Ok(Self {
261            egg_uuid: row.try_get("egg_uuid")?,
262            nest_uuid: row.try_get("nest_uuid")?,
263            node_uuid: row.try_get("node_uuid")?,
264            location_uuid: row.try_get("location_uuid")?,
265        })
266    }
267}
268
269/// Servers whose resolved configuration disables a feature, keyed by (egg, node) since that is
270/// all a matcher can tell servers apart by.
271#[derive(Default)]
272pub struct DisabledFeatures {
273    pub firewall: HashSet<(uuid::Uuid, uuid::Uuid)>,
274    pub private_network: HashSet<(uuid::Uuid, uuid::Uuid)>,
275}
276
277#[derive(Serialize, Deserialize, Clone)]
278pub struct EggConfiguration {
279    pub uuid: uuid::Uuid,
280
281    pub name: compact_str::CompactString,
282    pub description: Option<compact_str::CompactString>,
283    pub order: i16,
284
285    pub matcher: EggConfigurationMatcher,
286
287    pub config_allocations: Option<EggConfigAllocations>,
288    pub config_startup: Option<EggConfigStartup>,
289    pub config_routes: Option<EggConfigRoutes>,
290    pub config_features: Option<EggConfigFeatures>,
291
292    pub created: chrono::NaiveDateTime,
293
294    extension_data: super::ModelExtensionData,
295}
296
297impl BaseModel for EggConfiguration {
298    const NAME: &'static str = "egg_configuration";
299
300    fn get_extension_list() -> &'static super::ModelExtensionList {
301        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
302            LazyLock::new(|| parking_lot::RwLock::new(Vec::new()));
303
304        &EXTENSIONS
305    }
306
307    fn get_extension_data(&self) -> &super::ModelExtensionData {
308        &self.extension_data
309    }
310
311    #[inline]
312    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
313        let prefix = prefix.unwrap_or_default();
314
315        BTreeMap::from([
316            (
317                "egg_configurations.uuid",
318                compact_str::format_compact!("{prefix}uuid"),
319            ),
320            (
321                "egg_configurations.name",
322                compact_str::format_compact!("{prefix}name"),
323            ),
324            (
325                "egg_configurations.description",
326                compact_str::format_compact!("{prefix}description"),
327            ),
328            (
329                "egg_configurations.order_",
330                compact_str::format_compact!("{prefix}order_"),
331            ),
332            (
333                "egg_configurations.matcher",
334                compact_str::format_compact!("{prefix}matcher"),
335            ),
336            (
337                "egg_configurations.config_allocations",
338                compact_str::format_compact!("{prefix}config_allocations"),
339            ),
340            (
341                "egg_configurations.config_startup",
342                compact_str::format_compact!("{prefix}config_startup"),
343            ),
344            (
345                "egg_configurations.config_routes",
346                compact_str::format_compact!("{prefix}config_routes"),
347            ),
348            (
349                "egg_configurations.config_features",
350                compact_str::format_compact!("{prefix}config_features"),
351            ),
352            (
353                "egg_configurations.created",
354                compact_str::format_compact!("{prefix}created"),
355            ),
356        ])
357    }
358
359    #[inline]
360    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
361        let prefix = prefix.unwrap_or_default();
362
363        Ok(Self {
364            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
365            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
366            description: row
367                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
368            order: row.try_get(compact_str::format_compact!("{prefix}order_").as_str())?,
369            matcher: serde_json::from_value(
370                row.try_get(compact_str::format_compact!("{prefix}matcher").as_str())?,
371            )?,
372            config_allocations: row
373                .try_get::<Option<serde_json::Value>, _>(
374                    compact_str::format_compact!("{prefix}config_allocations").as_str(),
375                )?
376                .and_then(|v| serde_json::from_value(v).ok()),
377            config_startup: row
378                .try_get::<Option<serde_json::Value>, _>(
379                    compact_str::format_compact!("{prefix}config_startup").as_str(),
380                )?
381                .and_then(|v| serde_json::from_value(v).ok()),
382            config_routes: row
383                .try_get::<Option<serde_json::Value>, _>(
384                    compact_str::format_compact!("{prefix}config_routes").as_str(),
385                )?
386                .and_then(|v| serde_json::from_value(v).ok()),
387            config_features: row
388                .try_get::<Option<serde_json::Value>, _>(
389                    compact_str::format_compact!("{prefix}config_features").as_str(),
390                )?
391                .and_then(|v| serde_json::from_value(v).ok()),
392            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
393            extension_data: Self::map_extensions(prefix, row)?,
394        })
395    }
396
397    fn cache_invalidation_keys(&self) -> Vec<compact_str::CompactString> {
398        vec![
399            compact_str::format_compact!("{}::{}", Self::NAME, self.uuid),
400            Self::ALL_CACHE_KEY.into(),
401        ]
402    }
403}
404
405impl EggConfiguration {
406    pub const ALL_CACHE_KEY: &'static str = "egg_configurations::all";
407
408    pub async fn all_with_pagination(
409        database: &crate::database::Database,
410        page: i64,
411        per_page: i64,
412        search: Option<&str>,
413    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
414        super::PaginatedQueryBuilder::new(
415            Self::columns_sql(None),
416            format!(
417                r#"
418                FROM egg_configurations
419                WHERE {search}
420                "#,
421                search = super::search_sql(
422                    1,
423                    &["egg_configurations.name"],
424                    &["egg_configurations.uuid"]
425                ),
426            ),
427            "egg_configurations.order_, egg_configurations.created, egg_configurations.uuid",
428        )
429        .bind(search)
430        .fetch(database, page, per_page)
431        .await
432    }
433
434    pub async fn all<'a>(
435        executor: impl sqlx::PgExecutor<'a>,
436    ) -> Result<Vec<Self>, crate::database::DatabaseError> {
437        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
438            r#"
439            SELECT {}
440            FROM egg_configurations
441            ORDER BY egg_configurations.order_, egg_configurations.created, egg_configurations.uuid
442            "#,
443            Self::columns_sql(None)
444        )))
445        .fetch_all(executor)
446        .await?;
447
448        rows.into_iter()
449            .map(|row| Self::map(None, &row))
450            .try_collect_vec()
451    }
452
453    pub async fn all_cached(
454        database: &crate::database::Database,
455    ) -> Result<Vec<Self>, anyhow::Error> {
456        database
457            .cache
458            .cached(Self::ALL_CACHE_KEY, 60, || Self::all(database.read()))
459            .await
460    }
461
462    async fn server_targets(
463        database: &crate::database::Database,
464    ) -> Result<Vec<EggConfigurationTarget>, crate::database::DatabaseError> {
465        let rows = sqlx::query!(
466            r#"
467            SELECT DISTINCT
468                servers.egg_uuid,
469                nest_eggs.nest_uuid,
470                servers.node_uuid,
471                nodes.location_uuid
472            FROM servers
473            JOIN nest_eggs ON nest_eggs.uuid = servers.egg_uuid
474            JOIN nodes ON nodes.uuid = servers.node_uuid
475            "#
476        )
477        .fetch_all(database.write())
478        .await?;
479
480        Ok(rows
481            .into_iter()
482            .map(|row| EggConfigurationTarget {
483                egg_uuid: row.egg_uuid,
484                nest_uuid: row.nest_uuid,
485                node_uuid: row.node_uuid,
486                location_uuid: row.location_uuid,
487            })
488            .collect())
489    }
490
491    /// `configurations` must be in precedence order, as [`Self::all`] returns them.
492    pub fn merge<'a>(
493        configurations: impl IntoIterator<Item = &'a Self>,
494        target: &EggConfigurationTarget,
495    ) -> MergedEggConfiguration {
496        let mut base = MergedEggConfiguration::default();
497
498        for configuration in configurations {
499            if !configuration.matcher.matches(target) {
500                continue;
501            }
502
503            if configuration.config_allocations.is_some() {
504                base.config_allocations = configuration.config_allocations.clone();
505            }
506            if configuration.config_startup.is_some() {
507                base.config_startup = configuration.config_startup.clone();
508            }
509            if configuration.config_routes.is_some() {
510                base.config_routes = configuration.config_routes.clone();
511            }
512            if let Some(features) = configuration.config_features {
513                if features.firewall.is_some() {
514                    base.config_features.firewall = features.firewall;
515                }
516                if features.private_network.is_some() {
517                    base.config_features.private_network = features.private_network;
518                }
519            }
520        }
521
522        base
523    }
524
525    pub async fn merged_for_target(
526        database: &crate::database::Database,
527        target: &EggConfigurationTarget,
528    ) -> Result<MergedEggConfiguration, anyhow::Error> {
529        Ok(Self::merge(&Self::all_cached(database).await?, target))
530    }
531}
532
533impl DisabledFeatures {
534    fn from_targets(
535        configurations: &[EggConfiguration],
536        targets: &[EggConfigurationTarget],
537    ) -> Self {
538        let mut disabled = Self::default();
539        for target in targets {
540            let merged = EggConfiguration::merge(configurations, target);
541            let pair = (target.egg_uuid, target.node_uuid);
542
543            if !merged.firewall_enabled() {
544                disabled.firewall.insert(pair);
545            }
546            if !merged.private_network_enabled() {
547                disabled.private_network.insert(pair);
548            }
549        }
550
551        disabled
552    }
553
554    pub async fn resolve(
555        database: &crate::database::Database,
556    ) -> Result<Self, crate::database::DatabaseError> {
557        let configurations = EggConfiguration::all(database.write()).await?;
558        if !configurations.iter().any(|c| c.config_features.is_some()) {
559            return Ok(Self::default());
560        }
561
562        let targets = EggConfiguration::server_targets(database).await?;
563
564        Ok(Self::from_targets(&configurations, &targets))
565    }
566
567    /// Takes the features resolved *before* a change and pushes whatever flipped since to the
568    /// affected nodes. Logs instead of failing, since the change itself has already been made.
569    pub async fn propagate(self, state: &crate::State) {
570        if let Err(err) = self.propagate_now(state).await {
571            tracing::error!("failed to propagate egg configuration changes: {:?}", err);
572        }
573    }
574
575    async fn propagate_now(self, state: &crate::State) -> Result<(), anyhow::Error> {
576        state
577            .database
578            .cache
579            .invalidate(EggConfiguration::ALL_CACHE_KEY)
580            .await?;
581
582        let after = Self::resolve(&state.database).await?;
583
584        if self.private_network != after.private_network {
585            crate::tunnel::bump_epoch(state.database.write()).await?;
586            crate::tunnel::poke_nodes(&state.database).await;
587        }
588
589        let firewall_flipped = self
590            .firewall
591            .symmetric_difference(&after.firewall)
592            .copied()
593            .collect::<HashSet<_>>();
594        if firewall_flipped.is_empty() {
595            return Ok(());
596        }
597
598        for server in super::server::Server::all_with_firewall_by_egg_node_pairs(
599            &state.database,
600            &firewall_flipped,
601        )
602        .await?
603        {
604            server.batch_sync(state).await;
605        }
606
607        Ok(())
608    }
609}
610
611#[async_trait::async_trait]
612impl IntoAdminApiObject for EggConfiguration {
613    type AdminApiObject = AdminApiEggConfiguration;
614    type ExtraArgs<'a> = ();
615
616    async fn into_admin_api_object<'a>(
617        self,
618        state: &crate::State,
619        _args: Self::ExtraArgs<'a>,
620    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
621        let api_object = AdminApiEggConfiguration::init_hooks(&self, state).await?;
622
623        let api_object = finish_extendible!(
624            AdminApiEggConfiguration {
625                uuid: self.uuid,
626                name: self.name,
627                description: self.description,
628                order: self.order,
629                matcher: self.matcher,
630                config_allocations: self.config_allocations,
631                config_startup: self.config_startup,
632                config_routes: self.config_routes,
633                config_features: self.config_features,
634                created: self.created.and_utc(),
635            },
636            api_object,
637            state
638        )?;
639
640        Ok(api_object)
641    }
642}
643
644#[async_trait::async_trait]
645impl ByUuid for EggConfiguration {
646    async fn by_uuid(
647        database: &crate::database::Database,
648        uuid: uuid::Uuid,
649    ) -> Result<Self, crate::database::DatabaseError> {
650        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
651            r#"
652            SELECT {}
653            FROM egg_configurations
654            WHERE egg_configurations.uuid = $1
655            "#,
656            Self::columns_sql(None)
657        )))
658        .bind(uuid)
659        .fetch_one(database.read())
660        .await?;
661
662        Self::map(None, &row)
663    }
664
665    async fn by_uuid_with_transaction(
666        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
667        uuid: uuid::Uuid,
668    ) -> Result<Self, crate::database::DatabaseError> {
669        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
670            r#"
671            SELECT {}
672            FROM egg_configurations
673            WHERE egg_configurations.uuid = $1
674            "#,
675            Self::columns_sql(None)
676        )))
677        .bind(uuid)
678        .fetch_one(&mut **transaction)
679        .await?;
680
681        Self::map(None, &row)
682    }
683}
684
685#[derive(ToSchema, Deserialize, Validate)]
686pub struct CreateEggConfigurationOptions {
687    #[garde(length(chars, min = 1, max = 255))]
688    #[schema(min_length = 1, max_length = 255)]
689    pub name: compact_str::CompactString,
690    #[garde(length(chars, min = 1, max = 1024))]
691    #[schema(min_length = 1, max_length = 1024)]
692    pub description: Option<compact_str::CompactString>,
693    #[garde(skip)]
694    pub order: i16,
695    #[garde(dive, custom(EggConfigurationMatcher::validate_nesting))]
696    pub matcher: EggConfigurationMatcher,
697    #[garde(inner(custom(validate_config_allocations)))]
698    #[schema(inline)]
699    pub config_allocations: Option<EggConfigAllocations>,
700    #[garde(dive)]
701    #[schema(inline)]
702    pub config_startup: Option<EggConfigStartup>,
703    #[garde(dive)]
704    #[schema(inline)]
705    pub config_routes: Option<EggConfigRoutes>,
706    #[garde(dive)]
707    #[schema(inline)]
708    pub config_features: Option<EggConfigFeatures>,
709}
710
711#[async_trait::async_trait]
712impl CreatableModel for EggConfiguration {
713    type CreateOptions<'a> = CreateEggConfigurationOptions;
714    type CreateResult = Self;
715
716    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
717        static CREATE_LISTENERS: LazyLock<CreateListenerList<EggConfiguration>> =
718            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
719
720        &CREATE_LISTENERS
721    }
722
723    async fn create_with_transaction(
724        state: &crate::State,
725        mut options: Self::CreateOptions<'_>,
726        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
727    ) -> Result<Self, crate::database::DatabaseError> {
728        options.validate()?;
729
730        let mut query_builder = InsertQueryBuilder::new("egg_configurations");
731
732        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
733
734        query_builder
735            .set("name", &options.name)
736            .set("description", &options.description)
737            .set("order_", options.order)
738            .set("matcher", serde_json::to_value(&options.matcher)?)
739            .set(
740                "config_allocations",
741                options
742                    .config_allocations
743                    .as_ref()
744                    .map(serde_json::to_value)
745                    .transpose()?,
746            )
747            .set(
748                "config_startup",
749                options
750                    .config_startup
751                    .as_ref()
752                    .map(serde_json::to_value)
753                    .transpose()?,
754            )
755            .set(
756                "config_routes",
757                options
758                    .config_routes
759                    .as_ref()
760                    .map(serde_json::to_value)
761                    .transpose()?,
762            )
763            .set(
764                "config_features",
765                options
766                    .config_features
767                    .as_ref()
768                    .map(serde_json::to_value)
769                    .transpose()?,
770            );
771
772        let row = query_builder
773            .returning(&Self::columns_sql(None))
774            .fetch_one(&mut **transaction)
775            .await?;
776        let mut egg_configuration = Self::map(None, &row)?;
777
778        Self::run_after_create_handlers(&mut egg_configuration, &options, state, transaction)
779            .await?;
780
781        Ok(egg_configuration)
782    }
783}
784
785#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
786pub struct UpdateEggConfigurationOptions {
787    #[garde(length(chars, min = 1, max = 255))]
788    #[schema(min_length = 1, max_length = 255)]
789    pub name: Option<compact_str::CompactString>,
790    #[garde(length(chars, min = 1, max = 1024))]
791    #[schema(min_length = 1, max_length = 1024)]
792    #[serde(
793        default,
794        skip_serializing_if = "Option::is_none",
795        with = "::serde_with::rust::double_option"
796    )]
797    pub description: Option<Option<compact_str::CompactString>>,
798    #[garde(skip)]
799    pub order: Option<i16>,
800    #[garde(dive, custom(EggConfigurationMatcher::validate_optional_nesting))]
801    pub matcher: Option<EggConfigurationMatcher>,
802
803    #[garde(inner(inner(custom(validate_config_allocations))))]
804    #[schema(inline)]
805    #[serde(
806        default,
807        skip_serializing_if = "Option::is_none",
808        with = "::serde_with::rust::double_option"
809    )]
810    pub config_allocations: Option<Option<EggConfigAllocations>>,
811    #[garde(dive)]
812    #[schema(inline)]
813    #[serde(
814        default,
815        skip_serializing_if = "Option::is_none",
816        with = "::serde_with::rust::double_option"
817    )]
818    pub config_startup: Option<Option<EggConfigStartup>>,
819    #[garde(dive)]
820    #[schema(inline)]
821    #[serde(
822        default,
823        skip_serializing_if = "Option::is_none",
824        with = "::serde_with::rust::double_option"
825    )]
826    pub config_routes: Option<Option<EggConfigRoutes>>,
827    #[garde(dive)]
828    #[schema(inline)]
829    #[serde(
830        default,
831        skip_serializing_if = "Option::is_none",
832        with = "::serde_with::rust::double_option"
833    )]
834    pub config_features: Option<Option<EggConfigFeatures>>,
835}
836
837#[async_trait::async_trait]
838impl UpdatableModel for EggConfiguration {
839    type UpdateOptions = UpdateEggConfigurationOptions;
840
841    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
842        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggConfiguration>> =
843            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
844
845        &UPDATE_LISTENERS
846    }
847
848    async fn update_with_transaction(
849        &mut self,
850        state: &crate::State,
851        mut options: Self::UpdateOptions,
852        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
853    ) -> Result<(), crate::database::DatabaseError> {
854        options.validate()?;
855
856        let mut query_builder = UpdateQueryBuilder::new("egg_configurations");
857
858        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
859            .await?;
860
861        query_builder
862            .set("name", options.name.as_ref())
863            .set(
864                "description",
865                options.description.as_ref().map(|d| d.as_ref()),
866            )
867            .set("order_", options.order)
868            .set(
869                "matcher",
870                options
871                    .matcher
872                    .as_ref()
873                    .map(serde_json::to_value)
874                    .transpose()?,
875            )
876            .set(
877                "config_allocations",
878                options
879                    .config_allocations
880                    .as_ref()
881                    .map(|c| c.as_ref().map(serde_json::to_value).transpose())
882                    .transpose()?,
883            )
884            .set(
885                "config_startup",
886                options
887                    .config_startup
888                    .as_ref()
889                    .map(|c| c.as_ref().map(serde_json::to_value).transpose())
890                    .transpose()?,
891            )
892            .set(
893                "config_routes",
894                options
895                    .config_routes
896                    .as_ref()
897                    .map(|c| c.as_ref().map(serde_json::to_value).transpose())
898                    .transpose()?,
899            )
900            .set(
901                "config_features",
902                options
903                    .config_features
904                    .as_ref()
905                    .map(|c| c.as_ref().map(serde_json::to_value).transpose())
906                    .transpose()?,
907            )
908            .where_eq("uuid", self.uuid);
909
910        query_builder.execute(&mut **transaction).await?;
911
912        if let Some(name) = options.name {
913            self.name = name;
914        }
915        if let Some(description) = options.description {
916            self.description = description;
917        }
918        if let Some(order) = options.order {
919            self.order = order;
920        }
921        if let Some(matcher) = options.matcher {
922            self.matcher = matcher;
923        }
924        if let Some(config_allocations) = options.config_allocations {
925            self.config_allocations = config_allocations;
926        }
927        if let Some(config_startup) = options.config_startup {
928            self.config_startup = config_startup;
929        }
930        if let Some(config_routes) = options.config_routes {
931            self.config_routes = config_routes;
932        }
933        if let Some(config_features) = options.config_features {
934            self.config_features = config_features;
935        }
936
937        self.run_after_update_handlers(state, transaction).await?;
938
939        Ok(())
940    }
941}
942
943#[async_trait::async_trait]
944impl DeletableModel for EggConfiguration {
945    type DeleteOptions = ();
946
947    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
948        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggConfiguration>> =
949            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
950
951        &DELETE_LISTENERS
952    }
953
954    async fn delete_with_transaction(
955        &self,
956        state: &crate::State,
957        options: Self::DeleteOptions,
958        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
959    ) -> Result<(), anyhow::Error> {
960        self.run_delete_handlers(&options, state, transaction)
961            .await?;
962
963        sqlx::query(
964            r#"
965            DELETE FROM egg_configurations
966            WHERE egg_configurations.uuid = $1
967            "#,
968        )
969        .bind(self.uuid)
970        .execute(&mut **transaction)
971        .await?;
972
973        self.run_after_delete_handlers(&options, state, transaction)
974            .await?;
975
976        Ok(())
977    }
978}
979
980#[derive(Validate)]
981pub struct DuplicateEggConfigurationOptions {
982    #[garde(length(chars, min = 1, max = 255))]
983    pub name: compact_str::CompactString,
984}
985
986#[async_trait::async_trait]
987impl DuplicableModel for EggConfiguration {
988    type DuplicateOptions<'a> = DuplicateEggConfigurationOptions;
989    type DuplicateResult = Self;
990
991    fn get_duplicate_handlers() -> &'static LazyLock<DuplicateHandlerList<Self>> {
992        static DUPLICATE_LISTENERS: LazyLock<DuplicateHandlerList<EggConfiguration>> =
993            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
994
995        &DUPLICATE_LISTENERS
996    }
997
998    async fn duplicate_with_transaction(
999        &self,
1000        state: &crate::State,
1001        options: Self::DuplicateOptions<'_>,
1002        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1003    ) -> Result<Self, crate::database::DatabaseError> {
1004        options.validate()?;
1005
1006        self.run_duplicate_handlers(&options, state, transaction)
1007            .await?;
1008
1009        let mut query_builder = InsertQueryBuilder::new("egg_configurations");
1010
1011        query_builder
1012            .set("name", &options.name)
1013            .set("description", &self.description)
1014            .set("order_", self.order)
1015            .set("matcher", serde_json::to_value(&self.matcher)?)
1016            .set(
1017                "config_allocations",
1018                self.config_allocations
1019                    .as_ref()
1020                    .map(serde_json::to_value)
1021                    .transpose()?,
1022            )
1023            .set(
1024                "config_startup",
1025                self.config_startup
1026                    .as_ref()
1027                    .map(serde_json::to_value)
1028                    .transpose()?,
1029            )
1030            .set(
1031                "config_routes",
1032                self.config_routes
1033                    .as_ref()
1034                    .map(serde_json::to_value)
1035                    .transpose()?,
1036            )
1037            .set(
1038                "config_features",
1039                self.config_features
1040                    .as_ref()
1041                    .map(serde_json::to_value)
1042                    .transpose()?,
1043            );
1044
1045        let row = query_builder
1046            .returning(&Self::columns_sql(None))
1047            .fetch_one(&mut **transaction)
1048            .await?;
1049        let mut egg_configuration = Self::map(None, &row)?;
1050
1051        self.run_after_duplicate_handlers(&mut egg_configuration, &options, state, transaction)
1052            .await?;
1053
1054        Ok(egg_configuration)
1055    }
1056}
1057
1058#[schema_extension_derive::extendible]
1059#[init_args(EggConfiguration, crate::State)]
1060#[hook_args(crate::State)]
1061#[derive(ToSchema, Serialize)]
1062#[schema(title = "AdminEggConfiguration")]
1063pub struct AdminApiEggConfiguration {
1064    pub uuid: uuid::Uuid,
1065
1066    pub name: compact_str::CompactString,
1067    pub description: Option<compact_str::CompactString>,
1068    pub order: i16,
1069
1070    pub matcher: EggConfigurationMatcher,
1071
1072    #[schema(inline)]
1073    pub config_allocations: Option<EggConfigAllocations>,
1074    #[schema(inline)]
1075    pub config_startup: Option<EggConfigStartup>,
1076    #[schema(inline)]
1077    pub config_routes: Option<EggConfigRoutes>,
1078    #[schema(inline)]
1079    pub config_features: Option<EggConfigFeatures>,
1080
1081    pub created: chrono::DateTime<chrono::Utc>,
1082}
1083
1084#[derive(Deserialize, Serialize, Default)]
1085pub struct MergedEggConfiguration {
1086    pub config_allocations: Option<EggConfigAllocations>,
1087    pub config_startup: Option<EggConfigStartup>,
1088    pub config_routes: Option<EggConfigRoutes>,
1089    pub config_features: EggConfigFeatures,
1090}
1091
1092impl MergedEggConfiguration {
1093    #[inline]
1094    pub fn firewall_enabled(&self) -> bool {
1095        self.config_features.firewall.unwrap_or(true)
1096    }
1097
1098    #[inline]
1099    pub fn private_network_enabled(&self) -> bool {
1100        self.config_features.private_network.unwrap_or(true)
1101    }
1102}
1103
1104#[async_trait::async_trait]
1105impl IntoApiObject for MergedEggConfiguration {
1106    type ApiObject = ApiEggConfiguration;
1107    type ExtraArgs<'a> = ();
1108
1109    async fn into_api_object<'a>(
1110        self,
1111        state: &crate::State,
1112        _args: Self::ExtraArgs<'a>,
1113    ) -> Result<Self::ApiObject, crate::database::DatabaseError> {
1114        let api_object = ApiEggConfiguration::init_hooks(&self, state).await?;
1115
1116        let api_object = finish_extendible!(
1117            ApiEggConfiguration {
1118                allocation_self_assign_enabled: self
1119                    .config_allocations
1120                    .as_ref()
1121                    .is_some_and(|c| c.user_self_assign.enabled),
1122                allocation_self_assign_require_primary: self
1123                    .config_allocations
1124                    .as_ref()
1125                    .is_some_and(|c| c.user_self_assign.require_primary_allocation),
1126                startup_allow_custom_command: self
1127                    .config_startup
1128                    .as_ref()
1129                    .is_some_and(|c| c.allow_custom_startup_command),
1130                firewall_enabled: self.firewall_enabled(),
1131                private_network_enabled: self.private_network_enabled(),
1132                route_order: self.config_routes.map(|c| c.order),
1133            },
1134            api_object,
1135            state
1136        )?;
1137
1138        Ok(api_object)
1139    }
1140}
1141
1142#[schema_extension_derive::extendible]
1143#[init_args(MergedEggConfiguration, crate::State)]
1144#[hook_args(crate::State)]
1145#[derive(ToSchema, Serialize)]
1146#[schema(title = "NestEggConfiguration")]
1147pub struct ApiEggConfiguration {
1148    pub allocation_self_assign_enabled: bool,
1149    pub allocation_self_assign_require_primary: bool,
1150    pub startup_allow_custom_command: bool,
1151    pub firewall_enabled: bool,
1152    pub private_network_enabled: bool,
1153    pub route_order: Option<Vec<crate::settings::RouteOrderItem>>,
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159
1160    const EGG: uuid::Uuid = uuid::Uuid::from_u128(1);
1161    const NEST: uuid::Uuid = uuid::Uuid::from_u128(2);
1162    const OTHER_EGG: uuid::Uuid = uuid::Uuid::from_u128(3);
1163    const OTHER_NEST: uuid::Uuid = uuid::Uuid::from_u128(4);
1164    const NODE: uuid::Uuid = uuid::Uuid::from_u128(5);
1165    const LOCATION: uuid::Uuid = uuid::Uuid::from_u128(6);
1166    const OTHER_NODE: uuid::Uuid = uuid::Uuid::from_u128(7);
1167    const OTHER_LOCATION: uuid::Uuid = uuid::Uuid::from_u128(8);
1168
1169    fn target(egg_uuid: uuid::Uuid, nest_uuid: uuid::Uuid) -> EggConfigurationTarget {
1170        EggConfigurationTarget {
1171            egg_uuid,
1172            nest_uuid,
1173            node_uuid: NODE,
1174            location_uuid: LOCATION,
1175        }
1176    }
1177
1178    fn config(
1179        matcher: EggConfigurationMatcher,
1180        startup: Option<bool>,
1181        features: Option<EggConfigFeatures>,
1182    ) -> EggConfiguration {
1183        EggConfiguration {
1184            uuid: uuid::Uuid::new_v4(),
1185            name: "test".into(),
1186            description: None,
1187            order: 0,
1188            matcher,
1189            config_allocations: None,
1190            config_startup: startup.map(|allow_custom_startup_command| EggConfigStartup {
1191                allow_custom_startup_command,
1192            }),
1193            config_routes: None,
1194            config_features: features,
1195            created: chrono::NaiveDateTime::default(),
1196            extension_data: Vec::new(),
1197        }
1198    }
1199
1200    fn features(
1201        firewall: Option<bool>,
1202        private_network: Option<bool>,
1203    ) -> Option<EggConfigFeatures> {
1204        Some(EggConfigFeatures {
1205            firewall,
1206            private_network,
1207        })
1208    }
1209
1210    // EggConfigurationMatcher::matches
1211    #[test]
1212    fn matcher_combinators_and_leaves() {
1213        let eggs = |eggs: Vec<uuid::Uuid>| EggConfigurationMatcher::Eggs { eggs };
1214        let nests = |nests: Vec<uuid::Uuid>| EggConfigurationMatcher::Nests { nests };
1215
1216        assert!(EggConfigurationMatcher::All.matches(&target(EGG, NEST)));
1217        assert!(!EggConfigurationMatcher::Never.matches(&target(EGG, NEST)));
1218        assert!(eggs(vec![OTHER_EGG, EGG]).matches(&target(EGG, NEST)));
1219        assert!(!eggs(vec![OTHER_EGG]).matches(&target(EGG, NEST)));
1220        assert!(nests(vec![NEST]).matches(&target(EGG, NEST)));
1221        assert!(!nests(vec![OTHER_NEST]).matches(&target(EGG, NEST)));
1222        assert!(!nests(vec![EGG]).matches(&target(EGG, NEST)));
1223
1224        assert!(
1225            EggConfigurationMatcher::And {
1226                matchers: vec![eggs(vec![EGG]), nests(vec![NEST])],
1227            }
1228            .matches(&target(EGG, NEST))
1229        );
1230        assert!(
1231            !EggConfigurationMatcher::And {
1232                matchers: vec![eggs(vec![EGG]), nests(vec![OTHER_NEST])],
1233            }
1234            .matches(&target(EGG, NEST))
1235        );
1236        assert!(
1237            EggConfigurationMatcher::Or {
1238                matchers: vec![eggs(vec![OTHER_EGG]), nests(vec![NEST])],
1239            }
1240            .matches(&target(EGG, NEST))
1241        );
1242        assert!(
1243            !EggConfigurationMatcher::Or {
1244                matchers: vec![eggs(vec![OTHER_EGG]), nests(vec![OTHER_NEST])],
1245            }
1246            .matches(&target(EGG, NEST))
1247        );
1248        assert!(
1249            EggConfigurationMatcher::Not {
1250                matcher: Box::new(eggs(vec![OTHER_EGG])),
1251            }
1252            .matches(&target(EGG, NEST))
1253        );
1254        assert!(
1255            !EggConfigurationMatcher::Not {
1256                matcher: Box::new(nests(vec![NEST])),
1257            }
1258            .matches(&target(EGG, NEST))
1259        );
1260    }
1261
1262    // EggConfigurationMatcher (Deserialize)
1263    #[test]
1264    fn legacy_backfill_json_deserializes_to_eggs_matcher() {
1265        let matcher: EggConfigurationMatcher = serde_json::from_value(serde_json::json!({
1266            "type": "eggs",
1267            "eggs": [EGG.to_string()],
1268        }))
1269        .unwrap();
1270
1271        assert!(matches!(&matcher, EggConfigurationMatcher::Eggs { eggs } if eggs == &[EGG]));
1272        assert!(matcher.matches(&target(EGG, NEST)));
1273        assert!(!matcher.matches(&target(OTHER_EGG, NEST)));
1274    }
1275
1276    // EggConfigurationMatcher::matches
1277    #[test]
1278    fn node_and_location_leaves_match_their_own_field() {
1279        let elsewhere = EggConfigurationTarget {
1280            node_uuid: OTHER_NODE,
1281            location_uuid: OTHER_LOCATION,
1282            ..target(EGG, NEST)
1283        };
1284        let nodes = |nodes: Vec<uuid::Uuid>| EggConfigurationMatcher::Nodes { nodes };
1285        let locations =
1286            |locations: Vec<uuid::Uuid>| EggConfigurationMatcher::Locations { locations };
1287
1288        assert!(nodes(vec![OTHER_NODE, NODE]).matches(&target(EGG, NEST)));
1289        assert!(!nodes(vec![NODE]).matches(&elsewhere));
1290        assert!(!nodes(vec![LOCATION]).matches(&target(EGG, NEST)));
1291        assert!(locations(vec![LOCATION]).matches(&target(EGG, NEST)));
1292        assert!(!locations(vec![LOCATION]).matches(&elsewhere));
1293        assert!(!locations(vec![NODE]).matches(&target(EGG, NEST)));
1294
1295        let not_on_node = EggConfigurationMatcher::Not {
1296            matcher: Box::new(nodes(vec![NODE])),
1297        };
1298        assert!(!not_on_node.matches(&target(EGG, NEST)));
1299        assert!(not_on_node.matches(&elsewhere));
1300    }
1301
1302    // EggConfigurationMatcher::validate_nesting
1303    #[test]
1304    fn nesting_limit_counts_groups_only() {
1305        let nest = |inner: EggConfigurationMatcher| EggConfigurationMatcher::And {
1306            matchers: vec![inner],
1307        };
1308        let leaf = EggConfigurationMatcher::Eggs { eggs: vec![EGG] };
1309
1310        let at_limit = nest(EggConfigurationMatcher::Or {
1311            matchers: vec![EggConfigurationMatcher::Not {
1312                matcher: Box::new(leaf.clone()),
1313            }],
1314        });
1315        assert!(EggConfigurationMatcher::validate_nesting(&at_limit, &()).is_ok());
1316
1317        let over_limit = nest(at_limit);
1318        assert!(EggConfigurationMatcher::validate_nesting(&over_limit, &()).is_err());
1319
1320        assert!(EggConfigurationMatcher::validate_nesting(&leaf, &()).is_ok());
1321    }
1322
1323    // EggConfiguration::merge
1324    #[test]
1325    fn features_merge_per_field_across_configs() {
1326        let global = config(
1327            EggConfigurationMatcher::All,
1328            None,
1329            features(None, Some(false)),
1330        );
1331        let specific = config(
1332            EggConfigurationMatcher::Eggs { eggs: vec![EGG] },
1333            None,
1334            features(Some(false), None),
1335        );
1336
1337        let merged = EggConfiguration::merge([&global, &specific], &target(EGG, NEST));
1338
1339        assert!(!merged.firewall_enabled());
1340        assert!(!merged.private_network_enabled());
1341    }
1342
1343    #[test]
1344    fn later_config_wins_and_none_section_keeps_earlier() {
1345        let first = config(
1346            EggConfigurationMatcher::All,
1347            Some(true),
1348            features(Some(false), None),
1349        );
1350        let second = config(
1351            EggConfigurationMatcher::Nests { nests: vec![NEST] },
1352            Some(false),
1353            features(Some(true), None),
1354        );
1355        let third = config(EggConfigurationMatcher::All, None, None);
1356
1357        let merged = EggConfiguration::merge([&first, &second, &third], &target(EGG, NEST));
1358
1359        assert!(
1360            !merged
1361                .config_startup
1362                .as_ref()
1363                .unwrap()
1364                .allow_custom_startup_command
1365        );
1366        assert!(merged.firewall_enabled());
1367    }
1368
1369    #[test]
1370    fn non_matching_configs_are_skipped() {
1371        let global = config(EggConfigurationMatcher::All, Some(true), None);
1372        let other = config(
1373            EggConfigurationMatcher::Eggs {
1374                eggs: vec![OTHER_EGG],
1375            },
1376            Some(false),
1377            features(Some(false), Some(false)),
1378        );
1379
1380        let merged = EggConfiguration::merge([&global, &other], &target(EGG, NEST));
1381
1382        assert!(
1383            merged
1384                .config_startup
1385                .as_ref()
1386                .unwrap()
1387                .allow_custom_startup_command
1388        );
1389        assert!(merged.firewall_enabled());
1390        assert!(merged.private_network_enabled());
1391    }
1392
1393    #[test]
1394    fn same_egg_resolves_per_location() {
1395        let global = config(
1396            EggConfigurationMatcher::All,
1397            None,
1398            features(None, Some(false)),
1399        );
1400        let on_location = config(
1401            EggConfigurationMatcher::And {
1402                matchers: vec![
1403                    EggConfigurationMatcher::Eggs { eggs: vec![EGG] },
1404                    EggConfigurationMatcher::Locations {
1405                        locations: vec![LOCATION],
1406                    },
1407                ],
1408            },
1409            None,
1410            features(None, Some(true)),
1411        );
1412        let other_location = EggConfigurationTarget {
1413            location_uuid: OTHER_LOCATION,
1414            ..target(EGG, NEST)
1415        };
1416
1417        let here = EggConfiguration::merge([&global, &on_location], &target(EGG, NEST));
1418        let there = EggConfiguration::merge([&global, &on_location], &other_location);
1419
1420        assert!(here.private_network_enabled());
1421        assert!(!there.private_network_enabled());
1422    }
1423
1424    #[test]
1425    fn features_default_enabled_without_configs() {
1426        let merged = EggConfiguration::merge([], &target(EGG, NEST));
1427
1428        assert!(merged.firewall_enabled());
1429        assert!(merged.private_network_enabled());
1430        assert!(merged.config_startup.is_none());
1431    }
1432}