Skip to main content

alien_core/resources/
worker.rs

1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
3use crate::{PublicEndpointOutput, WorkerPublicEndpoint, APEX_HOST_LABEL};
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/// Specifies the source of the worker's executable code.
12/// This can be a pre-built container image or source code that the system will build.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15#[serde(rename_all = "camelCase", tag = "type")]
16pub enum WorkerCode {
17    /// Container image.
18    #[serde(rename_all = "camelCase")]
19    Image {
20        /// Container image (e.g., `ghcr.io/myorg/myimage:latest`).
21        image: String,
22    },
23    /// Source code to be built.
24    #[serde(rename_all = "camelCase")]
25    Source {
26        /// The source directory to build from
27        src: String,
28        /// Toolchain configuration with type-safe options
29        toolchain: ToolchainConfig,
30    },
31}
32
33/// Configuration for different programming language toolchains.
34/// Each toolchain provides type-safe build configuration and auto-detection capabilities.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[serde(rename_all = "lowercase", tag = "type")]
38pub enum ToolchainConfig {
39    /// Rust with Cargo build system
40    #[serde(rename_all = "camelCase")]
41    Rust {
42        /// Name of the binary to build and run
43        binary_name: String,
44    },
45    /// TypeScript/JavaScript compiled to single executable with Bun
46    #[serde(rename_all = "camelCase")]
47    TypeScript {
48        /// Name of the compiled binary (defaults to package.json name if not specified)
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        binary_name: Option<String>,
51    },
52    /// Docker build from Dockerfile
53    #[serde(rename_all = "camelCase")]
54    Docker {
55        /// Dockerfile path relative to src (default: "Dockerfile")
56        #[serde(skip_serializing_if = "Option::is_none")]
57        dockerfile: Option<String>,
58        /// Build arguments for docker build
59        #[serde(skip_serializing_if = "Option::is_none")]
60        build_args: Option<HashMap<String, String>>,
61        /// Multi-stage build target
62        #[serde(skip_serializing_if = "Option::is_none")]
63        target: Option<String>,
64    },
65}
66
67/// Defines what triggers a worker execution.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[serde(tag = "type", rename_all = "camelCase")]
71pub enum WorkerTrigger {
72    /// Worker triggered by queue messages (always 1 message per invocation)
73    Queue {
74        /// Reference to the queue resource
75        queue: ResourceRef,
76    },
77    /// Worker triggered by storage events (object created, deleted, etc.)
78    Storage {
79        /// Reference to the storage resource
80        storage: ResourceRef,
81        /// Events to trigger on (e.g., ["created", "deleted"])
82        events: Vec<String>,
83    },
84    /// Worker triggered on a schedule (cron expression)
85    Schedule {
86        /// Cron expression for scheduling (standard 5-field unix cron)
87        cron: String,
88    },
89}
90
91impl WorkerTrigger {
92    /// Creates a queue trigger for the specified queue resource.
93    /// The worker will be automatically invoked when messages arrive in the queue.
94    /// Each message is processed individually (batch size of 1).
95    pub fn queue<R: ?Sized>(queue: &R) -> Self
96    where
97        for<'a> &'a R: Into<ResourceRef>,
98    {
99        let queue_ref: ResourceRef = queue.into();
100        WorkerTrigger::Queue { queue: queue_ref }
101    }
102
103    /// Creates a storage trigger for the specified storage resource.
104    /// The worker will be invoked when matching events occur on the storage resource.
105    pub fn storage<R: ?Sized>(storage: &R, events: Vec<String>) -> Self
106    where
107        for<'a> &'a R: Into<ResourceRef>,
108    {
109        let storage_ref: ResourceRef = storage.into();
110        WorkerTrigger::Storage {
111            storage: storage_ref,
112            events,
113        }
114    }
115
116    /// Creates a schedule trigger with the specified cron expression.
117    /// Uses standard 5-field unix cron format (minute hour day-of-month month day-of-week).
118    pub fn schedule<S: Into<String>>(cron: S) -> Self {
119        WorkerTrigger::Schedule { cron: cron.into() }
120    }
121}
122
123/// Represents a serverless worker that executes code in response to triggers or direct invocations.
124/// Workers are the primary compute resource in serverless applications, designed to be stateless and ephemeral.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
126#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
127#[serde(rename_all = "camelCase", deny_unknown_fields)]
128#[builder(start_fn = new)]
129pub struct Worker {
130    /// Identifier for the worker. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).
131    /// Maximum 64 characters.
132    #[builder(start_fn)]
133    pub id: String,
134
135    /// List of resource references this worker depends on.
136    // TODO: We need to verify that the same link isn't added multiple times.
137    #[builder(field)]
138    pub links: Vec<ResourceRef>,
139
140    /// List of triggers that define what events automatically invoke this worker.
141    /// If empty, the worker is only invokable directly via HTTP calls or platform-specific invocation APIs.
142    /// When configured, the worker will be automatically invoked when any of the specified trigger conditions are met.
143    #[builder(field)]
144    pub triggers: Vec<WorkerTrigger>,
145
146    /// Public endpoints exposed by this worker.
147    #[builder(field)]
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub public_endpoints: Vec<WorkerPublicEndpoint>,
150
151    /// Permission profile name that defines the permissions granted to this worker.
152    /// This references a profile defined in the stack's permission definitions.
153    pub permissions: String,
154
155    /// Code for the worker, either a pre-built image or source code to be built.
156    pub code: WorkerCode,
157
158    /// Memory allocated to the worker in megabytes (MB).
159    /// Default: 256
160    ///
161    /// Platform-specific constraints:
162    /// - **AWS Lambda**: 128–10240 MB in 1 MB increments
163    /// - **GCP Cloud Run**: 128–32768 MB
164    /// - **Azure Container Apps**: fixed CPU/memory pairs — 512, 1024, 1536, 2048, 2560,
165    ///   3072, 3584, 4096 MB. Values below 512 are automatically rounded up at deploy time.
166    #[builder(default = default_memory_mb())]
167    #[serde(default = "default_memory_mb")]
168    #[cfg_attr(feature = "openapi", schema(default = default_memory_mb))]
169    pub memory_mb: u32,
170
171    /// Maximum execution time for the worker in seconds.
172    /// Constraints: 1‑3600 seconds (platform-specific limits may apply)
173    /// Default: 30
174    #[builder(default = default_timeout_seconds())]
175    #[serde(default = "default_timeout_seconds")]
176    #[cfg_attr(feature = "openapi", schema(default = default_timeout_seconds))]
177    pub timeout_seconds: u32,
178
179    /// Key-value pairs to set as environment variables for the worker.
180    #[builder(default)]
181    #[serde(default)]
182    pub environment: HashMap<String, String>,
183
184    /// Whether the worker can receive remote commands via the Commands protocol.
185    /// When enabled, the runtime polls the manager for pending commands and executes registered handlers.
186    #[builder(default = default_commands_enabled())]
187    #[serde(default = "default_commands_enabled")]
188    #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
189    pub commands_enabled: bool,
190
191    /// Maximum number of concurrent executions allowed for the worker.
192    /// None means platform default applies.
193    pub concurrency_limit: Option<u32>,
194
195    /// Optional readiness probe configuration.
196    /// Only applicable for workers with Public ingress.
197    /// When configured, the probe will be executed after provisioning/update to verify the worker is ready.
198    pub readiness_probe: Option<ReadinessProbe>,
199}
200
201impl Worker {
202    /// The resource type identifier for Workers
203    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
204
205    /// Returns the permission profile name for this worker.
206    pub fn get_permissions(&self) -> &str {
207        &self.permissions
208    }
209
210    fn validate_public_endpoints(&self) -> Result<()> {
211        let mut endpoint_names = std::collections::HashSet::new();
212        let mut apex_endpoint_name: Option<&str> = None;
213        for endpoint in &self.public_endpoints {
214            endpoint.validate_for_resource(&self.id)?;
215            if !endpoint_names.insert(endpoint.name.as_str()) {
216                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
217                    resource_id: self.id.clone(),
218                    reason: format!("duplicate public endpoint name '{}'", endpoint.name),
219                }));
220            }
221            if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
222                if let Some(existing_name) = apex_endpoint_name {
223                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
224                        resource_id: self.id.clone(),
225                        reason: format!(
226                            "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
227                            existing_name
228                        ),
229                    }));
230                }
231                apex_endpoint_name = Some(endpoint.name.as_str());
232            }
233        }
234
235        Ok(())
236    }
237}
238
239fn default_memory_mb() -> u32 {
240    256
241}
242
243fn default_timeout_seconds() -> u32 {
244    180
245}
246
247fn default_commands_enabled() -> bool {
248    false
249}
250
251/// HTTP method for readiness probe requests.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
254#[serde(rename_all = "UPPERCASE")]
255#[derive(Default)]
256pub enum HttpMethod {
257    #[default]
258    Get,
259    Post,
260    Put,
261    Delete,
262    Head,
263    Options,
264    Patch,
265}
266
267/// Configuration for HTTP-based readiness probe.
268/// This probe is executed after worker provisioning/update to verify the worker is ready to serve traffic.
269/// Only works with workers that have Public ingress.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
272#[serde(rename_all = "camelCase")]
273pub struct ReadinessProbe {
274    /// HTTP method to use for the probe request.
275    /// Default: GET
276    #[serde(default)]
277    pub method: HttpMethod,
278
279    /// Path to request for the probe (e.g., "/health", "/ready").
280    /// Default: "/"
281    #[serde(default = "default_probe_path")]
282    pub path: String,
283}
284
285fn default_probe_path() -> String {
286    "/".to_string()
287}
288
289impl Default for ReadinessProbe {
290    fn default() -> Self {
291        Self {
292            method: HttpMethod::default(),
293            path: default_probe_path(),
294        }
295    }
296}
297
298use crate::resources::worker::worker_builder::State;
299
300impl<S: State> WorkerBuilder<S> {
301    /// Links the worker to another resource with specified permissions.
302    /// Accepts a reference to any type `R` where `&R` can be converted into `ResourceRef`.
303    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
304    where
305        for<'a> &'a R: Into<ResourceRef>, // Use Higher-Rank Trait Bound (HRTB)
306    {
307        // Perform the conversion from &R to ResourceRef using .into()
308        let resource_ref: ResourceRef = resource.into();
309        self.links.push(resource_ref);
310        self
311    }
312
313    /// Adds a trigger to the worker. Workers can have multiple triggers.
314    /// Each trigger will independently invoke the worker when its conditions are met.
315    ///
316    /// # Examples
317    /// ```rust
318    /// # use alien_core::{Worker, WorkerTrigger, WorkerCode, Queue};
319    /// # let queue1 = Queue::new("queue-1".to_string()).build();
320    /// # let queue2 = Queue::new("queue-2".to_string()).build();
321    /// let worker = Worker::new("my-worker".to_string())
322    ///     .code(WorkerCode::Image { image: "test:latest".to_string() })
323    ///     .permissions("execution".to_string())
324    ///     .trigger(WorkerTrigger::queue(&queue1))
325    ///     .trigger(WorkerTrigger::queue(&queue2))
326    ///     .build();
327    /// ```
328    pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
329        self.triggers.push(trigger);
330        self
331    }
332
333    /// Exposes a named public endpoint for the worker.
334    pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
335        self.public_endpoints.push(endpoint);
336        self
337    }
338}
339
340// Implementation of ResourceDefinition trait for Worker
341impl ResourceDefinition for Worker {
342    fn get_resource_type(&self) -> ResourceType {
343        Self::RESOURCE_TYPE
344    }
345
346    fn id(&self) -> &str {
347        &self.id
348    }
349
350    fn get_dependencies(&self) -> Vec<ResourceRef> {
351        let mut dependencies = self.links.clone();
352
353        // Add trigger dependencies
354        for trigger in &self.triggers {
355            match trigger {
356                WorkerTrigger::Queue { queue } => {
357                    dependencies.push(queue.clone());
358                }
359                WorkerTrigger::Storage { storage, .. } => {
360                    dependencies.push(storage.clone());
361                }
362                WorkerTrigger::Schedule { .. } => {
363                    // Schedule triggers don't depend on other resources
364                }
365            }
366        }
367
368        dependencies
369    }
370
371    fn get_permissions(&self) -> Option<&str> {
372        Some(&self.permissions)
373    }
374
375    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
376        // Downcast to Worker type to use the existing validate_update method
377        let new_worker = new_config
378            .as_any()
379            .downcast_ref::<Worker>()
380            .ok_or_else(|| {
381                AlienError::new(ErrorData::UnexpectedResourceType {
382                    resource_id: self.id.clone(),
383                    expected: Self::RESOURCE_TYPE,
384                    actual: new_config.get_resource_type(),
385                })
386            })?;
387
388        if self.id != new_worker.id {
389            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
390                resource_id: self.id.clone(),
391                reason: "the 'id' field is immutable".to_string(),
392            }));
393        }
394        self.validate_public_endpoints()?;
395        new_worker.validate_public_endpoints()?;
396        if self.public_endpoints != new_worker.public_endpoints {
397            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
398                resource_id: self.id.clone(),
399                reason: "the 'publicEndpoints' field is immutable".to_string(),
400            }));
401        }
402        Ok(())
403    }
404
405    fn as_any(&self) -> &dyn Any {
406        self
407    }
408
409    fn as_any_mut(&mut self) -> &mut dyn Any {
410        self
411    }
412
413    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
414        Box::new(self.clone())
415    }
416
417    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
418        other.as_any().downcast_ref::<Worker>() == Some(self)
419    }
420
421    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
422        serde_json::to_value(self)
423    }
424}
425
426/// Outputs generated by a successfully provisioned Worker.
427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
428#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
429#[serde(rename_all = "camelCase")]
430pub struct WorkerOutputs {
431    /// The platform-specific worker name.
432    pub worker_name: String,
433    /// Public endpoints resolved for this worker.
434    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
435    pub public_endpoints: HashMap<String, PublicEndpointOutput>,
436    /// The ARN or platform-specific identifier.
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub identifier: Option<String>,
439    /// Push target for commands delivery. Platform-specific:
440    /// - AWS: Lambda function name or ARN
441    /// - GCP: Full Pub/Sub topic path (projects/{project}/topics/{topic})
442    /// - Azure: Service Bus "{namespace}/{queue}"
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub commands_push_target: Option<String>,
445}
446
447impl ResourceOutputsDefinition for WorkerOutputs {
448    fn get_resource_type(&self) -> ResourceType {
449        Worker::RESOURCE_TYPE.clone()
450    }
451
452    fn as_any(&self) -> &dyn Any {
453        self
454    }
455
456    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
457        Box::new(self.clone())
458    }
459
460    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
461        other.as_any().downcast_ref::<WorkerOutputs>() == Some(self)
462    }
463
464    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
465        serde_json::to_value(self)
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::Storage;
473
474    #[test]
475    fn test_worker_builder_direct_refs() {
476        let dummy_storage = Storage::new("test-storage".to_string()).build();
477        let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
478
479        let worker = Worker::new("my-worker".to_string())
480            .code(WorkerCode::Image {
481                image: "test-image".to_string(),
482            })
483            .permissions("execution".to_string())
484            .link(&dummy_storage) // Pass reference directly
485            .link(&dummy_storage_2) // Add a second link
486            .build();
487
488        assert_eq!(worker.id, "my-worker");
489        assert_eq!(
490            worker.code,
491            WorkerCode::Image {
492                image: "test-image".to_string()
493            }
494        );
495
496        // Verify permissions was set correctly
497        assert_eq!(worker.permissions, "execution");
498
499        // Verify links were added correctly
500        assert!(worker
501            .links
502            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
503        assert!(worker
504            .links
505            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
506        assert_eq!(worker.links.len(), 2); // Expect 2 links now
507    }
508
509    #[test]
510    fn test_worker_with_readiness_probe() {
511        let probe = ReadinessProbe {
512            method: HttpMethod::Post,
513            path: "/health".to_string(),
514        };
515
516        let worker = Worker::new("my-worker".to_string())
517            .code(WorkerCode::Image {
518                image: "test-image".to_string(),
519            })
520            .permissions("execution".to_string())
521            .public_endpoint(WorkerPublicEndpoint {
522                name: "api".to_string(),
523                host_label: None,
524                wildcard_subdomains: false,
525            })
526            .readiness_probe(probe.clone())
527            .build();
528
529        assert_eq!(worker.id, "my-worker");
530        assert_eq!(worker.public_endpoints[0].name, "api");
531        assert_eq!(worker.readiness_probe, Some(probe));
532    }
533
534    #[test]
535    fn test_readiness_probe_defaults() {
536        let probe = ReadinessProbe::default();
537        assert_eq!(probe.method, HttpMethod::Get);
538        assert_eq!(probe.path, "/");
539    }
540
541    #[test]
542    fn test_worker_with_rust_toolchain() {
543        let worker = Worker::new("my-rust-worker".to_string())
544            .code(WorkerCode::Source {
545                src: "./".to_string(),
546                toolchain: ToolchainConfig::Rust {
547                    binary_name: "my-app".to_string(),
548                },
549            })
550            .permissions("execution".to_string())
551            .build();
552
553        assert_eq!(worker.id, "my-rust-worker");
554
555        match &worker.code {
556            WorkerCode::Source { src, toolchain } => {
557                assert_eq!(src, "./");
558                assert_eq!(
559                    toolchain,
560                    &ToolchainConfig::Rust {
561                        binary_name: "my-app".to_string(),
562                    }
563                );
564            }
565            _ => panic!("Expected Source code"),
566        }
567    }
568
569    #[test]
570    fn test_worker_with_typescript_toolchain() {
571        let worker = Worker::new("my-ts-worker".to_string())
572            .code(WorkerCode::Source {
573                src: "./".to_string(),
574                toolchain: ToolchainConfig::TypeScript {
575                    binary_name: Some("my-ts-worker".to_string()),
576                },
577            })
578            .permissions("execution".to_string())
579            .build();
580
581        assert_eq!(worker.id, "my-ts-worker");
582
583        match &worker.code {
584            WorkerCode::Source { src, toolchain } => {
585                assert_eq!(src, "./");
586                assert_eq!(
587                    toolchain,
588                    &ToolchainConfig::TypeScript {
589                        binary_name: Some("my-ts-worker".to_string())
590                    }
591                );
592            }
593            _ => panic!("Expected Source code"),
594        }
595    }
596
597    #[test]
598    fn test_worker_with_queue_trigger() {
599        use crate::Queue;
600
601        let queue = Queue::new("test-queue".to_string()).build();
602
603        let worker = Worker::new("triggered-worker".to_string())
604            .code(WorkerCode::Image {
605                image: "test-image".to_string(),
606            })
607            .permissions("execution".to_string())
608            .trigger(WorkerTrigger::queue(&queue))
609            .build();
610
611        assert_eq!(worker.triggers.len(), 1);
612        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
613            assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
614            assert_eq!(queue_ref.id, "test-queue");
615        } else {
616            panic!("Expected queue trigger");
617        }
618    }
619
620    #[test]
621    fn test_worker_trigger_dependencies() {
622        use crate::Queue;
623
624        let queue = Queue::new("test-queue".to_string()).build();
625        let storage = Storage::new("test-storage".to_string()).build();
626
627        let worker = Worker::new("triggered-worker".to_string())
628            .code(WorkerCode::Image {
629                image: "test-image".to_string(),
630            })
631            .permissions("execution".to_string())
632            .link(&storage) // regular link dependency
633            .trigger(WorkerTrigger::queue(&queue)) // trigger dependency
634            .build();
635
636        let dependencies = worker.get_dependencies();
637
638        // Should have both link and trigger dependencies
639        assert_eq!(dependencies.len(), 2);
640        assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
641        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
642    }
643
644    #[test]
645    fn test_worker_trigger_helper_methods() {
646        use crate::Queue;
647
648        let queue = Queue::new("my-queue".to_string()).build();
649
650        // Test the helper method
651        let trigger = WorkerTrigger::queue(&queue);
652
653        if let WorkerTrigger::Queue { queue: queue_ref } = trigger {
654            assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
655            assert_eq!(queue_ref.id, "my-queue");
656        } else {
657            panic!("Expected queue trigger");
658        }
659    }
660
661    #[test]
662    fn test_worker_with_multiple_triggers() {
663        use crate::Queue;
664
665        let queue1 = Queue::new("queue-1".to_string()).build();
666        let queue2 = Queue::new("queue-2".to_string()).build();
667
668        let worker = Worker::new("multi-triggered-worker".to_string())
669            .code(WorkerCode::Image {
670                image: "test-image".to_string(),
671            })
672            .permissions("execution".to_string())
673            .trigger(WorkerTrigger::queue(&queue1))
674            .trigger(WorkerTrigger::queue(&queue2))
675            .trigger(WorkerTrigger::schedule("0 * * * *".to_string()))
676            .build();
677
678        assert_eq!(worker.triggers.len(), 3);
679
680        // Check first queue trigger
681        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
682            assert_eq!(queue_ref.id, "queue-1");
683        } else {
684            panic!("Expected first trigger to be queue-1");
685        }
686
687        // Check second queue trigger
688        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[1] {
689            assert_eq!(queue_ref.id, "queue-2");
690        } else {
691            panic!("Expected second trigger to be queue-2");
692        }
693
694        // Check schedule trigger
695        if let WorkerTrigger::Schedule { cron } = &worker.triggers[2] {
696            assert_eq!(cron, "0 * * * *");
697        } else {
698            panic!("Expected third trigger to be schedule");
699        }
700
701        // Check dependencies include both queues
702        let dependencies = worker.get_dependencies();
703        assert_eq!(dependencies.len(), 2); // Only queues, schedule has no dependency
704        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
705        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
706    }
707
708    #[test]
709    fn test_worker_with_commands_enabled() {
710        let worker = Worker::new("cmd-worker".to_string())
711            .code(WorkerCode::Image {
712                image: "test-image".to_string(),
713            })
714            .permissions("execution".to_string())
715            .commands_enabled(true)
716            .build();
717
718        assert_eq!(worker.id, "cmd-worker");
719        assert!(worker.public_endpoints.is_empty());
720        assert_eq!(worker.commands_enabled, true);
721    }
722
723    #[test]
724    fn test_worker_defaults() {
725        let worker = Worker::new("default-worker".to_string())
726            .code(WorkerCode::Image {
727                image: "test-image".to_string(),
728            })
729            .permissions("execution".to_string())
730            .build();
731
732        // Test that defaults are applied correctly
733        assert!(worker.public_endpoints.is_empty());
734        assert_eq!(worker.commands_enabled, false);
735        assert_eq!(worker.memory_mb, 256);
736        assert_eq!(worker.timeout_seconds, 180);
737    }
738
739    #[test]
740    fn test_worker_public_ingress_with_commands() {
741        let worker = Worker::new("public-cmd-worker".to_string())
742            .code(WorkerCode::Image {
743                image: "test-image".to_string(),
744            })
745            .permissions("execution".to_string())
746            .public_endpoint(WorkerPublicEndpoint {
747                name: "api".to_string(),
748                host_label: None,
749                wildcard_subdomains: false,
750            })
751            .commands_enabled(true)
752            .build();
753
754        assert_eq!(worker.public_endpoints[0].name, "api");
755        assert_eq!(worker.commands_enabled, true);
756    }
757
758    #[test]
759    fn worker_rejects_multiple_apex_public_endpoints() {
760        let worker = Worker::new("apex-worker".to_string())
761            .code(WorkerCode::Image {
762                image: "test-image".to_string(),
763            })
764            .permissions("execution".to_string())
765            .public_endpoint(WorkerPublicEndpoint {
766                name: "api".to_string(),
767                host_label: Some(APEX_HOST_LABEL.to_string()),
768                wildcard_subdomains: false,
769            })
770            .public_endpoint(WorkerPublicEndpoint {
771                name: "admin".to_string(),
772                host_label: Some(APEX_HOST_LABEL.to_string()),
773                wildcard_subdomains: false,
774            })
775            .build();
776
777        assert!(worker.validate_public_endpoints().is_err());
778    }
779}