1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
3use crate::LoadBalancerEndpoint;
4use alien_error::AlienError;
5use bon::Builder;
6use serde::{Deserialize, Serialize};
7use std::any::Any;
8use std::collections::HashMap;
9use std::fmt::Debug;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14#[serde(rename_all = "camelCase")]
15pub enum Ingress {
16 Public,
18 Private,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26#[serde(rename_all = "camelCase", tag = "type")]
27pub enum FunctionCode {
28 #[serde(rename_all = "camelCase")]
30 Image {
31 image: String,
33 },
34 #[serde(rename_all = "camelCase")]
36 Source {
37 src: String,
39 toolchain: ToolchainConfig,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48#[serde(rename_all = "lowercase", tag = "type")]
49pub enum ToolchainConfig {
50 #[serde(rename_all = "camelCase")]
52 Rust {
53 binary_name: String,
55 },
56 #[serde(rename_all = "camelCase")]
58 TypeScript {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 binary_name: Option<String>,
62 },
63 #[serde(rename_all = "camelCase")]
65 Docker {
66 #[serde(skip_serializing_if = "Option::is_none")]
68 dockerfile: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 build_args: Option<HashMap<String, String>>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 target: Option<String>,
75 },
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
81#[serde(tag = "type", rename_all = "camelCase")]
82pub enum FunctionTrigger {
83 Queue {
85 queue: ResourceRef,
87 },
88 #[allow(dead_code)]
90 Storage {
91 storage: ResourceRef,
93 events: Vec<String>,
95 },
96 #[allow(dead_code)]
98 Schedule {
99 cron: String,
101 },
102}
103
104impl FunctionTrigger {
105 pub fn queue<R: ?Sized>(queue: &R) -> Self
109 where
110 for<'a> &'a R: Into<ResourceRef>,
111 {
112 let queue_ref: ResourceRef = queue.into();
113 FunctionTrigger::Queue { queue: queue_ref }
114 }
115
116 #[allow(dead_code)]
118 pub fn storage<R: ?Sized>(storage: &R, events: Vec<String>) -> Self
119 where
120 for<'a> &'a R: Into<ResourceRef>,
121 {
122 let storage_ref: ResourceRef = storage.into();
123 FunctionTrigger::Storage {
124 storage: storage_ref,
125 events,
126 }
127 }
128
129 #[allow(dead_code)]
131 pub fn schedule<S: Into<String>>(cron: S) -> Self {
132 FunctionTrigger::Schedule { cron: cron.into() }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
139#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
140#[serde(rename_all = "camelCase", deny_unknown_fields)]
141#[builder(start_fn = new)]
142pub struct Function {
143 #[builder(start_fn)]
146 pub id: String,
147
148 #[builder(field)]
151 pub links: Vec<ResourceRef>,
152
153 #[builder(field)]
157 pub triggers: Vec<FunctionTrigger>,
158
159 pub permissions: String,
162
163 pub code: FunctionCode,
165
166 #[builder(default = default_memory_mb())]
170 #[serde(default = "default_memory_mb")]
171 #[cfg_attr(feature = "openapi", schema(default = default_memory_mb))]
172 pub memory_mb: u32,
173
174 #[builder(default = default_timeout_seconds())]
178 #[serde(default = "default_timeout_seconds")]
179 #[cfg_attr(feature = "openapi", schema(default = default_timeout_seconds))]
180 pub timeout_seconds: u32,
181
182 #[builder(default)]
184 #[serde(default)]
185 pub environment: HashMap<String, String>,
186
187 #[builder(default = default_ingress())]
189 #[serde(default = "default_ingress")]
190 #[cfg_attr(feature = "openapi", schema(default = default_ingress))]
191 pub ingress: Ingress,
192
193 #[builder(default = default_commands_enabled())]
196 #[serde(default = "default_commands_enabled")]
197 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
198 pub commands_enabled: bool,
199
200 pub concurrency_limit: Option<u32>,
203
204 pub readiness_probe: Option<ReadinessProbe>,
208}
209
210impl Function {
211 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("function");
213
214 pub fn get_permissions(&self) -> &str {
216 &self.permissions
217 }
218}
219
220fn default_memory_mb() -> u32 {
221 256
222}
223
224fn default_timeout_seconds() -> u32 {
225 180
226}
227
228fn default_ingress() -> Ingress {
229 Ingress::Private
230}
231
232fn default_commands_enabled() -> bool {
233 false
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
239#[serde(rename_all = "UPPERCASE")]
240#[derive(Default)]
241pub enum HttpMethod {
242 #[default]
243 Get,
244 Post,
245 Put,
246 Delete,
247 Head,
248 Options,
249 Patch,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
257#[serde(rename_all = "camelCase")]
258pub struct ReadinessProbe {
259 #[serde(default)]
262 pub method: HttpMethod,
263
264 #[serde(default = "default_probe_path")]
267 pub path: String,
268}
269
270fn default_probe_path() -> String {
271 "/".to_string()
272}
273
274impl Default for ReadinessProbe {
275 fn default() -> Self {
276 Self {
277 method: HttpMethod::default(),
278 path: default_probe_path(),
279 }
280 }
281}
282
283use crate::resources::function::function_builder::State;
284
285impl<S: State> FunctionBuilder<S> {
286 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
289 where
290 for<'a> &'a R: Into<ResourceRef>, {
292 let resource_ref: ResourceRef = resource.into();
294 self.links.push(resource_ref);
295 self
296 }
297
298 pub fn trigger(mut self, trigger: FunctionTrigger) -> Self {
314 self.triggers.push(trigger);
315 self
316 }
317}
318
319impl ResourceDefinition for Function {
321 fn get_resource_type(&self) -> ResourceType {
322 Self::RESOURCE_TYPE
323 }
324
325 fn id(&self) -> &str {
326 &self.id
327 }
328
329 fn get_dependencies(&self) -> Vec<ResourceRef> {
330 let mut dependencies = self.links.clone();
331
332 for trigger in &self.triggers {
334 match trigger {
335 FunctionTrigger::Queue { queue } => {
336 dependencies.push(queue.clone());
337 }
338 FunctionTrigger::Storage { storage, .. } => {
339 dependencies.push(storage.clone());
340 }
341 FunctionTrigger::Schedule { .. } => {
342 }
344 }
345 }
346
347 dependencies
348 }
349
350 fn get_permissions(&self) -> Option<&str> {
351 Some(&self.permissions)
352 }
353
354 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
355 let new_function = new_config
357 .as_any()
358 .downcast_ref::<Function>()
359 .ok_or_else(|| {
360 AlienError::new(ErrorData::UnexpectedResourceType {
361 resource_id: self.id.clone(),
362 expected: Self::RESOURCE_TYPE,
363 actual: new_config.get_resource_type(),
364 })
365 })?;
366
367 if self.id != new_function.id {
368 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
369 resource_id: self.id.clone(),
370 reason: "the 'id' field is immutable".to_string(),
371 }));
372 }
373 Ok(())
374 }
375
376 fn as_any(&self) -> &dyn Any {
377 self
378 }
379
380 fn as_any_mut(&mut self) -> &mut dyn Any {
381 self
382 }
383
384 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
385 Box::new(self.clone())
386 }
387
388 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
389 other.as_any().downcast_ref::<Function>() == Some(self)
390 }
391
392 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
393 serde_json::to_value(self)
394 }
395}
396
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
400#[serde(rename_all = "camelCase")]
401pub struct FunctionOutputs {
402 pub function_name: String,
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub url: Option<String>,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub identifier: Option<String>,
410 #[serde(skip_serializing_if = "Option::is_none")]
413 pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
414}
415
416impl ResourceOutputsDefinition for FunctionOutputs {
417 fn get_resource_type(&self) -> ResourceType {
418 Function::RESOURCE_TYPE.clone()
419 }
420
421 fn as_any(&self) -> &dyn Any {
422 self
423 }
424
425 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
426 Box::new(self.clone())
427 }
428
429 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
430 other.as_any().downcast_ref::<FunctionOutputs>() == Some(self)
431 }
432
433 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
434 serde_json::to_value(self)
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::Storage;
442
443 #[test]
444 fn test_function_builder_direct_refs() {
445 let dummy_storage = Storage::new("test-storage".to_string()).build();
446 let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
447
448 let function = Function::new("my-func".to_string())
449 .code(FunctionCode::Image {
450 image: "test-image".to_string(),
451 })
452 .permissions("execution".to_string())
453 .link(&dummy_storage) .link(&dummy_storage_2) .build();
456
457 assert_eq!(function.id, "my-func");
458 assert_eq!(
459 function.code,
460 FunctionCode::Image {
461 image: "test-image".to_string()
462 }
463 );
464
465 assert_eq!(function.permissions, "execution");
467
468 assert!(function
470 .links
471 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
472 assert!(function
473 .links
474 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
475 assert_eq!(function.links.len(), 2); }
477
478 #[test]
479 fn test_function_with_readiness_probe() {
480 let probe = ReadinessProbe {
481 method: HttpMethod::Post,
482 path: "/health".to_string(),
483 };
484
485 let function = Function::new("my-func".to_string())
486 .code(FunctionCode::Image {
487 image: "test-image".to_string(),
488 })
489 .permissions("execution".to_string())
490 .ingress(Ingress::Public)
491 .readiness_probe(probe.clone())
492 .build();
493
494 assert_eq!(function.id, "my-func");
495 assert_eq!(function.ingress, Ingress::Public);
496 assert_eq!(function.readiness_probe, Some(probe));
497 }
498
499 #[test]
500 fn test_readiness_probe_defaults() {
501 let probe = ReadinessProbe::default();
502 assert_eq!(probe.method, HttpMethod::Get);
503 assert_eq!(probe.path, "/");
504 }
505
506 #[test]
507 fn test_function_with_rust_toolchain() {
508 let function = Function::new("my-rust-func".to_string())
509 .code(FunctionCode::Source {
510 src: "./".to_string(),
511 toolchain: ToolchainConfig::Rust {
512 binary_name: "my-app".to_string(),
513 },
514 })
515 .permissions("execution".to_string())
516 .build();
517
518 assert_eq!(function.id, "my-rust-func");
519
520 match &function.code {
521 FunctionCode::Source { src, toolchain } => {
522 assert_eq!(src, "./");
523 assert_eq!(
524 toolchain,
525 &ToolchainConfig::Rust {
526 binary_name: "my-app".to_string(),
527 }
528 );
529 }
530 _ => panic!("Expected Source code"),
531 }
532 }
533
534 #[test]
535 fn test_function_with_typescript_toolchain() {
536 let function = Function::new("my-ts-func".to_string())
537 .code(FunctionCode::Source {
538 src: "./".to_string(),
539 toolchain: ToolchainConfig::TypeScript {
540 binary_name: Some("my-ts-func".to_string()),
541 },
542 })
543 .permissions("execution".to_string())
544 .build();
545
546 assert_eq!(function.id, "my-ts-func");
547
548 match &function.code {
549 FunctionCode::Source { src, toolchain } => {
550 assert_eq!(src, "./");
551 assert_eq!(
552 toolchain,
553 &ToolchainConfig::TypeScript {
554 binary_name: Some("my-ts-func".to_string())
555 }
556 );
557 }
558 _ => panic!("Expected Source code"),
559 }
560 }
561
562 #[test]
563 fn test_function_with_queue_trigger() {
564 use crate::Queue;
565
566 let queue = Queue::new("test-queue".to_string()).build();
567
568 let function = Function::new("triggered-func".to_string())
569 .code(FunctionCode::Image {
570 image: "test-image".to_string(),
571 })
572 .permissions("execution".to_string())
573 .trigger(FunctionTrigger::queue(&queue))
574 .build();
575
576 assert_eq!(function.triggers.len(), 1);
577 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[0] {
578 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
579 assert_eq!(queue_ref.id, "test-queue");
580 } else {
581 panic!("Expected queue trigger");
582 }
583 }
584
585 #[test]
586 fn test_function_trigger_dependencies() {
587 use crate::Queue;
588
589 let queue = Queue::new("test-queue".to_string()).build();
590 let storage = Storage::new("test-storage".to_string()).build();
591
592 let function = Function::new("triggered-func".to_string())
593 .code(FunctionCode::Image {
594 image: "test-image".to_string(),
595 })
596 .permissions("execution".to_string())
597 .link(&storage) .trigger(FunctionTrigger::queue(&queue)) .build();
600
601 let dependencies = function.get_dependencies();
602
603 assert_eq!(dependencies.len(), 2);
605 assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
606 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
607 }
608
609 #[test]
610 fn test_function_trigger_helper_methods() {
611 use crate::Queue;
612
613 let queue = Queue::new("my-queue".to_string()).build();
614
615 let trigger = FunctionTrigger::queue(&queue);
617
618 if let FunctionTrigger::Queue { queue: queue_ref } = trigger {
619 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
620 assert_eq!(queue_ref.id, "my-queue");
621 } else {
622 panic!("Expected queue trigger");
623 }
624 }
625
626 #[test]
627 fn test_function_with_multiple_triggers() {
628 use crate::Queue;
629
630 let queue1 = Queue::new("queue-1".to_string()).build();
631 let queue2 = Queue::new("queue-2".to_string()).build();
632
633 let function = Function::new("multi-triggered-func".to_string())
634 .code(FunctionCode::Image {
635 image: "test-image".to_string(),
636 })
637 .permissions("execution".to_string())
638 .trigger(FunctionTrigger::queue(&queue1))
639 .trigger(FunctionTrigger::queue(&queue2))
640 .trigger(FunctionTrigger::schedule("0 * * * *".to_string()))
641 .build();
642
643 assert_eq!(function.triggers.len(), 3);
644
645 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[0] {
647 assert_eq!(queue_ref.id, "queue-1");
648 } else {
649 panic!("Expected first trigger to be queue-1");
650 }
651
652 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[1] {
654 assert_eq!(queue_ref.id, "queue-2");
655 } else {
656 panic!("Expected second trigger to be queue-2");
657 }
658
659 if let FunctionTrigger::Schedule { cron } = &function.triggers[2] {
661 assert_eq!(cron, "0 * * * *");
662 } else {
663 panic!("Expected third trigger to be schedule");
664 }
665
666 let dependencies = function.get_dependencies();
668 assert_eq!(dependencies.len(), 2); assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
670 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
671 }
672
673 #[test]
674 fn test_function_with_commands_enabled() {
675 let function = Function::new("cmd-func".to_string())
676 .code(FunctionCode::Image {
677 image: "test-image".to_string(),
678 })
679 .permissions("execution".to_string())
680 .ingress(Ingress::Private)
681 .commands_enabled(true)
682 .build();
683
684 assert_eq!(function.id, "cmd-func");
685 assert_eq!(function.ingress, Ingress::Private);
686 assert_eq!(function.commands_enabled, true);
687 }
688
689 #[test]
690 fn test_function_defaults() {
691 let function = Function::new("default-func".to_string())
692 .code(FunctionCode::Image {
693 image: "test-image".to_string(),
694 })
695 .permissions("execution".to_string())
696 .build();
697
698 assert_eq!(function.ingress, Ingress::Private);
700 assert_eq!(function.commands_enabled, false);
701 assert_eq!(function.memory_mb, 256);
702 assert_eq!(function.timeout_seconds, 180);
703 }
704
705 #[test]
706 fn test_function_public_ingress_with_commands() {
707 let function = Function::new("public-cmd-func".to_string())
708 .code(FunctionCode::Image {
709 image: "test-image".to_string(),
710 })
711 .permissions("execution".to_string())
712 .ingress(Ingress::Public)
713 .commands_enabled(true)
714 .build();
715
716 assert_eq!(function.ingress, Ingress::Public);
717 assert_eq!(function.commands_enabled, true);
718 }
719
720}