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#[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 Docker {
55 #[serde(skip_serializing_if = "Option::is_none")]
57 dockerfile: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 build_args: Option<HashMap<String, String>>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 target: Option<String>,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[serde(tag = "type", rename_all = "camelCase")]
71pub enum WorkerTrigger {
72 Queue {
74 queue: ResourceRef,
76 },
77 Storage {
79 storage: ResourceRef,
81 events: Vec<String>,
83 },
84 Schedule {
86 cron: String,
88 },
89}
90
91impl WorkerTrigger {
92 pub fn queue<R: ?Sized>(queue: &R) -> Self
96 where
97 for<'a> &'a R: Into<ResourceRef>,
98 {
99 let queue_ref: ResourceRef = queue.into();
100 WorkerTrigger::Queue { queue: queue_ref }
101 }
102
103 pub fn storage<R: ?Sized>(storage: &R, events: Vec<String>) -> Self
106 where
107 for<'a> &'a R: Into<ResourceRef>,
108 {
109 let storage_ref: ResourceRef = storage.into();
110 WorkerTrigger::Storage {
111 storage: storage_ref,
112 events,
113 }
114 }
115
116 pub fn schedule<S: Into<String>>(cron: S) -> Self {
119 WorkerTrigger::Schedule { cron: cron.into() }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
126#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
127#[serde(rename_all = "camelCase", deny_unknown_fields)]
128#[builder(start_fn = new)]
129pub struct Worker {
130 #[builder(start_fn)]
133 pub id: String,
134
135 #[builder(field)]
138 pub links: Vec<ResourceRef>,
139
140 #[builder(field)]
144 pub triggers: Vec<WorkerTrigger>,
145
146 #[builder(field)]
148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub public_endpoints: Vec<WorkerPublicEndpoint>,
150
151 pub permissions: String,
154
155 pub code: WorkerCode,
157
158 #[builder(default = default_memory_mb())]
167 #[serde(default = "default_memory_mb")]
168 #[cfg_attr(feature = "openapi", schema(default = default_memory_mb))]
169 pub memory_mb: u32,
170
171 #[builder(
175 default = default_timeout_seconds(),
176 with = |timeout_seconds: u32| -> crate::Result<_> {
177 validate_timeout_seconds(timeout_seconds)
178 }
179 )]
180 #[serde(
181 default = "default_timeout_seconds",
182 deserialize_with = "deserialize_timeout_seconds"
183 )]
184 #[cfg_attr(
185 feature = "openapi",
186 schema(default = default_timeout_seconds, minimum = 1, maximum = 3600)
187 )]
188 pub timeout_seconds: u32,
189
190 #[builder(default)]
192 #[serde(default)]
193 pub environment: HashMap<String, String>,
194
195 #[builder(default = default_commands_enabled())]
199 #[serde(default = "default_commands_enabled")]
200 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
201 pub commands_enabled: bool,
202
203 pub concurrency_limit: Option<u32>,
206
207 #[builder(default)]
213 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
214 pub sandbox_launcher: bool,
215
216 pub readiness_probe: Option<ReadinessProbe>,
220}
221
222impl Worker {
223 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
225
226 pub fn get_permissions(&self) -> &str {
228 &self.permissions
229 }
230
231 fn validate_public_endpoints(&self) -> Result<()> {
232 let mut endpoint_names = std::collections::HashSet::new();
233 let mut apex_endpoint_name: Option<&str> = None;
234 for endpoint in &self.public_endpoints {
235 endpoint.validate_for_resource(&self.id)?;
236 if !endpoint_names.insert(endpoint.name.as_str()) {
237 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
238 resource_id: self.id.clone(),
239 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
240 }));
241 }
242 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
243 if let Some(existing_name) = apex_endpoint_name {
244 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
245 resource_id: self.id.clone(),
246 reason: format!(
247 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
248 existing_name
249 ),
250 }));
251 }
252 apex_endpoint_name = Some(endpoint.name.as_str());
253 }
254 }
255
256 Ok(())
257 }
258}
259
260fn default_memory_mb() -> u32 {
261 512
262}
263
264fn default_timeout_seconds() -> u32 {
265 180
266}
267
268pub const MAX_WORKER_TIMEOUT_SECONDS: u32 = 3600;
270
271fn deserialize_timeout_seconds<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
272where
273 D: serde::Deserializer<'de>,
274{
275 let value = u32::deserialize(deserializer)?;
276 validate_timeout_seconds(value).map_err(serde::de::Error::custom)
277}
278
279fn validate_timeout_seconds(timeout_seconds: u32) -> Result<u32> {
280 if (1..=MAX_WORKER_TIMEOUT_SECONDS).contains(&timeout_seconds) {
281 return Ok(timeout_seconds);
282 }
283
284 Err(AlienError::new(ErrorData::WorkerTimeoutInvalid {
285 timeout_seconds,
286 max_timeout_seconds: MAX_WORKER_TIMEOUT_SECONDS,
287 }))
288}
289
290fn default_commands_enabled() -> bool {
291 false
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
297#[serde(rename_all = "UPPERCASE")]
298#[derive(Default)]
299pub enum HttpMethod {
300 #[default]
301 Get,
302 Post,
303 Put,
304 Delete,
305 Head,
306 Options,
307 Patch,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
315#[serde(rename_all = "camelCase")]
316pub struct ReadinessProbe {
317 #[serde(default)]
320 pub method: HttpMethod,
321
322 #[serde(default = "default_probe_path")]
325 pub path: String,
326}
327
328fn default_probe_path() -> String {
329 "/".to_string()
330}
331
332impl Default for ReadinessProbe {
333 fn default() -> Self {
334 Self {
335 method: HttpMethod::default(),
336 path: default_probe_path(),
337 }
338 }
339}
340
341use crate::resources::worker::worker_builder::State;
342
343impl<S: State> WorkerBuilder<S> {
344 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
347 where
348 for<'a> &'a R: Into<ResourceRef>, {
350 let resource_ref: ResourceRef = resource.into();
352 self.links.push(resource_ref);
353 self
354 }
355
356 pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
372 self.triggers.push(trigger);
373 self
374 }
375
376 pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
378 self.public_endpoints.push(endpoint);
379 self
380 }
381}
382
383impl ResourceDefinition for Worker {
385 fn get_resource_type(&self) -> ResourceType {
386 Self::RESOURCE_TYPE
387 }
388
389 fn id(&self) -> &str {
390 &self.id
391 }
392
393 fn get_dependencies(&self) -> Vec<ResourceRef> {
394 let mut dependencies = self.links.clone();
395
396 for trigger in &self.triggers {
398 match trigger {
399 WorkerTrigger::Queue { queue } => {
400 dependencies.push(queue.clone());
401 }
402 WorkerTrigger::Storage { storage, .. } => {
403 dependencies.push(storage.clone());
404 }
405 WorkerTrigger::Schedule { .. } => {
406 }
408 }
409 }
410
411 dependencies
412 }
413
414 fn get_permissions(&self) -> Option<&str> {
415 Some(&self.permissions)
416 }
417
418 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
419 let new_worker = new_config
421 .as_any()
422 .downcast_ref::<Worker>()
423 .ok_or_else(|| {
424 AlienError::new(ErrorData::UnexpectedResourceType {
425 resource_id: self.id.clone(),
426 expected: Self::RESOURCE_TYPE,
427 actual: new_config.get_resource_type(),
428 })
429 })?;
430
431 if self.id != new_worker.id {
432 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
433 resource_id: self.id.clone(),
434 reason: "the 'id' field is immutable".to_string(),
435 }));
436 }
437 self.validate_public_endpoints()?;
438 new_worker.validate_public_endpoints()?;
439 if self.public_endpoints != new_worker.public_endpoints {
440 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
441 resource_id: self.id.clone(),
442 reason: "the 'publicEndpoints' field is immutable".to_string(),
443 }));
444 }
445 Ok(())
446 }
447
448 fn as_any(&self) -> &dyn Any {
449 self
450 }
451
452 fn as_any_mut(&mut self) -> &mut dyn Any {
453 self
454 }
455
456 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
457 Box::new(self.clone())
458 }
459
460 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
461 other.as_any().downcast_ref::<Worker>() == Some(self)
462 }
463
464 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
465 serde_json::to_value(self)
466 }
467}
468
469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
472#[serde(rename_all = "camelCase")]
473pub struct WorkerOutputs {
474 pub worker_name: String,
476 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
478 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub identifier: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub commands_push_target: Option<String>,
488}
489
490impl ResourceOutputsDefinition for WorkerOutputs {
491 fn get_resource_type(&self) -> ResourceType {
492 Worker::RESOURCE_TYPE.clone()
493 }
494
495 fn as_any(&self) -> &dyn Any {
496 self
497 }
498
499 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
500 Box::new(self.clone())
501 }
502
503 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
504 other.as_any().downcast_ref::<WorkerOutputs>() == Some(self)
505 }
506
507 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
508 serde_json::to_value(self)
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::Storage;
516
517 #[test]
518 fn test_worker_builder_direct_refs() {
519 let dummy_storage = Storage::new("test-storage".to_string()).build();
520 let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
521
522 let worker = Worker::new("my-worker".to_string())
523 .code(WorkerCode::Image {
524 image: "test-image".to_string(),
525 })
526 .permissions("execution".to_string())
527 .link(&dummy_storage) .link(&dummy_storage_2) .build();
530
531 assert_eq!(worker.id, "my-worker");
532 assert_eq!(
533 worker.code,
534 WorkerCode::Image {
535 image: "test-image".to_string()
536 }
537 );
538
539 assert_eq!(worker.permissions, "execution");
541
542 assert!(worker
544 .links
545 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
546 assert!(worker
547 .links
548 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
549 assert_eq!(worker.links.len(), 2); }
551
552 #[test]
553 fn test_worker_with_readiness_probe() {
554 let probe = ReadinessProbe {
555 method: HttpMethod::Post,
556 path: "/health".to_string(),
557 };
558
559 let worker = Worker::new("my-worker".to_string())
560 .code(WorkerCode::Image {
561 image: "test-image".to_string(),
562 })
563 .permissions("execution".to_string())
564 .public_endpoint(WorkerPublicEndpoint {
565 name: "api".to_string(),
566 host_label: None,
567 wildcard_subdomains: false,
568 })
569 .readiness_probe(probe.clone())
570 .build();
571
572 assert_eq!(worker.id, "my-worker");
573 assert_eq!(worker.public_endpoints[0].name, "api");
574 assert_eq!(worker.readiness_probe, Some(probe));
575 }
576
577 #[test]
578 fn test_readiness_probe_defaults() {
579 let probe = ReadinessProbe::default();
580 assert_eq!(probe.method, HttpMethod::Get);
581 assert_eq!(probe.path, "/");
582 }
583
584 #[test]
585 fn test_worker_with_rust_toolchain() {
586 let worker = Worker::new("my-rust-worker".to_string())
587 .code(WorkerCode::Source {
588 src: "./".to_string(),
589 toolchain: ToolchainConfig::Rust {
590 binary_name: "my-app".to_string(),
591 },
592 })
593 .permissions("execution".to_string())
594 .build();
595
596 assert_eq!(worker.id, "my-rust-worker");
597
598 match &worker.code {
599 WorkerCode::Source { src, toolchain } => {
600 assert_eq!(src, "./");
601 assert_eq!(
602 toolchain,
603 &ToolchainConfig::Rust {
604 binary_name: "my-app".to_string(),
605 }
606 );
607 }
608 _ => panic!("Expected Source code"),
609 }
610 }
611
612 #[test]
613 fn test_worker_with_typescript_toolchain() {
614 let worker = Worker::new("my-ts-worker".to_string())
615 .code(WorkerCode::Source {
616 src: "./".to_string(),
617 toolchain: ToolchainConfig::TypeScript {
618 binary_name: Some("my-ts-worker".to_string()),
619 },
620 })
621 .permissions("execution".to_string())
622 .build();
623
624 assert_eq!(worker.id, "my-ts-worker");
625
626 match &worker.code {
627 WorkerCode::Source { src, toolchain } => {
628 assert_eq!(src, "./");
629 assert_eq!(
630 toolchain,
631 &ToolchainConfig::TypeScript {
632 binary_name: Some("my-ts-worker".to_string())
633 }
634 );
635 }
636 _ => panic!("Expected Source code"),
637 }
638 }
639
640 #[test]
641 fn test_worker_with_queue_trigger() {
642 use crate::Queue;
643
644 let queue = Queue::new("test-queue".to_string()).build();
645
646 let worker = Worker::new("triggered-worker".to_string())
647 .code(WorkerCode::Image {
648 image: "test-image".to_string(),
649 })
650 .permissions("execution".to_string())
651 .trigger(WorkerTrigger::queue(&queue))
652 .build();
653
654 assert_eq!(worker.triggers.len(), 1);
655 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
656 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
657 assert_eq!(queue_ref.id, "test-queue");
658 } else {
659 panic!("Expected queue trigger");
660 }
661 }
662
663 #[test]
664 fn test_worker_trigger_dependencies() {
665 use crate::Queue;
666
667 let queue = Queue::new("test-queue".to_string()).build();
668 let storage = Storage::new("test-storage".to_string()).build();
669
670 let worker = Worker::new("triggered-worker".to_string())
671 .code(WorkerCode::Image {
672 image: "test-image".to_string(),
673 })
674 .permissions("execution".to_string())
675 .link(&storage) .trigger(WorkerTrigger::queue(&queue)) .build();
678
679 let dependencies = worker.get_dependencies();
680
681 assert_eq!(dependencies.len(), 2);
683 assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
684 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
685 }
686
687 #[test]
688 fn test_worker_trigger_helper_methods() {
689 use crate::Queue;
690
691 let queue = Queue::new("my-queue".to_string()).build();
692
693 let trigger = WorkerTrigger::queue(&queue);
695
696 if let WorkerTrigger::Queue { queue: queue_ref } = trigger {
697 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
698 assert_eq!(queue_ref.id, "my-queue");
699 } else {
700 panic!("Expected queue trigger");
701 }
702 }
703
704 #[test]
705 fn test_worker_with_multiple_triggers() {
706 use crate::Queue;
707
708 let queue1 = Queue::new("queue-1".to_string()).build();
709 let queue2 = Queue::new("queue-2".to_string()).build();
710
711 let worker = Worker::new("multi-triggered-worker".to_string())
712 .code(WorkerCode::Image {
713 image: "test-image".to_string(),
714 })
715 .permissions("execution".to_string())
716 .trigger(WorkerTrigger::queue(&queue1))
717 .trigger(WorkerTrigger::queue(&queue2))
718 .trigger(WorkerTrigger::schedule("0 * * * *".to_string()))
719 .build();
720
721 assert_eq!(worker.triggers.len(), 3);
722
723 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
725 assert_eq!(queue_ref.id, "queue-1");
726 } else {
727 panic!("Expected first trigger to be queue-1");
728 }
729
730 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[1] {
732 assert_eq!(queue_ref.id, "queue-2");
733 } else {
734 panic!("Expected second trigger to be queue-2");
735 }
736
737 if let WorkerTrigger::Schedule { cron } = &worker.triggers[2] {
739 assert_eq!(cron, "0 * * * *");
740 } else {
741 panic!("Expected third trigger to be schedule");
742 }
743
744 let dependencies = worker.get_dependencies();
746 assert_eq!(dependencies.len(), 2); assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
748 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
749 }
750
751 #[test]
752 fn test_worker_with_commands_enabled() {
753 let worker = Worker::new("cmd-worker".to_string())
754 .code(WorkerCode::Image {
755 image: "test-image".to_string(),
756 })
757 .permissions("execution".to_string())
758 .commands_enabled(true)
759 .build();
760
761 assert_eq!(worker.id, "cmd-worker");
762 assert!(worker.public_endpoints.is_empty());
763 assert_eq!(worker.commands_enabled, true);
764 }
765
766 #[test]
767 fn test_worker_defaults() {
768 let worker = Worker::new("default-worker".to_string())
769 .code(WorkerCode::Image {
770 image: "test-image".to_string(),
771 })
772 .permissions("execution".to_string())
773 .build();
774
775 assert!(worker.public_endpoints.is_empty());
777 assert_eq!(worker.commands_enabled, false);
778 assert_eq!(worker.memory_mb, 512);
779 assert_eq!(worker.timeout_seconds, 180);
780 }
781
782 #[test]
783 fn worker_deserialization_rejects_timeout_outside_supported_range() {
784 let worker = Worker::new("timeout-worker".to_string())
785 .code(WorkerCode::Image {
786 image: "test-image".to_string(),
787 })
788 .permissions("execution".to_string())
789 .build();
790 let mut value = serde_json::to_value(worker).expect("serialize worker");
791
792 value["timeoutSeconds"] = serde_json::json!(0);
793 assert!(serde_json::from_value::<Worker>(value.clone()).is_err());
794
795 value["timeoutSeconds"] = serde_json::json!(MAX_WORKER_TIMEOUT_SECONDS + 1);
796 assert!(serde_json::from_value::<Worker>(value).is_err());
797 }
798
799 #[test]
800 fn worker_builder_rejects_zero_timeout() {
801 let Err(error) = Worker::new("timeout-worker".to_string()).timeout_seconds(0) else {
802 panic!("zero timeout must be rejected");
803 };
804
805 assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
806 assert_eq!(error.http_status_code, Some(400));
807 }
808
809 #[test]
810 fn worker_builder_rejects_timeout_above_maximum() {
811 let Err(error) = Worker::new("timeout-worker".to_string())
812 .timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS + 1)
813 else {
814 panic!("timeout above maximum must be rejected");
815 };
816
817 assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
818 }
819
820 #[test]
821 fn worker_builder_accepts_minimum_timeout() {
822 let worker = Worker::new("timeout-worker".to_string())
823 .timeout_seconds(1)
824 .expect("minimum timeout is valid")
825 .code(WorkerCode::Image {
826 image: "test-image".to_string(),
827 })
828 .permissions("execution".to_string())
829 .build();
830
831 assert_eq!(worker.timeout_seconds, 1);
832 }
833
834 #[test]
835 fn worker_builder_accepts_maximum_timeout() {
836 let worker = Worker::new("timeout-worker".to_string())
837 .timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS)
838 .expect("maximum timeout is valid")
839 .code(WorkerCode::Image {
840 image: "test-image".to_string(),
841 })
842 .permissions("execution".to_string())
843 .build();
844
845 assert_eq!(worker.timeout_seconds, MAX_WORKER_TIMEOUT_SECONDS);
846 }
847
848 #[test]
849 fn test_worker_public_ingress_with_commands() {
850 let worker = Worker::new("public-cmd-worker".to_string())
851 .code(WorkerCode::Image {
852 image: "test-image".to_string(),
853 })
854 .permissions("execution".to_string())
855 .public_endpoint(WorkerPublicEndpoint {
856 name: "api".to_string(),
857 host_label: None,
858 wildcard_subdomains: false,
859 })
860 .commands_enabled(true)
861 .build();
862
863 assert_eq!(worker.public_endpoints[0].name, "api");
864 assert_eq!(worker.commands_enabled, true);
865 }
866
867 #[test]
868 fn worker_rejects_multiple_apex_public_endpoints() {
869 let worker = Worker::new("apex-worker".to_string())
870 .code(WorkerCode::Image {
871 image: "test-image".to_string(),
872 })
873 .permissions("execution".to_string())
874 .public_endpoint(WorkerPublicEndpoint {
875 name: "api".to_string(),
876 host_label: Some(APEX_HOST_LABEL.to_string()),
877 wildcard_subdomains: false,
878 })
879 .public_endpoint(WorkerPublicEndpoint {
880 name: "admin".to_string(),
881 host_label: Some(APEX_HOST_LABEL.to_string()),
882 wildcard_subdomains: false,
883 })
884 .build();
885
886 assert!(worker.validate_public_endpoints().is_err());
887 }
888}