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#[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 #[serde(rename_all = "camelCase")]
19 Image {
20 image: String,
22 },
23 #[serde(rename_all = "camelCase")]
25 Source {
26 src: String,
28 toolchain: ToolchainConfig,
30 },
31}
32
33#[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 #[serde(rename_all = "camelCase")]
41 Rust {
42 binary_name: String,
44 },
45 #[serde(rename_all = "camelCase")]
47 TypeScript {
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 binary_name: Option<String>,
51 },
52 #[serde(rename_all = "camelCase")]
54 Python {
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 python_version: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 package: Option<String>,
61 command: Vec<String>,
63 },
64 #[serde(rename_all = "camelCase")]
66 Docker {
67 #[serde(skip_serializing_if = "Option::is_none")]
69 dockerfile: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 build_args: Option<HashMap<String, String>>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 target: Option<String>,
76 },
77}
78
79#[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 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 100))]
87 pub max_messages: u32,
88 #[cfg_attr(feature = "openapi", schema(minimum = 0, maximum = 300))]
91 pub max_window_seconds: u32,
92}
93
94impl QueueBatchConfig {
95 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#[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 Queue {
130 queue: ResourceRef,
132 #[serde(
134 default,
135 skip_serializing_if = "Option::is_none",
136 deserialize_with = "deserialize_queue_batch"
137 )]
138 batch: Option<QueueBatchConfig>,
139 },
140 Storage {
142 storage: ResourceRef,
144 events: Vec<String>,
146 },
147 Schedule {
149 cron: String,
151 },
152}
153
154impl WorkerTrigger {
155 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 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 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 pub fn schedule<S: Into<String>>(cron: S) -> Self {
197 WorkerTrigger::Schedule { cron: cron.into() }
198 }
199}
200
201#[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 #[builder(start_fn)]
211 pub id: String,
212
213 #[builder(field)]
216 pub links: Vec<ResourceRef>,
217
218 #[builder(field)]
222 pub triggers: Vec<WorkerTrigger>,
223
224 #[builder(field)]
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub public_endpoints: Vec<WorkerPublicEndpoint>,
228
229 pub permissions: String,
232
233 pub code: WorkerCode,
235
236 #[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 #[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 #[builder(default)]
270 #[serde(default)]
271 pub environment: HashMap<String, String>,
272
273 #[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 pub concurrency_limit: Option<u32>,
284
285 pub readiness_probe: Option<ReadinessProbe>,
289}
290
291impl Worker {
292 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
294
295 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 pub fn has_queue_batches(&self) -> bool {
314 self.triggers
315 .iter()
316 .any(|trigger| matches!(trigger, WorkerTrigger::Queue { batch: Some(_), .. }))
317 }
318
319 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 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 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 pub fn get_permissions(&self) -> &str {
375 &self.permissions
376 }
377
378 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
428pub 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
475#[serde(rename_all = "camelCase")]
476pub struct ReadinessProbe {
477 #[serde(default)]
480 pub method: HttpMethod,
481
482 #[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 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
507 where
508 for<'a> &'a R: Into<ResourceRef>, {
510 let resource_ref: ResourceRef = resource.into();
512 self.links.push(resource_ref);
513 self
514 }
515
516 pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
532 self.triggers.push(trigger);
533 self
534 }
535
536 pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
538 self.public_endpoints.push(endpoint);
539 self
540 }
541}
542
543impl 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 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 }
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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
631#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
632#[serde(rename_all = "camelCase")]
633pub struct WorkerOutputs {
634 pub worker_name: String,
636 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
638 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
639 #[serde(skip_serializing_if = "Option::is_none")]
641 pub identifier: Option<String>,
642 #[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) .link(&dummy_storage_2) .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 assert_eq!(worker.permissions, "execution");
701
702 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); }
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) .trigger(WorkerTrigger::queue(&queue)) .build();
841
842 let dependencies = worker.get_dependencies();
843
844 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 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 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 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 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 let dependencies = worker.get_dependencies();
918 assert_eq!(dependencies.len(), 2); 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 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}