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 pub readiness_probe: Option<ReadinessProbe>,
211}
212
213impl Worker {
214 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
216
217 pub fn get_permissions(&self) -> &str {
219 &self.permissions
220 }
221
222 fn validate_public_endpoints(&self) -> Result<()> {
223 let mut endpoint_names = std::collections::HashSet::new();
224 let mut apex_endpoint_name: Option<&str> = None;
225 for endpoint in &self.public_endpoints {
226 endpoint.validate_for_resource(&self.id)?;
227 if !endpoint_names.insert(endpoint.name.as_str()) {
228 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
229 resource_id: self.id.clone(),
230 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
231 }));
232 }
233 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
234 if let Some(existing_name) = apex_endpoint_name {
235 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
236 resource_id: self.id.clone(),
237 reason: format!(
238 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
239 existing_name
240 ),
241 }));
242 }
243 apex_endpoint_name = Some(endpoint.name.as_str());
244 }
245 }
246
247 Ok(())
248 }
249}
250
251fn default_memory_mb() -> u32 {
252 256
253}
254
255fn default_timeout_seconds() -> u32 {
256 180
257}
258
259pub const MAX_WORKER_TIMEOUT_SECONDS: u32 = 3600;
261
262fn deserialize_timeout_seconds<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
263where
264 D: serde::Deserializer<'de>,
265{
266 let value = u32::deserialize(deserializer)?;
267 validate_timeout_seconds(value).map_err(serde::de::Error::custom)
268}
269
270fn validate_timeout_seconds(timeout_seconds: u32) -> Result<u32> {
271 if (1..=MAX_WORKER_TIMEOUT_SECONDS).contains(&timeout_seconds) {
272 return Ok(timeout_seconds);
273 }
274
275 Err(AlienError::new(ErrorData::WorkerTimeoutInvalid {
276 timeout_seconds,
277 max_timeout_seconds: MAX_WORKER_TIMEOUT_SECONDS,
278 }))
279}
280
281fn default_commands_enabled() -> bool {
282 false
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
288#[serde(rename_all = "UPPERCASE")]
289#[derive(Default)]
290pub enum HttpMethod {
291 #[default]
292 Get,
293 Post,
294 Put,
295 Delete,
296 Head,
297 Options,
298 Patch,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
306#[serde(rename_all = "camelCase")]
307pub struct ReadinessProbe {
308 #[serde(default)]
311 pub method: HttpMethod,
312
313 #[serde(default = "default_probe_path")]
316 pub path: String,
317}
318
319fn default_probe_path() -> String {
320 "/".to_string()
321}
322
323impl Default for ReadinessProbe {
324 fn default() -> Self {
325 Self {
326 method: HttpMethod::default(),
327 path: default_probe_path(),
328 }
329 }
330}
331
332use crate::resources::worker::worker_builder::State;
333
334impl<S: State> WorkerBuilder<S> {
335 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
338 where
339 for<'a> &'a R: Into<ResourceRef>, {
341 let resource_ref: ResourceRef = resource.into();
343 self.links.push(resource_ref);
344 self
345 }
346
347 pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
363 self.triggers.push(trigger);
364 self
365 }
366
367 pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
369 self.public_endpoints.push(endpoint);
370 self
371 }
372}
373
374impl ResourceDefinition for Worker {
376 fn get_resource_type(&self) -> ResourceType {
377 Self::RESOURCE_TYPE
378 }
379
380 fn id(&self) -> &str {
381 &self.id
382 }
383
384 fn get_dependencies(&self) -> Vec<ResourceRef> {
385 let mut dependencies = self.links.clone();
386
387 for trigger in &self.triggers {
389 match trigger {
390 WorkerTrigger::Queue { queue } => {
391 dependencies.push(queue.clone());
392 }
393 WorkerTrigger::Storage { storage, .. } => {
394 dependencies.push(storage.clone());
395 }
396 WorkerTrigger::Schedule { .. } => {
397 }
399 }
400 }
401
402 dependencies
403 }
404
405 fn get_permissions(&self) -> Option<&str> {
406 Some(&self.permissions)
407 }
408
409 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
410 let new_worker = new_config
412 .as_any()
413 .downcast_ref::<Worker>()
414 .ok_or_else(|| {
415 AlienError::new(ErrorData::UnexpectedResourceType {
416 resource_id: self.id.clone(),
417 expected: Self::RESOURCE_TYPE,
418 actual: new_config.get_resource_type(),
419 })
420 })?;
421
422 if self.id != new_worker.id {
423 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
424 resource_id: self.id.clone(),
425 reason: "the 'id' field is immutable".to_string(),
426 }));
427 }
428 self.validate_public_endpoints()?;
429 new_worker.validate_public_endpoints()?;
430 if self.public_endpoints != new_worker.public_endpoints {
431 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
432 resource_id: self.id.clone(),
433 reason: "the 'publicEndpoints' field is immutable".to_string(),
434 }));
435 }
436 Ok(())
437 }
438
439 fn as_any(&self) -> &dyn Any {
440 self
441 }
442
443 fn as_any_mut(&mut self) -> &mut dyn Any {
444 self
445 }
446
447 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
448 Box::new(self.clone())
449 }
450
451 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
452 other.as_any().downcast_ref::<Worker>() == Some(self)
453 }
454
455 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
456 serde_json::to_value(self)
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
462#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
463#[serde(rename_all = "camelCase")]
464pub struct WorkerOutputs {
465 pub worker_name: String,
467 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
469 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub identifier: Option<String>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub commands_push_target: Option<String>,
479}
480
481impl ResourceOutputsDefinition for WorkerOutputs {
482 fn get_resource_type(&self) -> ResourceType {
483 Worker::RESOURCE_TYPE.clone()
484 }
485
486 fn as_any(&self) -> &dyn Any {
487 self
488 }
489
490 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
491 Box::new(self.clone())
492 }
493
494 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
495 other.as_any().downcast_ref::<WorkerOutputs>() == Some(self)
496 }
497
498 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
499 serde_json::to_value(self)
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::Storage;
507
508 #[test]
509 fn test_worker_builder_direct_refs() {
510 let dummy_storage = Storage::new("test-storage".to_string()).build();
511 let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
512
513 let worker = Worker::new("my-worker".to_string())
514 .code(WorkerCode::Image {
515 image: "test-image".to_string(),
516 })
517 .permissions("execution".to_string())
518 .link(&dummy_storage) .link(&dummy_storage_2) .build();
521
522 assert_eq!(worker.id, "my-worker");
523 assert_eq!(
524 worker.code,
525 WorkerCode::Image {
526 image: "test-image".to_string()
527 }
528 );
529
530 assert_eq!(worker.permissions, "execution");
532
533 assert!(worker
535 .links
536 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
537 assert!(worker
538 .links
539 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
540 assert_eq!(worker.links.len(), 2); }
542
543 #[test]
544 fn test_worker_with_readiness_probe() {
545 let probe = ReadinessProbe {
546 method: HttpMethod::Post,
547 path: "/health".to_string(),
548 };
549
550 let worker = Worker::new("my-worker".to_string())
551 .code(WorkerCode::Image {
552 image: "test-image".to_string(),
553 })
554 .permissions("execution".to_string())
555 .public_endpoint(WorkerPublicEndpoint {
556 name: "api".to_string(),
557 host_label: None,
558 wildcard_subdomains: false,
559 })
560 .readiness_probe(probe.clone())
561 .build();
562
563 assert_eq!(worker.id, "my-worker");
564 assert_eq!(worker.public_endpoints[0].name, "api");
565 assert_eq!(worker.readiness_probe, Some(probe));
566 }
567
568 #[test]
569 fn test_readiness_probe_defaults() {
570 let probe = ReadinessProbe::default();
571 assert_eq!(probe.method, HttpMethod::Get);
572 assert_eq!(probe.path, "/");
573 }
574
575 #[test]
576 fn test_worker_with_rust_toolchain() {
577 let worker = Worker::new("my-rust-worker".to_string())
578 .code(WorkerCode::Source {
579 src: "./".to_string(),
580 toolchain: ToolchainConfig::Rust {
581 binary_name: "my-app".to_string(),
582 },
583 })
584 .permissions("execution".to_string())
585 .build();
586
587 assert_eq!(worker.id, "my-rust-worker");
588
589 match &worker.code {
590 WorkerCode::Source { src, toolchain } => {
591 assert_eq!(src, "./");
592 assert_eq!(
593 toolchain,
594 &ToolchainConfig::Rust {
595 binary_name: "my-app".to_string(),
596 }
597 );
598 }
599 _ => panic!("Expected Source code"),
600 }
601 }
602
603 #[test]
604 fn test_worker_with_typescript_toolchain() {
605 let worker = Worker::new("my-ts-worker".to_string())
606 .code(WorkerCode::Source {
607 src: "./".to_string(),
608 toolchain: ToolchainConfig::TypeScript {
609 binary_name: Some("my-ts-worker".to_string()),
610 },
611 })
612 .permissions("execution".to_string())
613 .build();
614
615 assert_eq!(worker.id, "my-ts-worker");
616
617 match &worker.code {
618 WorkerCode::Source { src, toolchain } => {
619 assert_eq!(src, "./");
620 assert_eq!(
621 toolchain,
622 &ToolchainConfig::TypeScript {
623 binary_name: Some("my-ts-worker".to_string())
624 }
625 );
626 }
627 _ => panic!("Expected Source code"),
628 }
629 }
630
631 #[test]
632 fn test_worker_with_queue_trigger() {
633 use crate::Queue;
634
635 let queue = Queue::new("test-queue".to_string()).build();
636
637 let worker = Worker::new("triggered-worker".to_string())
638 .code(WorkerCode::Image {
639 image: "test-image".to_string(),
640 })
641 .permissions("execution".to_string())
642 .trigger(WorkerTrigger::queue(&queue))
643 .build();
644
645 assert_eq!(worker.triggers.len(), 1);
646 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
647 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
648 assert_eq!(queue_ref.id, "test-queue");
649 } else {
650 panic!("Expected queue trigger");
651 }
652 }
653
654 #[test]
655 fn test_worker_trigger_dependencies() {
656 use crate::Queue;
657
658 let queue = Queue::new("test-queue".to_string()).build();
659 let storage = Storage::new("test-storage".to_string()).build();
660
661 let worker = Worker::new("triggered-worker".to_string())
662 .code(WorkerCode::Image {
663 image: "test-image".to_string(),
664 })
665 .permissions("execution".to_string())
666 .link(&storage) .trigger(WorkerTrigger::queue(&queue)) .build();
669
670 let dependencies = worker.get_dependencies();
671
672 assert_eq!(dependencies.len(), 2);
674 assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
675 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
676 }
677
678 #[test]
679 fn test_worker_trigger_helper_methods() {
680 use crate::Queue;
681
682 let queue = Queue::new("my-queue".to_string()).build();
683
684 let trigger = WorkerTrigger::queue(&queue);
686
687 if let WorkerTrigger::Queue { queue: queue_ref } = trigger {
688 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
689 assert_eq!(queue_ref.id, "my-queue");
690 } else {
691 panic!("Expected queue trigger");
692 }
693 }
694
695 #[test]
696 fn test_worker_with_multiple_triggers() {
697 use crate::Queue;
698
699 let queue1 = Queue::new("queue-1".to_string()).build();
700 let queue2 = Queue::new("queue-2".to_string()).build();
701
702 let worker = Worker::new("multi-triggered-worker".to_string())
703 .code(WorkerCode::Image {
704 image: "test-image".to_string(),
705 })
706 .permissions("execution".to_string())
707 .trigger(WorkerTrigger::queue(&queue1))
708 .trigger(WorkerTrigger::queue(&queue2))
709 .trigger(WorkerTrigger::schedule("0 * * * *".to_string()))
710 .build();
711
712 assert_eq!(worker.triggers.len(), 3);
713
714 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
716 assert_eq!(queue_ref.id, "queue-1");
717 } else {
718 panic!("Expected first trigger to be queue-1");
719 }
720
721 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[1] {
723 assert_eq!(queue_ref.id, "queue-2");
724 } else {
725 panic!("Expected second trigger to be queue-2");
726 }
727
728 if let WorkerTrigger::Schedule { cron } = &worker.triggers[2] {
730 assert_eq!(cron, "0 * * * *");
731 } else {
732 panic!("Expected third trigger to be schedule");
733 }
734
735 let dependencies = worker.get_dependencies();
737 assert_eq!(dependencies.len(), 2); assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
739 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
740 }
741
742 #[test]
743 fn test_worker_with_commands_enabled() {
744 let worker = Worker::new("cmd-worker".to_string())
745 .code(WorkerCode::Image {
746 image: "test-image".to_string(),
747 })
748 .permissions("execution".to_string())
749 .commands_enabled(true)
750 .build();
751
752 assert_eq!(worker.id, "cmd-worker");
753 assert!(worker.public_endpoints.is_empty());
754 assert_eq!(worker.commands_enabled, true);
755 }
756
757 #[test]
758 fn test_worker_defaults() {
759 let worker = Worker::new("default-worker".to_string())
760 .code(WorkerCode::Image {
761 image: "test-image".to_string(),
762 })
763 .permissions("execution".to_string())
764 .build();
765
766 assert!(worker.public_endpoints.is_empty());
768 assert_eq!(worker.commands_enabled, false);
769 assert_eq!(worker.memory_mb, 256);
770 assert_eq!(worker.timeout_seconds, 180);
771 }
772
773 #[test]
774 fn worker_deserialization_rejects_timeout_outside_supported_range() {
775 let worker = Worker::new("timeout-worker".to_string())
776 .code(WorkerCode::Image {
777 image: "test-image".to_string(),
778 })
779 .permissions("execution".to_string())
780 .build();
781 let mut value = serde_json::to_value(worker).expect("serialize worker");
782
783 value["timeoutSeconds"] = serde_json::json!(0);
784 assert!(serde_json::from_value::<Worker>(value.clone()).is_err());
785
786 value["timeoutSeconds"] = serde_json::json!(MAX_WORKER_TIMEOUT_SECONDS + 1);
787 assert!(serde_json::from_value::<Worker>(value).is_err());
788 }
789
790 #[test]
791 fn worker_builder_rejects_zero_timeout() {
792 let Err(error) = Worker::new("timeout-worker".to_string()).timeout_seconds(0) else {
793 panic!("zero timeout must be rejected");
794 };
795
796 assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
797 assert_eq!(error.http_status_code, Some(400));
798 }
799
800 #[test]
801 fn worker_builder_rejects_timeout_above_maximum() {
802 let Err(error) = Worker::new("timeout-worker".to_string())
803 .timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS + 1)
804 else {
805 panic!("timeout above maximum must be rejected");
806 };
807
808 assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
809 }
810
811 #[test]
812 fn worker_builder_accepts_minimum_timeout() {
813 let worker = Worker::new("timeout-worker".to_string())
814 .timeout_seconds(1)
815 .expect("minimum timeout is valid")
816 .code(WorkerCode::Image {
817 image: "test-image".to_string(),
818 })
819 .permissions("execution".to_string())
820 .build();
821
822 assert_eq!(worker.timeout_seconds, 1);
823 }
824
825 #[test]
826 fn worker_builder_accepts_maximum_timeout() {
827 let worker = Worker::new("timeout-worker".to_string())
828 .timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS)
829 .expect("maximum timeout is valid")
830 .code(WorkerCode::Image {
831 image: "test-image".to_string(),
832 })
833 .permissions("execution".to_string())
834 .build();
835
836 assert_eq!(worker.timeout_seconds, MAX_WORKER_TIMEOUT_SECONDS);
837 }
838
839 #[test]
840 fn test_worker_public_ingress_with_commands() {
841 let worker = Worker::new("public-cmd-worker".to_string())
842 .code(WorkerCode::Image {
843 image: "test-image".to_string(),
844 })
845 .permissions("execution".to_string())
846 .public_endpoint(WorkerPublicEndpoint {
847 name: "api".to_string(),
848 host_label: None,
849 wildcard_subdomains: false,
850 })
851 .commands_enabled(true)
852 .build();
853
854 assert_eq!(worker.public_endpoints[0].name, "api");
855 assert_eq!(worker.commands_enabled, true);
856 }
857
858 #[test]
859 fn worker_rejects_multiple_apex_public_endpoints() {
860 let worker = Worker::new("apex-worker".to_string())
861 .code(WorkerCode::Image {
862 image: "test-image".to_string(),
863 })
864 .permissions("execution".to_string())
865 .public_endpoint(WorkerPublicEndpoint {
866 name: "api".to_string(),
867 host_label: Some(APEX_HOST_LABEL.to_string()),
868 wildcard_subdomains: false,
869 })
870 .public_endpoint(WorkerPublicEndpoint {
871 name: "admin".to_string(),
872 host_label: Some(APEX_HOST_LABEL.to_string()),
873 wildcard_subdomains: false,
874 })
875 .build();
876
877 assert!(worker.validate_public_endpoints().is_err());
878 }
879}