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: 180
174    #[builder(
175        default = default_timeout_seconds(),
176        with = |timeout_seconds: u32| -> crate::Result<_> {
177            validate_timeout_seconds(timeout_seconds)
178        }
179    )]
180    #[serde(
181        default = "default_timeout_seconds",
182        deserialize_with = "deserialize_timeout_seconds"
183    )]
184    #[cfg_attr(
185        feature = "openapi",
186        schema(default = default_timeout_seconds, minimum = 1, maximum = 3600)
187    )]
188    pub timeout_seconds: u32,
189
190    /// Key-value pairs to set as environment variables for the worker.
191    #[builder(default)]
192    #[serde(default)]
193    pub environment: HashMap<String, String>,
194
195    /// Whether the worker can receive remote commands via the Commands protocol.
196    /// When enabled, the platform pushes commands into the Worker runtime,
197    /// which executes registered handlers.
198    #[builder(default = default_commands_enabled())]
199    #[serde(default = "default_commands_enabled")]
200    #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
201    pub commands_enabled: bool,
202
203    /// Maximum number of concurrent executions allowed for the worker.
204    /// None means platform default applies.
205    pub concurrency_limit: Option<u32>,
206
207    /// Optional readiness probe configuration.
208    /// Only applicable for workers with Public ingress.
209    /// When configured, the probe will be executed after provisioning/update to verify the worker is ready.
210    pub readiness_probe: Option<ReadinessProbe>,
211}
212
213impl Worker {
214    /// The resource type identifier for Workers
215    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
216
217    /// Returns the permission profile name for this worker.
218    pub fn get_permissions(&self) -> &str {
219        &self.permissions
220    }
221
222    fn validate_public_endpoints(&self) -> Result<()> {
223        let mut endpoint_names = std::collections::HashSet::new();
224        let mut apex_endpoint_name: Option<&str> = None;
225        for endpoint in &self.public_endpoints {
226            endpoint.validate_for_resource(&self.id)?;
227            if !endpoint_names.insert(endpoint.name.as_str()) {
228                return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
229                    resource_id: self.id.clone(),
230                    reason: format!("duplicate public endpoint name '{}'", endpoint.name),
231                }));
232            }
233            if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
234                if let Some(existing_name) = apex_endpoint_name {
235                    return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
236                        resource_id: self.id.clone(),
237                        reason: format!(
238                            "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
239                            existing_name
240                        ),
241                    }));
242                }
243                apex_endpoint_name = Some(endpoint.name.as_str());
244            }
245        }
246
247        Ok(())
248    }
249}
250
251fn default_memory_mb() -> u32 {
252    256
253}
254
255fn default_timeout_seconds() -> u32 {
256    180
257}
258
259/// Longest Worker execution supported by every Commands delivery path.
260pub const MAX_WORKER_TIMEOUT_SECONDS: u32 = 3600;
261
262fn deserialize_timeout_seconds<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
263where
264    D: serde::Deserializer<'de>,
265{
266    let value = u32::deserialize(deserializer)?;
267    validate_timeout_seconds(value).map_err(serde::de::Error::custom)
268}
269
270fn validate_timeout_seconds(timeout_seconds: u32) -> Result<u32> {
271    if (1..=MAX_WORKER_TIMEOUT_SECONDS).contains(&timeout_seconds) {
272        return Ok(timeout_seconds);
273    }
274
275    Err(AlienError::new(ErrorData::WorkerTimeoutInvalid {
276        timeout_seconds,
277        max_timeout_seconds: MAX_WORKER_TIMEOUT_SECONDS,
278    }))
279}
280
281fn default_commands_enabled() -> bool {
282    false
283}
284
285/// HTTP method for readiness probe requests.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
288#[serde(rename_all = "UPPERCASE")]
289#[derive(Default)]
290pub enum HttpMethod {
291    #[default]
292    Get,
293    Post,
294    Put,
295    Delete,
296    Head,
297    Options,
298    Patch,
299}
300
301/// Configuration for HTTP-based readiness probe.
302/// This probe is executed after worker provisioning/update to verify the worker is ready to serve traffic.
303/// Only works with workers that have Public ingress.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
306#[serde(rename_all = "camelCase")]
307pub struct ReadinessProbe {
308    /// HTTP method to use for the probe request.
309    /// Default: GET
310    #[serde(default)]
311    pub method: HttpMethod,
312
313    /// Path to request for the probe (e.g., "/health", "/ready").
314    /// Default: "/"
315    #[serde(default = "default_probe_path")]
316    pub path: String,
317}
318
319fn default_probe_path() -> String {
320    "/".to_string()
321}
322
323impl Default for ReadinessProbe {
324    fn default() -> Self {
325        Self {
326            method: HttpMethod::default(),
327            path: default_probe_path(),
328        }
329    }
330}
331
332use crate::resources::worker::worker_builder::State;
333
334impl<S: State> WorkerBuilder<S> {
335    /// Links the worker to another resource with specified permissions.
336    /// Accepts a reference to any type `R` where `&R` can be converted into `ResourceRef`.
337    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
338    where
339        for<'a> &'a R: Into<ResourceRef>, // Use Higher-Rank Trait Bound (HRTB)
340    {
341        // Perform the conversion from &R to ResourceRef using .into()
342        let resource_ref: ResourceRef = resource.into();
343        self.links.push(resource_ref);
344        self
345    }
346
347    /// Adds a trigger to the worker. Workers can have multiple triggers.
348    /// Each trigger will independently invoke the worker when its conditions are met.
349    ///
350    /// # Examples
351    /// ```rust
352    /// # use alien_core::{Worker, WorkerTrigger, WorkerCode, Queue};
353    /// # let queue1 = Queue::new("queue-1".to_string()).build();
354    /// # let queue2 = Queue::new("queue-2".to_string()).build();
355    /// let worker = Worker::new("my-worker".to_string())
356    ///     .code(WorkerCode::Image { image: "test:latest".to_string() })
357    ///     .permissions("execution".to_string())
358    ///     .trigger(WorkerTrigger::queue(&queue1))
359    ///     .trigger(WorkerTrigger::queue(&queue2))
360    ///     .build();
361    /// ```
362    pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
363        self.triggers.push(trigger);
364        self
365    }
366
367    /// Exposes a named public endpoint for the worker.
368    pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
369        self.public_endpoints.push(endpoint);
370        self
371    }
372}
373
374// Implementation of ResourceDefinition trait for Worker
375impl ResourceDefinition for Worker {
376    fn get_resource_type(&self) -> ResourceType {
377        Self::RESOURCE_TYPE
378    }
379
380    fn id(&self) -> &str {
381        &self.id
382    }
383
384    fn get_dependencies(&self) -> Vec<ResourceRef> {
385        let mut dependencies = self.links.clone();
386
387        // Add trigger dependencies
388        for trigger in &self.triggers {
389            match trigger {
390                WorkerTrigger::Queue { queue } => {
391                    dependencies.push(queue.clone());
392                }
393                WorkerTrigger::Storage { storage, .. } => {
394                    dependencies.push(storage.clone());
395                }
396                WorkerTrigger::Schedule { .. } => {
397                    // Schedule triggers don't depend on other resources
398                }
399            }
400        }
401
402        dependencies
403    }
404
405    fn get_permissions(&self) -> Option<&str> {
406        Some(&self.permissions)
407    }
408
409    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
410        // Downcast to Worker type to use the existing validate_update method
411        let new_worker = new_config
412            .as_any()
413            .downcast_ref::<Worker>()
414            .ok_or_else(|| {
415                AlienError::new(ErrorData::UnexpectedResourceType {
416                    resource_id: self.id.clone(),
417                    expected: Self::RESOURCE_TYPE,
418                    actual: new_config.get_resource_type(),
419                })
420            })?;
421
422        if self.id != new_worker.id {
423            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
424                resource_id: self.id.clone(),
425                reason: "the 'id' field is immutable".to_string(),
426            }));
427        }
428        self.validate_public_endpoints()?;
429        new_worker.validate_public_endpoints()?;
430        if self.public_endpoints != new_worker.public_endpoints {
431            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
432                resource_id: self.id.clone(),
433                reason: "the 'publicEndpoints' field is immutable".to_string(),
434            }));
435        }
436        Ok(())
437    }
438
439    fn as_any(&self) -> &dyn Any {
440        self
441    }
442
443    fn as_any_mut(&mut self) -> &mut dyn Any {
444        self
445    }
446
447    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
448        Box::new(self.clone())
449    }
450
451    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
452        other.as_any().downcast_ref::<Worker>() == Some(self)
453    }
454
455    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
456        serde_json::to_value(self)
457    }
458}
459
460/// Outputs generated by a successfully provisioned Worker.
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
462#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
463#[serde(rename_all = "camelCase")]
464pub struct WorkerOutputs {
465    /// The platform-specific worker name.
466    pub worker_name: String,
467    /// Public endpoints resolved for this worker.
468    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
469    pub public_endpoints: HashMap<String, PublicEndpointOutput>,
470    /// The ARN or platform-specific identifier.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub identifier: Option<String>,
473    /// Push target for commands delivery. Platform-specific:
474    /// - AWS: Lambda function name or ARN
475    /// - GCP: Full Pub/Sub topic path (projects/{project}/topics/{topic})
476    /// - Azure: Service Bus "{namespace}/{queue}"
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub commands_push_target: Option<String>,
479}
480
481impl ResourceOutputsDefinition for WorkerOutputs {
482    fn get_resource_type(&self) -> ResourceType {
483        Worker::RESOURCE_TYPE.clone()
484    }
485
486    fn as_any(&self) -> &dyn Any {
487        self
488    }
489
490    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
491        Box::new(self.clone())
492    }
493
494    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
495        other.as_any().downcast_ref::<WorkerOutputs>() == Some(self)
496    }
497
498    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
499        serde_json::to_value(self)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::Storage;
507
508    #[test]
509    fn test_worker_builder_direct_refs() {
510        let dummy_storage = Storage::new("test-storage".to_string()).build();
511        let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
512
513        let worker = Worker::new("my-worker".to_string())
514            .code(WorkerCode::Image {
515                image: "test-image".to_string(),
516            })
517            .permissions("execution".to_string())
518            .link(&dummy_storage) // Pass reference directly
519            .link(&dummy_storage_2) // Add a second link
520            .build();
521
522        assert_eq!(worker.id, "my-worker");
523        assert_eq!(
524            worker.code,
525            WorkerCode::Image {
526                image: "test-image".to_string()
527            }
528        );
529
530        // Verify permissions was set correctly
531        assert_eq!(worker.permissions, "execution");
532
533        // Verify links were added correctly
534        assert!(worker
535            .links
536            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
537        assert!(worker
538            .links
539            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
540        assert_eq!(worker.links.len(), 2); // Expect 2 links now
541    }
542
543    #[test]
544    fn test_worker_with_readiness_probe() {
545        let probe = ReadinessProbe {
546            method: HttpMethod::Post,
547            path: "/health".to_string(),
548        };
549
550        let worker = Worker::new("my-worker".to_string())
551            .code(WorkerCode::Image {
552                image: "test-image".to_string(),
553            })
554            .permissions("execution".to_string())
555            .public_endpoint(WorkerPublicEndpoint {
556                name: "api".to_string(),
557                host_label: None,
558                wildcard_subdomains: false,
559            })
560            .readiness_probe(probe.clone())
561            .build();
562
563        assert_eq!(worker.id, "my-worker");
564        assert_eq!(worker.public_endpoints[0].name, "api");
565        assert_eq!(worker.readiness_probe, Some(probe));
566    }
567
568    #[test]
569    fn test_readiness_probe_defaults() {
570        let probe = ReadinessProbe::default();
571        assert_eq!(probe.method, HttpMethod::Get);
572        assert_eq!(probe.path, "/");
573    }
574
575    #[test]
576    fn test_worker_with_rust_toolchain() {
577        let worker = Worker::new("my-rust-worker".to_string())
578            .code(WorkerCode::Source {
579                src: "./".to_string(),
580                toolchain: ToolchainConfig::Rust {
581                    binary_name: "my-app".to_string(),
582                },
583            })
584            .permissions("execution".to_string())
585            .build();
586
587        assert_eq!(worker.id, "my-rust-worker");
588
589        match &worker.code {
590            WorkerCode::Source { src, toolchain } => {
591                assert_eq!(src, "./");
592                assert_eq!(
593                    toolchain,
594                    &ToolchainConfig::Rust {
595                        binary_name: "my-app".to_string(),
596                    }
597                );
598            }
599            _ => panic!("Expected Source code"),
600        }
601    }
602
603    #[test]
604    fn test_worker_with_typescript_toolchain() {
605        let worker = Worker::new("my-ts-worker".to_string())
606            .code(WorkerCode::Source {
607                src: "./".to_string(),
608                toolchain: ToolchainConfig::TypeScript {
609                    binary_name: Some("my-ts-worker".to_string()),
610                },
611            })
612            .permissions("execution".to_string())
613            .build();
614
615        assert_eq!(worker.id, "my-ts-worker");
616
617        match &worker.code {
618            WorkerCode::Source { src, toolchain } => {
619                assert_eq!(src, "./");
620                assert_eq!(
621                    toolchain,
622                    &ToolchainConfig::TypeScript {
623                        binary_name: Some("my-ts-worker".to_string())
624                    }
625                );
626            }
627            _ => panic!("Expected Source code"),
628        }
629    }
630
631    #[test]
632    fn test_worker_with_queue_trigger() {
633        use crate::Queue;
634
635        let queue = Queue::new("test-queue".to_string()).build();
636
637        let worker = Worker::new("triggered-worker".to_string())
638            .code(WorkerCode::Image {
639                image: "test-image".to_string(),
640            })
641            .permissions("execution".to_string())
642            .trigger(WorkerTrigger::queue(&queue))
643            .build();
644
645        assert_eq!(worker.triggers.len(), 1);
646        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
647            assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
648            assert_eq!(queue_ref.id, "test-queue");
649        } else {
650            panic!("Expected queue trigger");
651        }
652    }
653
654    #[test]
655    fn test_worker_trigger_dependencies() {
656        use crate::Queue;
657
658        let queue = Queue::new("test-queue".to_string()).build();
659        let storage = Storage::new("test-storage".to_string()).build();
660
661        let worker = Worker::new("triggered-worker".to_string())
662            .code(WorkerCode::Image {
663                image: "test-image".to_string(),
664            })
665            .permissions("execution".to_string())
666            .link(&storage) // regular link dependency
667            .trigger(WorkerTrigger::queue(&queue)) // trigger dependency
668            .build();
669
670        let dependencies = worker.get_dependencies();
671
672        // Should have both link and trigger dependencies
673        assert_eq!(dependencies.len(), 2);
674        assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
675        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
676    }
677
678    #[test]
679    fn test_worker_trigger_helper_methods() {
680        use crate::Queue;
681
682        let queue = Queue::new("my-queue".to_string()).build();
683
684        // Test the helper method
685        let trigger = WorkerTrigger::queue(&queue);
686
687        if let WorkerTrigger::Queue { queue: queue_ref } = trigger {
688            assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
689            assert_eq!(queue_ref.id, "my-queue");
690        } else {
691            panic!("Expected queue trigger");
692        }
693    }
694
695    #[test]
696    fn test_worker_with_multiple_triggers() {
697        use crate::Queue;
698
699        let queue1 = Queue::new("queue-1".to_string()).build();
700        let queue2 = Queue::new("queue-2".to_string()).build();
701
702        let worker = Worker::new("multi-triggered-worker".to_string())
703            .code(WorkerCode::Image {
704                image: "test-image".to_string(),
705            })
706            .permissions("execution".to_string())
707            .trigger(WorkerTrigger::queue(&queue1))
708            .trigger(WorkerTrigger::queue(&queue2))
709            .trigger(WorkerTrigger::schedule("0 * * * *".to_string()))
710            .build();
711
712        assert_eq!(worker.triggers.len(), 3);
713
714        // Check first queue trigger
715        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
716            assert_eq!(queue_ref.id, "queue-1");
717        } else {
718            panic!("Expected first trigger to be queue-1");
719        }
720
721        // Check second queue trigger
722        if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[1] {
723            assert_eq!(queue_ref.id, "queue-2");
724        } else {
725            panic!("Expected second trigger to be queue-2");
726        }
727
728        // Check schedule trigger
729        if let WorkerTrigger::Schedule { cron } = &worker.triggers[2] {
730            assert_eq!(cron, "0 * * * *");
731        } else {
732            panic!("Expected third trigger to be schedule");
733        }
734
735        // Check dependencies include both queues
736        let dependencies = worker.get_dependencies();
737        assert_eq!(dependencies.len(), 2); // Only queues, schedule has no dependency
738        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
739        assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
740    }
741
742    #[test]
743    fn test_worker_with_commands_enabled() {
744        let worker = Worker::new("cmd-worker".to_string())
745            .code(WorkerCode::Image {
746                image: "test-image".to_string(),
747            })
748            .permissions("execution".to_string())
749            .commands_enabled(true)
750            .build();
751
752        assert_eq!(worker.id, "cmd-worker");
753        assert!(worker.public_endpoints.is_empty());
754        assert_eq!(worker.commands_enabled, true);
755    }
756
757    #[test]
758    fn test_worker_defaults() {
759        let worker = Worker::new("default-worker".to_string())
760            .code(WorkerCode::Image {
761                image: "test-image".to_string(),
762            })
763            .permissions("execution".to_string())
764            .build();
765
766        // Test that defaults are applied correctly
767        assert!(worker.public_endpoints.is_empty());
768        assert_eq!(worker.commands_enabled, false);
769        assert_eq!(worker.memory_mb, 256);
770        assert_eq!(worker.timeout_seconds, 180);
771    }
772
773    #[test]
774    fn worker_deserialization_rejects_timeout_outside_supported_range() {
775        let worker = Worker::new("timeout-worker".to_string())
776            .code(WorkerCode::Image {
777                image: "test-image".to_string(),
778            })
779            .permissions("execution".to_string())
780            .build();
781        let mut value = serde_json::to_value(worker).expect("serialize worker");
782
783        value["timeoutSeconds"] = serde_json::json!(0);
784        assert!(serde_json::from_value::<Worker>(value.clone()).is_err());
785
786        value["timeoutSeconds"] = serde_json::json!(MAX_WORKER_TIMEOUT_SECONDS + 1);
787        assert!(serde_json::from_value::<Worker>(value).is_err());
788    }
789
790    #[test]
791    fn worker_builder_rejects_zero_timeout() {
792        let Err(error) = Worker::new("timeout-worker".to_string()).timeout_seconds(0) else {
793            panic!("zero timeout must be rejected");
794        };
795
796        assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
797        assert_eq!(error.http_status_code, Some(400));
798    }
799
800    #[test]
801    fn worker_builder_rejects_timeout_above_maximum() {
802        let Err(error) = Worker::new("timeout-worker".to_string())
803            .timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS + 1)
804        else {
805            panic!("timeout above maximum must be rejected");
806        };
807
808        assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
809    }
810
811    #[test]
812    fn worker_builder_accepts_minimum_timeout() {
813        let worker = Worker::new("timeout-worker".to_string())
814            .timeout_seconds(1)
815            .expect("minimum timeout is valid")
816            .code(WorkerCode::Image {
817                image: "test-image".to_string(),
818            })
819            .permissions("execution".to_string())
820            .build();
821
822        assert_eq!(worker.timeout_seconds, 1);
823    }
824
825    #[test]
826    fn worker_builder_accepts_maximum_timeout() {
827        let worker = Worker::new("timeout-worker".to_string())
828            .timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS)
829            .expect("maximum timeout is valid")
830            .code(WorkerCode::Image {
831                image: "test-image".to_string(),
832            })
833            .permissions("execution".to_string())
834            .build();
835
836        assert_eq!(worker.timeout_seconds, MAX_WORKER_TIMEOUT_SECONDS);
837    }
838
839    #[test]
840    fn test_worker_public_ingress_with_commands() {
841        let worker = Worker::new("public-cmd-worker".to_string())
842            .code(WorkerCode::Image {
843                image: "test-image".to_string(),
844            })
845            .permissions("execution".to_string())
846            .public_endpoint(WorkerPublicEndpoint {
847                name: "api".to_string(),
848                host_label: None,
849                wildcard_subdomains: false,
850            })
851            .commands_enabled(true)
852            .build();
853
854        assert_eq!(worker.public_endpoints[0].name, "api");
855        assert_eq!(worker.commands_enabled, true);
856    }
857
858    #[test]
859    fn worker_rejects_multiple_apex_public_endpoints() {
860        let worker = Worker::new("apex-worker".to_string())
861            .code(WorkerCode::Image {
862                image: "test-image".to_string(),
863            })
864            .permissions("execution".to_string())
865            .public_endpoint(WorkerPublicEndpoint {
866                name: "api".to_string(),
867                host_label: Some(APEX_HOST_LABEL.to_string()),
868                wildcard_subdomains: false,
869            })
870            .public_endpoint(WorkerPublicEndpoint {
871                name: "admin".to_string(),
872                host_label: Some(APEX_HOST_LABEL.to_string()),
873                wildcard_subdomains: false,
874            })
875            .build();
876
877        assert!(worker.validate_public_endpoints().is_err());
878    }
879}