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