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