Skip to main content

alien_core/resources/
function.rs

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/// Controls network accessibility of the function.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14#[serde(rename_all = "camelCase")]
15pub enum Ingress {
16    /// Function accessible from the internet with an HTTPS URL
17    Public,
18    /// Function accessible only from within the customer's cloud via triggers, other functions, etc. No external HTTP URL is created.
19    Private,
20}
21
22/// Specifies the source of the function's executable code.
23/// This can be a pre-built container image or source code that the system will build.
24#[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    /// Container image.
29    #[serde(rename_all = "camelCase")]
30    Image {
31        /// Container image (e.g., `ghcr.io/myorg/myimage:latest`).
32        image: String,
33    },
34    /// Source code to be built.
35    #[serde(rename_all = "camelCase")]
36    Source {
37        /// The source directory to build from
38        src: String,
39        /// Toolchain configuration with type-safe options
40        toolchain: ToolchainConfig,
41    },
42}
43
44/// Configuration for different programming language toolchains.
45/// Each toolchain provides type-safe build configuration and auto-detection capabilities.
46#[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    /// Rust with Cargo build system
51    #[serde(rename_all = "camelCase")]
52    Rust {
53        /// Name of the binary to build and run
54        binary_name: String,
55    },
56    /// TypeScript/JavaScript compiled to single executable with Bun
57    #[serde(rename_all = "camelCase")]
58    TypeScript {
59        /// Name of the compiled binary (defaults to package.json name if not specified)
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        binary_name: Option<String>,
62    },
63    /// Docker build from Dockerfile
64    #[serde(rename_all = "camelCase")]
65    Docker {
66        /// Dockerfile path relative to src (default: "Dockerfile")
67        #[serde(skip_serializing_if = "Option::is_none")]
68        dockerfile: Option<String>,
69        /// Build arguments for docker build
70        #[serde(skip_serializing_if = "Option::is_none")]
71        build_args: Option<HashMap<String, String>>,
72        /// Multi-stage build target
73        #[serde(skip_serializing_if = "Option::is_none")]
74        target: Option<String>,
75    },
76}
77
78/// Defines what triggers a function execution
79#[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    /// Function triggered by queue messages (always 1 message per invocation)
84    Queue {
85        /// Reference to the queue resource
86        queue: ResourceRef,
87    },
88    /// Function triggered by storage events (object created, deleted, etc.)
89    Storage {
90        /// Reference to the storage resource
91        storage: ResourceRef,
92        /// Events to trigger on (e.g., ["created", "deleted"])
93        events: Vec<String>,
94    },
95    /// Function triggered on a schedule (cron expression)
96    Schedule {
97        /// Cron expression for scheduling (standard 5-field unix cron)
98        cron: String,
99    },
100}
101
102impl FunctionTrigger {
103    /// Creates a queue trigger for the specified queue resource.
104    /// The function will be automatically invoked when messages arrive in the queue.
105    /// Each message is processed individually (batch size of 1).
106    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    /// Creates a storage trigger for the specified storage resource.
115    /// The function will be invoked when matching events occur on the storage resource.
116    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    /// Creates a schedule trigger with the specified cron expression.
128    /// Uses standard 5-field unix cron format (minute hour day-of-month month day-of-week).
129    pub fn schedule<S: Into<String>>(cron: S) -> Self {
130        FunctionTrigger::Schedule { cron: cron.into() }
131    }
132}
133
134/// Represents a serverless function that executes code in response to triggers or direct invocations.
135/// Functions are the primary compute resource in serverless applications, designed to be stateless and ephemeral.
136#[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    /// Identifier for the function. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).
142    /// Maximum 64 characters.
143    #[builder(start_fn)]
144    pub id: String,
145
146    /// List of resource references this function depends on.
147    // TODO: We need to verify that the same link isn't added multiple times.
148    #[builder(field)]
149    pub links: Vec<ResourceRef>,
150
151    /// List of triggers that define what events automatically invoke this function.
152    /// If empty, the function is only invokable directly via HTTP calls or platform-specific invocation APIs.
153    /// When configured, the function will be automatically invoked when any of the specified trigger conditions are met.
154    #[builder(field)]
155    pub triggers: Vec<FunctionTrigger>,
156
157    /// Permission profile name that defines the permissions granted to this function.
158    /// This references a profile defined in the stack's permission definitions.
159    pub permissions: String,
160
161    /// Code for the function, either a pre-built image or source code to be built.
162    pub code: FunctionCode,
163
164    /// Memory allocated to the function in megabytes (MB).
165    /// Constraints: 128‑32768 MB (platform-specific limits may apply)
166    /// Default: 256
167    #[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    /// Maximum execution time for the function in seconds.
173    /// Constraints: 1‑3600 seconds (platform-specific limits may apply)
174    /// Default: 30
175    #[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    /// Key-value pairs to set as environment variables for the function.
181    #[builder(default)]
182    #[serde(default)]
183    pub environment: HashMap<String, String>,
184
185    /// Controls network accessibility of the function.
186    #[builder(default = default_ingress())]
187    #[serde(default = "default_ingress")]
188    #[cfg_attr(feature = "openapi", schema(default = default_ingress))]
189    pub ingress: Ingress,
190
191    /// Whether the function can receive remote commands via the Commands protocol.
192    /// When enabled, the runtime polls the manager for pending commands and executes registered handlers.
193    #[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    /// Maximum number of concurrent executions allowed for the function.
199    /// None means platform default applies.
200    pub concurrency_limit: Option<u32>,
201
202    /// Optional readiness probe configuration.
203    /// Only applicable for functions with Public ingress.
204    /// When configured, the probe will be executed after provisioning/update to verify the function is ready.
205    pub readiness_probe: Option<ReadinessProbe>,
206}
207
208impl Function {
209    /// The resource type identifier for Functions
210    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("function");
211
212    /// Returns the permission profile name for this function.
213    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/// HTTP method for readiness probe requests.
235#[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/// Configuration for HTTP-based readiness probe.
251/// This probe is executed after function provisioning/update to verify the function is ready to serve traffic.
252/// Only works with functions that have Public ingress.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
255#[serde(rename_all = "camelCase")]
256pub struct ReadinessProbe {
257    /// HTTP method to use for the probe request.
258    /// Default: GET
259    #[serde(default)]
260    pub method: HttpMethod,
261
262    /// Path to request for the probe (e.g., "/health", "/ready").
263    /// Default: "/"
264    #[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    /// Links the function to another resource with specified permissions.
285    /// Accepts a reference to any type `R` where `&R` can be converted into `ResourceRef`.
286    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
287    where
288        for<'a> &'a R: Into<ResourceRef>, // Use Higher-Rank Trait Bound (HRTB)
289    {
290        // Perform the conversion from &R to ResourceRef using .into()
291        let resource_ref: ResourceRef = resource.into();
292        self.links.push(resource_ref);
293        self
294    }
295
296    /// Adds a trigger to the function. Functions can have multiple triggers.
297    /// Each trigger will independently invoke the function when its conditions are met.
298    ///
299    /// # Examples
300    /// ```rust
301    /// # use alien_core::{Function, FunctionTrigger, FunctionCode, Queue};
302    /// # let queue1 = Queue::new("queue-1".to_string()).build();
303    /// # let queue2 = Queue::new("queue-2".to_string()).build();
304    /// let function = Function::new("my-function".to_string())
305    ///     .code(FunctionCode::Image { image: "test:latest".to_string() })
306    ///     .permissions("execution".to_string())
307    ///     .trigger(FunctionTrigger::queue(&queue1))
308    ///     .trigger(FunctionTrigger::queue(&queue2))
309    ///     .build();
310    /// ```
311    pub fn trigger(mut self, trigger: FunctionTrigger) -> Self {
312        self.triggers.push(trigger);
313        self
314    }
315}
316
317// Implementation of ResourceDefinition trait for Function
318impl 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        // Add trigger dependencies
331        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                    // Schedule triggers don't depend on other resources
341                }
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        // Downcast to Function type to use the existing validate_update method
354        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/// Outputs generated by a successfully provisioned Function.
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
397#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
398#[serde(rename_all = "camelCase")]
399pub struct FunctionOutputs {
400    /// The name of the function.
401    pub function_name: String,
402    /// The invocation URL (if applicable, e.g., for public ingress or specific platforms).
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub url: Option<String>,
405    /// The ARN or platform-specific identifier.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub identifier: Option<String>,
408    /// Load balancer endpoint information for DNS management (optional).
409    /// Used by the DNS controller to create custom domain mappings.
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
412    /// Push target for commands delivery. Platform-specific:
413    /// - AWS: Lambda function name or ARN
414    /// - GCP: Full Pub/Sub topic path (projects/{project}/topics/{topic})
415    /// - Azure: Service Bus "{namespace}/{queue}"
416    #[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) // Pass reference directly
458            .link(&dummy_storage_2) // Add a second link
459            .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        // Verify permissions was set correctly
470        assert_eq!(function.permissions, "execution");
471
472        // Verify links were added correctly
473        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); // Expect 2 links now
480    }
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) // regular link dependency
602            .trigger(FunctionTrigger::queue(&queue)) // trigger dependency
603            .build();
604
605        let dependencies = function.get_dependencies();
606
607        // Should have both link and trigger dependencies
608        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        // Test the helper method
620        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        // Check first queue trigger
650        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        // Check second queue trigger
657        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        // Check schedule trigger
664        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        // Check dependencies include both queues
671        let dependencies = function.get_dependencies();
672        assert_eq!(dependencies.len(), 2); // Only queues, schedule has no dependency
673        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        // Test that defaults are applied correctly
703        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}