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 (future implementation)
89    #[allow(dead_code)]
90    Storage {
91        /// Reference to the storage resource
92        storage: ResourceRef,
93        /// Events to trigger on (e.g., ["created", "deleted"])
94        events: Vec<String>,
95    },
96    /// Function triggered by scheduled events (future implementation)
97    #[allow(dead_code)]
98    Schedule {
99        /// Cron expression for scheduling
100        cron: String,
101    },
102}
103
104impl FunctionTrigger {
105    /// Creates a queue trigger for the specified queue resource.
106    /// The function will be automatically invoked when messages arrive in the queue.
107    /// Each message is processed individually (batch size of 1).
108    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    /// Creates a storage trigger for the specified storage resource (future implementation).
117    #[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    /// Creates a schedule trigger with the specified cron expression (future implementation).
130    #[allow(dead_code)]
131    pub fn schedule<S: Into<String>>(cron: S) -> Self {
132        FunctionTrigger::Schedule { cron: cron.into() }
133    }
134}
135
136/// Represents a serverless function that executes code in response to triggers or direct invocations.
137/// Functions are the primary compute resource in serverless applications, designed to be stateless and ephemeral.
138#[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    /// Identifier for the function. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).
144    /// Maximum 64 characters.
145    #[builder(start_fn)]
146    pub id: String,
147
148    /// List of resource references this function depends on.
149    // TODO: We need to verify that the same link isn't added multiple times.
150    #[builder(field)]
151    pub links: Vec<ResourceRef>,
152
153    /// List of triggers that define what events automatically invoke this function.
154    /// If empty, the function is only invokable directly via HTTP calls or platform-specific invocation APIs.
155    /// When configured, the function will be automatically invoked when any of the specified trigger conditions are met.
156    #[builder(field)]
157    pub triggers: Vec<FunctionTrigger>,
158
159    /// Permission profile name that defines the permissions granted to this function.
160    /// This references a profile defined in the stack's permission definitions.
161    pub permissions: String,
162
163    /// Code for the function, either a pre-built image or source code to be built.
164    pub code: FunctionCode,
165
166    /// Memory allocated to the function in megabytes (MB).
167    /// Constraints: 128‑32768 MB (platform-specific limits may apply)
168    /// Default: 256
169    #[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    /// Maximum execution time for the function in seconds.
175    /// Constraints: 1‑3600 seconds (platform-specific limits may apply)
176    /// Default: 30
177    #[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    /// Key-value pairs to set as environment variables for the function.
183    #[builder(default)]
184    #[serde(default)]
185    pub environment: HashMap<String, String>,
186
187    /// Controls network accessibility of the function.
188    #[builder(default = default_ingress())]
189    #[serde(default = "default_ingress")]
190    #[cfg_attr(feature = "openapi", schema(default = default_ingress))]
191    pub ingress: Ingress,
192
193    /// Whether the function can receive remote commands via the Commands protocol.
194    /// When enabled, the runtime polls the manager for pending commands and executes registered handlers.
195    #[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    /// Maximum number of concurrent executions allowed for the function.
201    /// None means platform default applies.
202    pub concurrency_limit: Option<u32>,
203
204    /// Optional readiness probe configuration.
205    /// Only applicable for functions with Public ingress.
206    /// When configured, the probe will be executed after provisioning/update to verify the function is ready.
207    pub readiness_probe: Option<ReadinessProbe>,
208}
209
210impl Function {
211    /// The resource type identifier for Functions
212    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("function");
213
214    /// Returns the permission profile name for this function.
215    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/// HTTP method for readiness probe requests.
237#[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/// Configuration for HTTP-based readiness probe.
253/// This probe is executed after function provisioning/update to verify the function is ready to serve traffic.
254/// Only works with functions that have Public ingress.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
257#[serde(rename_all = "camelCase")]
258pub struct ReadinessProbe {
259    /// HTTP method to use for the probe request.
260    /// Default: GET
261    #[serde(default)]
262    pub method: HttpMethod,
263
264    /// Path to request for the probe (e.g., "/health", "/ready").
265    /// Default: "/"
266    #[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    /// Links the function to another resource with specified permissions.
287    /// Accepts a reference to any type `R` where `&R` can be converted into `ResourceRef`.
288    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
289    where
290        for<'a> &'a R: Into<ResourceRef>, // Use Higher-Rank Trait Bound (HRTB)
291    {
292        // Perform the conversion from &R to ResourceRef using .into()
293        let resource_ref: ResourceRef = resource.into();
294        self.links.push(resource_ref);
295        self
296    }
297
298    /// Adds a trigger to the function. Functions can have multiple triggers.
299    /// Each trigger will independently invoke the function when its conditions are met.
300    ///
301    /// # Examples
302    /// ```rust
303    /// # use alien_core::{Function, FunctionTrigger, FunctionCode, Queue};
304    /// # let queue1 = Queue::new("queue-1".to_string()).build();
305    /// # let queue2 = Queue::new("queue-2".to_string()).build();
306    /// let function = Function::new("my-function".to_string())
307    ///     .code(FunctionCode::Image { image: "test:latest".to_string() })
308    ///     .permissions("execution".to_string())
309    ///     .trigger(FunctionTrigger::queue(&queue1))
310    ///     .trigger(FunctionTrigger::queue(&queue2))
311    ///     .build();
312    /// ```
313    pub fn trigger(mut self, trigger: FunctionTrigger) -> Self {
314        self.triggers.push(trigger);
315        self
316    }
317}
318
319// Implementation of ResourceDefinition trait for Function
320#[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        // Add trigger dependencies
338        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                    // Schedule triggers don't depend on other resources
348                }
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        // Downcast to Function type to use the existing validate_update method
361        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/// Outputs generated by a successfully provisioned Function.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
401#[serde(rename_all = "camelCase")]
402pub struct FunctionOutputs {
403    /// The name of the function.
404    pub function_name: String,
405    /// The invocation URL (if applicable, e.g., for public ingress or specific platforms).
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub url: Option<String>,
408    /// The ARN or platform-specific identifier.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub identifier: Option<String>,
411    /// Load balancer endpoint information for DNS management (optional).
412    /// Used by the DNS controller to create custom domain mappings.
413    #[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) // Pass reference directly
452            .link(&dummy_storage_2) // Add a second link
453            .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        // Verify permissions was set correctly
464        assert_eq!(function.permissions, "execution");
465
466        // Verify links were added correctly
467        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); // Expect 2 links now
474    }
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) // regular link dependency
596            .trigger(FunctionTrigger::queue(&queue)) // trigger dependency
597            .build();
598
599        let dependencies = function.get_dependencies();
600
601        // Should have both link and trigger dependencies
602        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        // Test the helper method
614        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        // Check first queue trigger
644        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        // Check second queue trigger
651        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        // Check schedule trigger
658        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        // Check dependencies include both queues
665        let dependencies = function.get_dependencies();
666        assert_eq!(dependencies.len(), 2); // Only queues, schedule has no dependency
667        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        // Test that defaults are applied correctly
697        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}