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
319#[typetag::serde(name = "function")]
321impl ResourceDefinition for Function {
322 fn resource_type() -> ResourceType {
323 Self::RESOURCE_TYPE.clone()
324 }
325
326 fn get_resource_type(&self) -> ResourceType {
327 Self::resource_type()
328 }
329
330 fn id(&self) -> &str {
331 &self.id
332 }
333
334 fn get_dependencies(&self) -> Vec<ResourceRef> {
335 let mut dependencies = self.links.clone();
336
337 for trigger in &self.triggers {
339 match trigger {
340 FunctionTrigger::Queue { queue } => {
341 dependencies.push(queue.clone());
342 }
343 FunctionTrigger::Storage { storage, .. } => {
344 dependencies.push(storage.clone());
345 }
346 FunctionTrigger::Schedule { .. } => {
347 }
349 }
350 }
351
352 dependencies
353 }
354
355 fn get_permissions(&self) -> Option<&str> {
356 Some(&self.permissions)
357 }
358
359 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
360 let new_function = new_config
362 .as_any()
363 .downcast_ref::<Function>()
364 .ok_or_else(|| {
365 AlienError::new(ErrorData::UnexpectedResourceType {
366 resource_id: self.id.clone(),
367 expected: Self::RESOURCE_TYPE,
368 actual: new_config.get_resource_type(),
369 })
370 })?;
371
372 if self.id != new_function.id {
373 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
374 resource_id: self.id.clone(),
375 reason: "the 'id' field is immutable".to_string(),
376 }));
377 }
378 Ok(())
379 }
380
381 fn as_any(&self) -> &dyn Any {
382 self
383 }
384
385 fn as_any_mut(&mut self) -> &mut dyn Any {
386 self
387 }
388
389 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
390 Box::new(self.clone())
391 }
392
393 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
394 other.as_any().downcast_ref::<Function>() == Some(self)
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
401#[serde(rename_all = "camelCase")]
402pub struct FunctionOutputs {
403 pub function_name: String,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub url: Option<String>,
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub identifier: Option<String>,
411 #[serde(skip_serializing_if = "Option::is_none")]
414 pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
415}
416
417#[typetag::serde(name = "function")]
418impl ResourceOutputsDefinition for FunctionOutputs {
419 fn resource_type() -> ResourceType {
420 Function::RESOURCE_TYPE.clone()
421 }
422
423 fn as_any(&self) -> &dyn Any {
424 self
425 }
426
427 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
428 Box::new(self.clone())
429 }
430
431 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
432 other.as_any().downcast_ref::<FunctionOutputs>() == Some(self)
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::Storage;
440
441 #[test]
442 fn test_function_builder_direct_refs() {
443 let dummy_storage = Storage::new("test-storage".to_string()).build();
444 let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
445
446 let function = Function::new("my-func".to_string())
447 .code(FunctionCode::Image {
448 image: "test-image".to_string(),
449 })
450 .permissions("execution".to_string())
451 .link(&dummy_storage) .link(&dummy_storage_2) .build();
454
455 assert_eq!(function.id, "my-func");
456 assert_eq!(
457 function.code,
458 FunctionCode::Image {
459 image: "test-image".to_string()
460 }
461 );
462
463 assert_eq!(function.permissions, "execution");
465
466 assert!(function
468 .links
469 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
470 assert!(function
471 .links
472 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
473 assert_eq!(function.links.len(), 2); }
475
476 #[test]
477 fn test_function_with_readiness_probe() {
478 let probe = ReadinessProbe {
479 method: HttpMethod::Post,
480 path: "/health".to_string(),
481 };
482
483 let function = Function::new("my-func".to_string())
484 .code(FunctionCode::Image {
485 image: "test-image".to_string(),
486 })
487 .permissions("execution".to_string())
488 .ingress(Ingress::Public)
489 .readiness_probe(probe.clone())
490 .build();
491
492 assert_eq!(function.id, "my-func");
493 assert_eq!(function.ingress, Ingress::Public);
494 assert_eq!(function.readiness_probe, Some(probe));
495 }
496
497 #[test]
498 fn test_readiness_probe_defaults() {
499 let probe = ReadinessProbe::default();
500 assert_eq!(probe.method, HttpMethod::Get);
501 assert_eq!(probe.path, "/");
502 }
503
504 #[test]
505 fn test_function_with_rust_toolchain() {
506 let function = Function::new("my-rust-func".to_string())
507 .code(FunctionCode::Source {
508 src: "./".to_string(),
509 toolchain: ToolchainConfig::Rust {
510 binary_name: "my-app".to_string(),
511 },
512 })
513 .permissions("execution".to_string())
514 .build();
515
516 assert_eq!(function.id, "my-rust-func");
517
518 match &function.code {
519 FunctionCode::Source { src, toolchain } => {
520 assert_eq!(src, "./");
521 assert_eq!(
522 toolchain,
523 &ToolchainConfig::Rust {
524 binary_name: "my-app".to_string(),
525 }
526 );
527 }
528 _ => panic!("Expected Source code"),
529 }
530 }
531
532 #[test]
533 fn test_function_with_typescript_toolchain() {
534 let function = Function::new("my-ts-func".to_string())
535 .code(FunctionCode::Source {
536 src: "./".to_string(),
537 toolchain: ToolchainConfig::TypeScript {
538 binary_name: Some("my-ts-func".to_string()),
539 },
540 })
541 .permissions("execution".to_string())
542 .build();
543
544 assert_eq!(function.id, "my-ts-func");
545
546 match &function.code {
547 FunctionCode::Source { src, toolchain } => {
548 assert_eq!(src, "./");
549 assert_eq!(
550 toolchain,
551 &ToolchainConfig::TypeScript {
552 binary_name: Some("my-ts-func".to_string())
553 }
554 );
555 }
556 _ => panic!("Expected Source code"),
557 }
558 }
559
560 #[test]
561 fn test_function_with_queue_trigger() {
562 use crate::Queue;
563
564 let queue = Queue::new("test-queue".to_string()).build();
565
566 let function = Function::new("triggered-func".to_string())
567 .code(FunctionCode::Image {
568 image: "test-image".to_string(),
569 })
570 .permissions("execution".to_string())
571 .trigger(FunctionTrigger::queue(&queue))
572 .build();
573
574 assert_eq!(function.triggers.len(), 1);
575 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[0] {
576 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
577 assert_eq!(queue_ref.id, "test-queue");
578 } else {
579 panic!("Expected queue trigger");
580 }
581 }
582
583 #[test]
584 fn test_function_trigger_dependencies() {
585 use crate::Queue;
586
587 let queue = Queue::new("test-queue".to_string()).build();
588 let storage = Storage::new("test-storage".to_string()).build();
589
590 let function = Function::new("triggered-func".to_string())
591 .code(FunctionCode::Image {
592 image: "test-image".to_string(),
593 })
594 .permissions("execution".to_string())
595 .link(&storage) .trigger(FunctionTrigger::queue(&queue)) .build();
598
599 let dependencies = function.get_dependencies();
600
601 assert_eq!(dependencies.len(), 2);
603 assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
604 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
605 }
606
607 #[test]
608 fn test_function_trigger_helper_methods() {
609 use crate::Queue;
610
611 let queue = Queue::new("my-queue".to_string()).build();
612
613 let trigger = FunctionTrigger::queue(&queue);
615
616 if let FunctionTrigger::Queue { queue: queue_ref } = trigger {
617 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
618 assert_eq!(queue_ref.id, "my-queue");
619 } else {
620 panic!("Expected queue trigger");
621 }
622 }
623
624 #[test]
625 fn test_function_with_multiple_triggers() {
626 use crate::Queue;
627
628 let queue1 = Queue::new("queue-1".to_string()).build();
629 let queue2 = Queue::new("queue-2".to_string()).build();
630
631 let function = Function::new("multi-triggered-func".to_string())
632 .code(FunctionCode::Image {
633 image: "test-image".to_string(),
634 })
635 .permissions("execution".to_string())
636 .trigger(FunctionTrigger::queue(&queue1))
637 .trigger(FunctionTrigger::queue(&queue2))
638 .trigger(FunctionTrigger::schedule("0 * * * *".to_string()))
639 .build();
640
641 assert_eq!(function.triggers.len(), 3);
642
643 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[0] {
645 assert_eq!(queue_ref.id, "queue-1");
646 } else {
647 panic!("Expected first trigger to be queue-1");
648 }
649
650 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[1] {
652 assert_eq!(queue_ref.id, "queue-2");
653 } else {
654 panic!("Expected second trigger to be queue-2");
655 }
656
657 if let FunctionTrigger::Schedule { cron } = &function.triggers[2] {
659 assert_eq!(cron, "0 * * * *");
660 } else {
661 panic!("Expected third trigger to be schedule");
662 }
663
664 let dependencies = function.get_dependencies();
666 assert_eq!(dependencies.len(), 2); assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
668 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
669 }
670
671 #[test]
672 fn test_function_with_commands_enabled() {
673 let function = Function::new("cmd-func".to_string())
674 .code(FunctionCode::Image {
675 image: "test-image".to_string(),
676 })
677 .permissions("execution".to_string())
678 .ingress(Ingress::Private)
679 .commands_enabled(true)
680 .build();
681
682 assert_eq!(function.id, "cmd-func");
683 assert_eq!(function.ingress, Ingress::Private);
684 assert_eq!(function.commands_enabled, true);
685 }
686
687 #[test]
688 fn test_function_defaults() {
689 let function = Function::new("default-func".to_string())
690 .code(FunctionCode::Image {
691 image: "test-image".to_string(),
692 })
693 .permissions("execution".to_string())
694 .build();
695
696 assert_eq!(function.ingress, Ingress::Private);
698 assert_eq!(function.commands_enabled, false);
699 assert_eq!(function.memory_mb, 256);
700 assert_eq!(function.timeout_seconds, 180);
701 }
702
703 #[test]
704 fn test_function_public_ingress_with_commands() {
705 let function = Function::new("public-cmd-func".to_string())
706 .code(FunctionCode::Image {
707 image: "test-image".to_string(),
708 })
709 .permissions("execution".to_string())
710 .ingress(Ingress::Public)
711 .commands_enabled(true)
712 .build();
713
714 assert_eq!(function.ingress, Ingress::Public);
715 assert_eq!(function.commands_enabled, true);
716 }
717
718}