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