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#[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#[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 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 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 #[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 #[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 #[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 #[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 #[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}