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(default = default_timeout_seconds())]
175 #[serde(default = "default_timeout_seconds")]
176 #[cfg_attr(feature = "openapi", schema(default = default_timeout_seconds))]
177 pub timeout_seconds: u32,
178
179 #[builder(default)]
181 #[serde(default)]
182 pub environment: HashMap<String, String>,
183
184 #[builder(default = default_commands_enabled())]
187 #[serde(default = "default_commands_enabled")]
188 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
189 pub commands_enabled: bool,
190
191 pub concurrency_limit: Option<u32>,
194
195 pub readiness_probe: Option<ReadinessProbe>,
199}
200
201impl Worker {
202 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
204
205 pub fn get_permissions(&self) -> &str {
207 &self.permissions
208 }
209
210 fn validate_public_endpoints(&self) -> Result<()> {
211 let mut endpoint_names = std::collections::HashSet::new();
212 let mut apex_endpoint_name: Option<&str> = None;
213 for endpoint in &self.public_endpoints {
214 endpoint.validate_for_resource(&self.id)?;
215 if !endpoint_names.insert(endpoint.name.as_str()) {
216 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
217 resource_id: self.id.clone(),
218 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
219 }));
220 }
221 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
222 if let Some(existing_name) = apex_endpoint_name {
223 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
224 resource_id: self.id.clone(),
225 reason: format!(
226 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
227 existing_name
228 ),
229 }));
230 }
231 apex_endpoint_name = Some(endpoint.name.as_str());
232 }
233 }
234
235 Ok(())
236 }
237}
238
239fn default_memory_mb() -> u32 {
240 256
241}
242
243fn default_timeout_seconds() -> u32 {
244 180
245}
246
247fn default_commands_enabled() -> bool {
248 false
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
254#[serde(rename_all = "UPPERCASE")]
255#[derive(Default)]
256pub enum HttpMethod {
257 #[default]
258 Get,
259 Post,
260 Put,
261 Delete,
262 Head,
263 Options,
264 Patch,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
272#[serde(rename_all = "camelCase")]
273pub struct ReadinessProbe {
274 #[serde(default)]
277 pub method: HttpMethod,
278
279 #[serde(default = "default_probe_path")]
282 pub path: String,
283}
284
285fn default_probe_path() -> String {
286 "/".to_string()
287}
288
289impl Default for ReadinessProbe {
290 fn default() -> Self {
291 Self {
292 method: HttpMethod::default(),
293 path: default_probe_path(),
294 }
295 }
296}
297
298use crate::resources::worker::worker_builder::State;
299
300impl<S: State> WorkerBuilder<S> {
301 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
304 where
305 for<'a> &'a R: Into<ResourceRef>, {
307 let resource_ref: ResourceRef = resource.into();
309 self.links.push(resource_ref);
310 self
311 }
312
313 pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
329 self.triggers.push(trigger);
330 self
331 }
332
333 pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
335 self.public_endpoints.push(endpoint);
336 self
337 }
338}
339
340impl ResourceDefinition for Worker {
342 fn get_resource_type(&self) -> ResourceType {
343 Self::RESOURCE_TYPE
344 }
345
346 fn id(&self) -> &str {
347 &self.id
348 }
349
350 fn get_dependencies(&self) -> Vec<ResourceRef> {
351 let mut dependencies = self.links.clone();
352
353 for trigger in &self.triggers {
355 match trigger {
356 WorkerTrigger::Queue { queue } => {
357 dependencies.push(queue.clone());
358 }
359 WorkerTrigger::Storage { storage, .. } => {
360 dependencies.push(storage.clone());
361 }
362 WorkerTrigger::Schedule { .. } => {
363 }
365 }
366 }
367
368 dependencies
369 }
370
371 fn get_permissions(&self) -> Option<&str> {
372 Some(&self.permissions)
373 }
374
375 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
376 let new_worker = new_config
378 .as_any()
379 .downcast_ref::<Worker>()
380 .ok_or_else(|| {
381 AlienError::new(ErrorData::UnexpectedResourceType {
382 resource_id: self.id.clone(),
383 expected: Self::RESOURCE_TYPE,
384 actual: new_config.get_resource_type(),
385 })
386 })?;
387
388 if self.id != new_worker.id {
389 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
390 resource_id: self.id.clone(),
391 reason: "the 'id' field is immutable".to_string(),
392 }));
393 }
394 self.validate_public_endpoints()?;
395 new_worker.validate_public_endpoints()?;
396 if self.public_endpoints != new_worker.public_endpoints {
397 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
398 resource_id: self.id.clone(),
399 reason: "the 'publicEndpoints' field is immutable".to_string(),
400 }));
401 }
402 Ok(())
403 }
404
405 fn as_any(&self) -> &dyn Any {
406 self
407 }
408
409 fn as_any_mut(&mut self) -> &mut dyn Any {
410 self
411 }
412
413 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
414 Box::new(self.clone())
415 }
416
417 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
418 other.as_any().downcast_ref::<Worker>() == Some(self)
419 }
420
421 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
422 serde_json::to_value(self)
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
428#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
429#[serde(rename_all = "camelCase")]
430pub struct WorkerOutputs {
431 pub worker_name: String,
433 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
435 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
436 #[serde(skip_serializing_if = "Option::is_none")]
438 pub identifier: Option<String>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub commands_push_target: Option<String>,
445}
446
447impl ResourceOutputsDefinition for WorkerOutputs {
448 fn get_resource_type(&self) -> ResourceType {
449 Worker::RESOURCE_TYPE.clone()
450 }
451
452 fn as_any(&self) -> &dyn Any {
453 self
454 }
455
456 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
457 Box::new(self.clone())
458 }
459
460 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
461 other.as_any().downcast_ref::<WorkerOutputs>() == 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#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::Storage;
473
474 #[test]
475 fn test_worker_builder_direct_refs() {
476 let dummy_storage = Storage::new("test-storage".to_string()).build();
477 let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
478
479 let worker = Worker::new("my-worker".to_string())
480 .code(WorkerCode::Image {
481 image: "test-image".to_string(),
482 })
483 .permissions("execution".to_string())
484 .link(&dummy_storage) .link(&dummy_storage_2) .build();
487
488 assert_eq!(worker.id, "my-worker");
489 assert_eq!(
490 worker.code,
491 WorkerCode::Image {
492 image: "test-image".to_string()
493 }
494 );
495
496 assert_eq!(worker.permissions, "execution");
498
499 assert!(worker
501 .links
502 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
503 assert!(worker
504 .links
505 .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
506 assert_eq!(worker.links.len(), 2); }
508
509 #[test]
510 fn test_worker_with_readiness_probe() {
511 let probe = ReadinessProbe {
512 method: HttpMethod::Post,
513 path: "/health".to_string(),
514 };
515
516 let worker = Worker::new("my-worker".to_string())
517 .code(WorkerCode::Image {
518 image: "test-image".to_string(),
519 })
520 .permissions("execution".to_string())
521 .public_endpoint(WorkerPublicEndpoint {
522 name: "api".to_string(),
523 host_label: None,
524 wildcard_subdomains: false,
525 })
526 .readiness_probe(probe.clone())
527 .build();
528
529 assert_eq!(worker.id, "my-worker");
530 assert_eq!(worker.public_endpoints[0].name, "api");
531 assert_eq!(worker.readiness_probe, Some(probe));
532 }
533
534 #[test]
535 fn test_readiness_probe_defaults() {
536 let probe = ReadinessProbe::default();
537 assert_eq!(probe.method, HttpMethod::Get);
538 assert_eq!(probe.path, "/");
539 }
540
541 #[test]
542 fn test_worker_with_rust_toolchain() {
543 let worker = Worker::new("my-rust-worker".to_string())
544 .code(WorkerCode::Source {
545 src: "./".to_string(),
546 toolchain: ToolchainConfig::Rust {
547 binary_name: "my-app".to_string(),
548 },
549 })
550 .permissions("execution".to_string())
551 .build();
552
553 assert_eq!(worker.id, "my-rust-worker");
554
555 match &worker.code {
556 WorkerCode::Source { src, toolchain } => {
557 assert_eq!(src, "./");
558 assert_eq!(
559 toolchain,
560 &ToolchainConfig::Rust {
561 binary_name: "my-app".to_string(),
562 }
563 );
564 }
565 _ => panic!("Expected Source code"),
566 }
567 }
568
569 #[test]
570 fn test_worker_with_typescript_toolchain() {
571 let worker = Worker::new("my-ts-worker".to_string())
572 .code(WorkerCode::Source {
573 src: "./".to_string(),
574 toolchain: ToolchainConfig::TypeScript {
575 binary_name: Some("my-ts-worker".to_string()),
576 },
577 })
578 .permissions("execution".to_string())
579 .build();
580
581 assert_eq!(worker.id, "my-ts-worker");
582
583 match &worker.code {
584 WorkerCode::Source { src, toolchain } => {
585 assert_eq!(src, "./");
586 assert_eq!(
587 toolchain,
588 &ToolchainConfig::TypeScript {
589 binary_name: Some("my-ts-worker".to_string())
590 }
591 );
592 }
593 _ => panic!("Expected Source code"),
594 }
595 }
596
597 #[test]
598 fn test_worker_with_queue_trigger() {
599 use crate::Queue;
600
601 let queue = Queue::new("test-queue".to_string()).build();
602
603 let worker = Worker::new("triggered-worker".to_string())
604 .code(WorkerCode::Image {
605 image: "test-image".to_string(),
606 })
607 .permissions("execution".to_string())
608 .trigger(WorkerTrigger::queue(&queue))
609 .build();
610
611 assert_eq!(worker.triggers.len(), 1);
612 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
613 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
614 assert_eq!(queue_ref.id, "test-queue");
615 } else {
616 panic!("Expected queue trigger");
617 }
618 }
619
620 #[test]
621 fn test_worker_trigger_dependencies() {
622 use crate::Queue;
623
624 let queue = Queue::new("test-queue".to_string()).build();
625 let storage = Storage::new("test-storage".to_string()).build();
626
627 let worker = Worker::new("triggered-worker".to_string())
628 .code(WorkerCode::Image {
629 image: "test-image".to_string(),
630 })
631 .permissions("execution".to_string())
632 .link(&storage) .trigger(WorkerTrigger::queue(&queue)) .build();
635
636 let dependencies = worker.get_dependencies();
637
638 assert_eq!(dependencies.len(), 2);
640 assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
641 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
642 }
643
644 #[test]
645 fn test_worker_trigger_helper_methods() {
646 use crate::Queue;
647
648 let queue = Queue::new("my-queue".to_string()).build();
649
650 let trigger = WorkerTrigger::queue(&queue);
652
653 if let WorkerTrigger::Queue { queue: queue_ref } = trigger {
654 assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
655 assert_eq!(queue_ref.id, "my-queue");
656 } else {
657 panic!("Expected queue trigger");
658 }
659 }
660
661 #[test]
662 fn test_worker_with_multiple_triggers() {
663 use crate::Queue;
664
665 let queue1 = Queue::new("queue-1".to_string()).build();
666 let queue2 = Queue::new("queue-2".to_string()).build();
667
668 let worker = Worker::new("multi-triggered-worker".to_string())
669 .code(WorkerCode::Image {
670 image: "test-image".to_string(),
671 })
672 .permissions("execution".to_string())
673 .trigger(WorkerTrigger::queue(&queue1))
674 .trigger(WorkerTrigger::queue(&queue2))
675 .trigger(WorkerTrigger::schedule("0 * * * *".to_string()))
676 .build();
677
678 assert_eq!(worker.triggers.len(), 3);
679
680 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[0] {
682 assert_eq!(queue_ref.id, "queue-1");
683 } else {
684 panic!("Expected first trigger to be queue-1");
685 }
686
687 if let WorkerTrigger::Queue { queue: queue_ref } = &worker.triggers[1] {
689 assert_eq!(queue_ref.id, "queue-2");
690 } else {
691 panic!("Expected second trigger to be queue-2");
692 }
693
694 if let WorkerTrigger::Schedule { cron } = &worker.triggers[2] {
696 assert_eq!(cron, "0 * * * *");
697 } else {
698 panic!("Expected third trigger to be schedule");
699 }
700
701 let dependencies = worker.get_dependencies();
703 assert_eq!(dependencies.len(), 2); assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
705 assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
706 }
707
708 #[test]
709 fn test_worker_with_commands_enabled() {
710 let worker = Worker::new("cmd-worker".to_string())
711 .code(WorkerCode::Image {
712 image: "test-image".to_string(),
713 })
714 .permissions("execution".to_string())
715 .commands_enabled(true)
716 .build();
717
718 assert_eq!(worker.id, "cmd-worker");
719 assert!(worker.public_endpoints.is_empty());
720 assert_eq!(worker.commands_enabled, true);
721 }
722
723 #[test]
724 fn test_worker_defaults() {
725 let worker = Worker::new("default-worker".to_string())
726 .code(WorkerCode::Image {
727 image: "test-image".to_string(),
728 })
729 .permissions("execution".to_string())
730 .build();
731
732 assert!(worker.public_endpoints.is_empty());
734 assert_eq!(worker.commands_enabled, false);
735 assert_eq!(worker.memory_mb, 256);
736 assert_eq!(worker.timeout_seconds, 180);
737 }
738
739 #[test]
740 fn test_worker_public_ingress_with_commands() {
741 let worker = Worker::new("public-cmd-worker".to_string())
742 .code(WorkerCode::Image {
743 image: "test-image".to_string(),
744 })
745 .permissions("execution".to_string())
746 .public_endpoint(WorkerPublicEndpoint {
747 name: "api".to_string(),
748 host_label: None,
749 wildcard_subdomains: false,
750 })
751 .commands_enabled(true)
752 .build();
753
754 assert_eq!(worker.public_endpoints[0].name, "api");
755 assert_eq!(worker.commands_enabled, true);
756 }
757
758 #[test]
759 fn worker_rejects_multiple_apex_public_endpoints() {
760 let worker = Worker::new("apex-worker".to_string())
761 .code(WorkerCode::Image {
762 image: "test-image".to_string(),
763 })
764 .permissions("execution".to_string())
765 .public_endpoint(WorkerPublicEndpoint {
766 name: "api".to_string(),
767 host_label: Some(APEX_HOST_LABEL.to_string()),
768 wildcard_subdomains: false,
769 })
770 .public_endpoint(WorkerPublicEndpoint {
771 name: "admin".to_string(),
772 host_label: Some(APEX_HOST_LABEL.to_string()),
773 wildcard_subdomains: false,
774 })
775 .build();
776
777 assert!(worker.validate_public_endpoints().is_err());
778 }
779}