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