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