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
320impl 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        // Add trigger dependencies
333        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                    // Schedule triggers don't depend on other resources
343                }
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        // Downcast to Function type to use the existing validate_update method
356        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/// Outputs generated by a successfully provisioned Function.
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
400#[serde(rename_all = "camelCase")]
401pub struct FunctionOutputs {
402    /// The name of the function.
403    pub function_name: String,
404    /// The invocation URL (if applicable, e.g., for public ingress or specific platforms).
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub url: Option<String>,
407    /// The ARN or platform-specific identifier.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub identifier: Option<String>,
410    /// Load balancer endpoint information for DNS management (optional).
411    /// Used by the DNS controller to create custom domain mappings.
412    #[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) // Pass reference directly
454            .link(&dummy_storage_2) // Add a second link
455            .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        // Verify permissions was set correctly
466        assert_eq!(function.permissions, "execution");
467
468        // Verify links were added correctly
469        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); // Expect 2 links now
476    }
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) // regular link dependency
598            .trigger(FunctionTrigger::queue(&queue)) // trigger dependency
599            .build();
600
601        let dependencies = function.get_dependencies();
602
603        // Should have both link and trigger dependencies
604        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        // Test the helper method
616        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        // Check first queue trigger
646        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        // Check second queue trigger
653        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        // Check schedule trigger
660        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        // Check dependencies include both queues
667        let dependencies = function.get_dependencies();
668        assert_eq!(dependencies.len(), 2); // Only queues, schedule has no dependency
669        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        // Test that defaults are applied correctly
699        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}