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 Storage {
90 storage: ResourceRef,
92 events: Vec<String>,
94 },
95 Schedule {
97 cron: String,
99 },
100}
101
102impl FunctionTrigger {
103 pub fn queue<R: ?Sized>(queue: &R) -> Self
107 where
108 for<'a> &'a R: Into<ResourceRef>,
109 {
110 let queue_ref: ResourceRef = queue.into();
111 FunctionTrigger::Queue { queue: queue_ref }
112 }
113
114 pub fn storage<R: ?Sized>(storage: &R, events: Vec<String>) -> Self
117 where
118 for<'a> &'a R: Into<ResourceRef>,
119 {
120 let storage_ref: ResourceRef = storage.into();
121 FunctionTrigger::Storage {
122 storage: storage_ref,
123 events,
124 }
125 }
126
127 pub fn schedule<S: Into<String>>(cron: S) -> Self {
130 FunctionTrigger::Schedule { cron: cron.into() }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138#[serde(rename_all = "camelCase", deny_unknown_fields)]
139#[builder(start_fn = new)]
140pub struct Function {
141 #[builder(start_fn)]
144 pub id: String,
145
146 #[builder(field)]
149 pub links: Vec<ResourceRef>,
150
151 #[builder(field)]
155 pub triggers: Vec<FunctionTrigger>,
156
157 pub permissions: String,
160
161 pub code: FunctionCode,
163
164 #[builder(default = default_memory_mb())]
168 #[serde(default = "default_memory_mb")]
169 #[cfg_attr(feature = "openapi", schema(default = default_memory_mb))]
170 pub memory_mb: u32,
171
172 #[builder(default = default_timeout_seconds())]
176 #[serde(default = "default_timeout_seconds")]
177 #[cfg_attr(feature = "openapi", schema(default = default_timeout_seconds))]
178 pub timeout_seconds: u32,
179
180 #[builder(default)]
182 #[serde(default)]
183 pub environment: HashMap<String, String>,
184
185 #[builder(default = default_ingress())]
187 #[serde(default = "default_ingress")]
188 #[cfg_attr(feature = "openapi", schema(default = default_ingress))]
189 pub ingress: Ingress,
190
191 #[builder(default = default_commands_enabled())]
194 #[serde(default = "default_commands_enabled")]
195 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
196 pub commands_enabled: bool,
197
198 pub concurrency_limit: Option<u32>,
201
202 pub readiness_probe: Option<ReadinessProbe>,
206}
207
208impl Function {
209 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("function");
211
212 pub fn get_permissions(&self) -> &str {
214 &self.permissions
215 }
216}
217
218fn default_memory_mb() -> u32 {
219 256
220}
221
222fn default_timeout_seconds() -> u32 {
223 180
224}
225
226fn default_ingress() -> Ingress {
227 Ingress::Private
228}
229
230fn default_commands_enabled() -> bool {
231 false
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
237#[serde(rename_all = "UPPERCASE")]
238#[derive(Default)]
239pub enum HttpMethod {
240 #[default]
241 Get,
242 Post,
243 Put,
244 Delete,
245 Head,
246 Options,
247 Patch,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
255#[serde(rename_all = "camelCase")]
256pub struct ReadinessProbe {
257 #[serde(default)]
260 pub method: HttpMethod,
261
262 #[serde(default = "default_probe_path")]
265 pub path: String,
266}
267
268fn default_probe_path() -> String {
269 "/".to_string()
270}
271
272impl Default for ReadinessProbe {
273 fn default() -> Self {
274 Self {
275 method: HttpMethod::default(),
276 path: default_probe_path(),
277 }
278 }
279}
280
281use crate::resources::function::function_builder::State;
282
283impl<S: State> FunctionBuilder<S> {
284 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
287 where
288 for<'a> &'a R: Into<ResourceRef>, {
290 let resource_ref: ResourceRef = resource.into();
292 self.links.push(resource_ref);
293 self
294 }
295
296 pub fn trigger(mut self, trigger: FunctionTrigger) -> Self {
312 self.triggers.push(trigger);
313 self
314 }
315}
316
317impl ResourceDefinition for Function {
319 fn get_resource_type(&self) -> ResourceType {
320 Self::RESOURCE_TYPE
321 }
322
323 fn id(&self) -> &str {
324 &self.id
325 }
326
327 fn get_dependencies(&self) -> Vec<ResourceRef> {
328 let mut dependencies = self.links.clone();
329
330 for trigger in &self.triggers {
332 match trigger {
333 FunctionTrigger::Queue { queue } => {
334 dependencies.push(queue.clone());
335 }
336 FunctionTrigger::Storage { storage, .. } => {
337 dependencies.push(storage.clone());
338 }
339 FunctionTrigger::Schedule { .. } => {
340 }
342 }
343 }
344
345 dependencies
346 }
347
348 fn get_permissions(&self) -> Option<&str> {
349 Some(&self.permissions)
350 }
351
352 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
353 let new_function = new_config
355 .as_any()
356 .downcast_ref::<Function>()
357 .ok_or_else(|| {
358 AlienError::new(ErrorData::UnexpectedResourceType {
359 resource_id: self.id.clone(),
360 expected: Self::RESOURCE_TYPE,
361 actual: new_config.get_resource_type(),
362 })
363 })?;
364
365 if self.id != new_function.id {
366 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
367 resource_id: self.id.clone(),
368 reason: "the 'id' field is immutable".to_string(),
369 }));
370 }
371 Ok(())
372 }
373
374 fn as_any(&self) -> &dyn Any {
375 self
376 }
377
378 fn as_any_mut(&mut self) -> &mut dyn Any {
379 self
380 }
381
382 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
383 Box::new(self.clone())
384 }
385
386 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
387 other.as_any().downcast_ref::<Function>() == Some(self)
388 }
389
390 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
391 serde_json::to_value(self)
392 }
393}
394
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
397#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
398#[serde(rename_all = "camelCase")]
399pub struct FunctionOutputs {
400 pub function_name: String,
402 #[serde(skip_serializing_if = "Option::is_none")]
404 pub url: Option<String>,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub identifier: Option<String>,
408 #[serde(skip_serializing_if = "Option::is_none")]
411 pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub commands_push_target: Option<String>,
418}
419
420impl ResourceOutputsDefinition for FunctionOutputs {
421 fn get_resource_type(&self) -> ResourceType {
422 Function::RESOURCE_TYPE.clone()
423 }
424
425 fn as_any(&self) -> &dyn Any {
426 self
427 }
428
429 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
430 Box::new(self.clone())
431 }
432
433 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
434 other.as_any().downcast_ref::<FunctionOutputs>() == Some(self)
435 }
436
437 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
438 serde_json::to_value(self)
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::Storage;
446
447 #[test]
448 fn test_function_builder_direct_refs() {
449 let dummy_storage = Storage::new("test-storage".to_string()).build();
450 let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
451
452 let function = Function::new("my-func".to_string())
453 .code(FunctionCode::Image {
454 image: "test-image".to_string(),
455 })
456 .permissions("execution".to_string())
457 .link(&dummy_storage) .link(&dummy_storage_2) .build();
460
461 assert_eq!(function.id, "my-func");
462 assert_eq!(
463 function.code,
464 FunctionCode::Image {
465 image: "test-image".to_string()
466 }
467 );
468
469 assert_eq!(function.permissions, "execution");
471
472 assert!(function
474 .links
475 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
476 assert!(function
477 .links
478 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
479 assert_eq!(function.links.len(), 2); }
481
482 #[test]
483 fn test_function_with_readiness_probe() {
484 let probe = ReadinessProbe {
485 method: HttpMethod::Post,
486 path: "/health".to_string(),
487 };
488
489 let function = Function::new("my-func".to_string())
490 .code(FunctionCode::Image {
491 image: "test-image".to_string(),
492 })
493 .permissions("execution".to_string())
494 .ingress(Ingress::Public)
495 .readiness_probe(probe.clone())
496 .build();
497
498 assert_eq!(function.id, "my-func");
499 assert_eq!(function.ingress, Ingress::Public);
500 assert_eq!(function.readiness_probe, Some(probe));
501 }
502
503 #[test]
504 fn test_readiness_probe_defaults() {
505 let probe = ReadinessProbe::default();
506 assert_eq!(probe.method, HttpMethod::Get);
507 assert_eq!(probe.path, "/");
508 }
509
510 #[test]
511 fn test_function_with_rust_toolchain() {
512 let function = Function::new("my-rust-func".to_string())
513 .code(FunctionCode::Source {
514 src: "./".to_string(),
515 toolchain: ToolchainConfig::Rust {
516 binary_name: "my-app".to_string(),
517 },
518 })
519 .permissions("execution".to_string())
520 .build();
521
522 assert_eq!(function.id, "my-rust-func");
523
524 match &function.code {
525 FunctionCode::Source { src, toolchain } => {
526 assert_eq!(src, "./");
527 assert_eq!(
528 toolchain,
529 &ToolchainConfig::Rust {
530 binary_name: "my-app".to_string(),
531 }
532 );
533 }
534 _ => panic!("Expected Source code"),
535 }
536 }
537
538 #[test]
539 fn test_function_with_typescript_toolchain() {
540 let function = Function::new("my-ts-func".to_string())
541 .code(FunctionCode::Source {
542 src: "./".to_string(),
543 toolchain: ToolchainConfig::TypeScript {
544 binary_name: Some("my-ts-func".to_string()),
545 },
546 })
547 .permissions("execution".to_string())
548 .build();
549
550 assert_eq!(function.id, "my-ts-func");
551
552 match &function.code {
553 FunctionCode::Source { src, toolchain } => {
554 assert_eq!(src, "./");
555 assert_eq!(
556 toolchain,
557 &ToolchainConfig::TypeScript {
558 binary_name: Some("my-ts-func".to_string())
559 }
560 );
561 }
562 _ => panic!("Expected Source code"),
563 }
564 }
565
566 #[test]
567 fn test_function_with_queue_trigger() {
568 use crate::Queue;
569
570 let queue = Queue::new("test-queue".to_string()).build();
571
572 let function = Function::new("triggered-func".to_string())
573 .code(FunctionCode::Image {
574 image: "test-image".to_string(),
575 })
576 .permissions("execution".to_string())
577 .trigger(FunctionTrigger::queue(&queue))
578 .build();
579
580 assert_eq!(function.triggers.len(), 1);
581 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[0] {
582 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
583 assert_eq!(queue_ref.id, "test-queue");
584 } else {
585 panic!("Expected queue trigger");
586 }
587 }
588
589 #[test]
590 fn test_function_trigger_dependencies() {
591 use crate::Queue;
592
593 let queue = Queue::new("test-queue".to_string()).build();
594 let storage = Storage::new("test-storage".to_string()).build();
595
596 let function = Function::new("triggered-func".to_string())
597 .code(FunctionCode::Image {
598 image: "test-image".to_string(),
599 })
600 .permissions("execution".to_string())
601 .link(&storage) .trigger(FunctionTrigger::queue(&queue)) .build();
604
605 let dependencies = function.get_dependencies();
606
607 assert_eq!(dependencies.len(), 2);
609 assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
610 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
611 }
612
613 #[test]
614 fn test_function_trigger_helper_methods() {
615 use crate::Queue;
616
617 let queue = Queue::new("my-queue".to_string()).build();
618
619 let trigger = FunctionTrigger::queue(&queue);
621
622 if let FunctionTrigger::Queue { queue: queue_ref } = trigger {
623 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
624 assert_eq!(queue_ref.id, "my-queue");
625 } else {
626 panic!("Expected queue trigger");
627 }
628 }
629
630 #[test]
631 fn test_function_with_multiple_triggers() {
632 use crate::Queue;
633
634 let queue1 = Queue::new("queue-1".to_string()).build();
635 let queue2 = Queue::new("queue-2".to_string()).build();
636
637 let function = Function::new("multi-triggered-func".to_string())
638 .code(FunctionCode::Image {
639 image: "test-image".to_string(),
640 })
641 .permissions("execution".to_string())
642 .trigger(FunctionTrigger::queue(&queue1))
643 .trigger(FunctionTrigger::queue(&queue2))
644 .trigger(FunctionTrigger::schedule("0 * * * *".to_string()))
645 .build();
646
647 assert_eq!(function.triggers.len(), 3);
648
649 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[0] {
651 assert_eq!(queue_ref.id, "queue-1");
652 } else {
653 panic!("Expected first trigger to be queue-1");
654 }
655
656 if let FunctionTrigger::Queue { queue: queue_ref } = &function.triggers[1] {
658 assert_eq!(queue_ref.id, "queue-2");
659 } else {
660 panic!("Expected second trigger to be queue-2");
661 }
662
663 if let FunctionTrigger::Schedule { cron } = &function.triggers[2] {
665 assert_eq!(cron, "0 * * * *");
666 } else {
667 panic!("Expected third trigger to be schedule");
668 }
669
670 let dependencies = function.get_dependencies();
672 assert_eq!(dependencies.len(), 2); assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
674 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
675 }
676
677 #[test]
678 fn test_function_with_commands_enabled() {
679 let function = Function::new("cmd-func".to_string())
680 .code(FunctionCode::Image {
681 image: "test-image".to_string(),
682 })
683 .permissions("execution".to_string())
684 .ingress(Ingress::Private)
685 .commands_enabled(true)
686 .build();
687
688 assert_eq!(function.id, "cmd-func");
689 assert_eq!(function.ingress, Ingress::Private);
690 assert_eq!(function.commands_enabled, true);
691 }
692
693 #[test]
694 fn test_function_defaults() {
695 let function = Function::new("default-func".to_string())
696 .code(FunctionCode::Image {
697 image: "test-image".to_string(),
698 })
699 .permissions("execution".to_string())
700 .build();
701
702 assert_eq!(function.ingress, Ingress::Private);
704 assert_eq!(function.commands_enabled, false);
705 assert_eq!(function.memory_mb, 256);
706 assert_eq!(function.timeout_seconds, 180);
707 }
708
709 #[test]
710 fn test_function_public_ingress_with_commands() {
711 let function = Function::new("public-cmd-func".to_string())
712 .code(FunctionCode::Image {
713 image: "test-image".to_string(),
714 })
715 .permissions("execution".to_string())
716 .ingress(Ingress::Public)
717 .commands_enabled(true)
718 .build();
719
720 assert_eq!(function.ingress, Ingress::Public);
721 assert_eq!(function.commands_enabled, true);
722 }
723}