use crate::error::{ErrorData, Result};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
use crate::{PublicEndpointOutput, WorkerPublicEndpoint, APEX_HOST_LABEL};
use alien_error::{AlienError, Context, IntoAlienError};
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::collections::HashMap;
use std::fmt::Debug;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum WorkerCode {
#[serde(rename_all = "camelCase")]
Image {
image: String,
},
#[serde(rename_all = "camelCase")]
Source {
src: String,
toolchain: ToolchainConfig,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase", tag = "type")]
pub enum ToolchainConfig {
#[serde(rename_all = "camelCase")]
Rust {
binary_name: String,
},
#[serde(rename_all = "camelCase")]
TypeScript {
#[serde(default, skip_serializing_if = "Option::is_none")]
binary_name: Option<String>,
},
#[serde(rename_all = "camelCase")]
Python {
#[serde(default, skip_serializing_if = "Option::is_none")]
python_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
package: Option<String>,
command: Vec<String>,
},
#[serde(rename_all = "camelCase")]
Docker {
#[serde(skip_serializing_if = "Option::is_none")]
dockerfile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
build_args: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
target: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct QueueBatchConfig {
#[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 100))]
pub max_messages: u32,
#[cfg_attr(feature = "openapi", schema(minimum = 0, maximum = 300))]
pub max_window_seconds: u32,
}
impl QueueBatchConfig {
pub fn validate(&self) -> Result<()> {
if !(1..=100).contains(&self.max_messages)
|| self.max_window_seconds > 300
|| (self.max_messages > 10 && self.max_window_seconds == 0)
{
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: "queue trigger".to_string(),
reason: "batch maxMessages must be 1–100, maxWindowSeconds 0–300, and counts above 10 require a nonzero window".to_string(),
}));
}
Ok(())
}
}
fn deserialize_queue_batch<'de, D>(
deserializer: D,
) -> std::result::Result<Option<QueueBatchConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
let batch = Option::<QueueBatchConfig>::deserialize(deserializer)?;
if let Some(batch) = &batch {
batch.validate().map_err(serde::de::Error::custom)?;
}
Ok(batch)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum WorkerTrigger {
Queue {
queue: ResourceRef,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_queue_batch"
)]
batch: Option<QueueBatchConfig>,
},
Storage {
storage: ResourceRef,
events: Vec<String>,
},
Schedule {
cron: String,
},
}
impl WorkerTrigger {
pub fn queue<R: ?Sized>(queue: &R) -> Self
where
for<'a> &'a R: Into<ResourceRef>,
{
let queue_ref: ResourceRef = queue.into();
WorkerTrigger::Queue {
queue: queue_ref,
batch: None,
}
}
pub fn queue_batch<R: ?Sized>(queue: &R, batch: QueueBatchConfig) -> Result<Self>
where
for<'a> &'a R: Into<ResourceRef>,
{
batch.validate()?;
Ok(Self::Queue {
queue: queue.into(),
batch: Some(batch),
})
}
pub fn storage<R: ?Sized>(storage: &R, events: Vec<String>) -> Self
where
for<'a> &'a R: Into<ResourceRef>,
{
let storage_ref: ResourceRef = storage.into();
WorkerTrigger::Storage {
storage: storage_ref,
events,
}
}
pub fn schedule<S: Into<String>>(cron: S) -> Self {
WorkerTrigger::Schedule { cron: cron.into() }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[builder(start_fn = new)]
pub struct Worker {
#[builder(start_fn)]
pub id: String,
#[builder(field)]
pub links: Vec<ResourceRef>,
#[builder(field)]
pub triggers: Vec<WorkerTrigger>,
#[builder(field)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub public_endpoints: Vec<WorkerPublicEndpoint>,
pub permissions: String,
pub code: WorkerCode,
#[builder(default = default_memory_mb())]
#[serde(default = "default_memory_mb")]
#[cfg_attr(feature = "openapi", schema(default = default_memory_mb))]
pub memory_mb: u32,
#[builder(
default = default_timeout_seconds(),
with = |timeout_seconds: u32| -> crate::Result<_> {
validate_timeout_seconds(timeout_seconds)
}
)]
#[serde(
default = "default_timeout_seconds",
deserialize_with = "deserialize_timeout_seconds"
)]
#[cfg_attr(
feature = "openapi",
schema(default = default_timeout_seconds, minimum = 1, maximum = 3600)
)]
pub timeout_seconds: u32,
#[builder(default)]
#[serde(default)]
pub environment: HashMap<String, String>,
#[builder(default = default_commands_enabled())]
#[serde(default = "default_commands_enabled")]
#[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
pub commands_enabled: bool,
pub concurrency_limit: Option<u32>,
pub readiness_probe: Option<ReadinessProbe>,
}
impl Worker {
pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("worker");
pub fn runtime_binding_links(&self) -> Vec<ResourceRef> {
let mut links = self.links.clone();
for trigger in &self.triggers {
if let WorkerTrigger::Queue {
queue,
batch: Some(_),
} = trigger
{
if !links.contains(queue) {
links.push(queue.clone());
}
}
}
links
}
pub fn has_queue_batches(&self) -> bool {
self.triggers
.iter()
.any(|trigger| matches!(trigger, WorkerTrigger::Queue { batch: Some(_), .. }))
}
pub fn queue_batch_environment(&self) -> Result<Option<String>> {
if !self.has_queue_batches() {
return Ok(None);
}
let mut batches = std::collections::BTreeMap::new();
for trigger in &self.triggers {
if let WorkerTrigger::Queue { queue, batch } = trigger {
if let Some(batch) = batch {
batch.validate()?;
}
if batches.insert(&queue.id, batch).is_some() {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "Duplicate queue trigger".into(),
}));
}
}
}
if batches.is_empty() {
return Ok(None);
}
serde_json::to_string(&batches)
.map(Some)
.into_alien_error()
.context(ErrorData::GenericError {
message: "Failed to serialize queue batch configuration".into(),
})
}
pub fn queue_batch_config(&self, queue_id: &str) -> Option<&QueueBatchConfig> {
self.triggers.iter().find_map(|trigger| match trigger {
WorkerTrigger::Queue { queue, batch } if queue.id == queue_id => batch.as_ref(),
_ => None,
})
}
pub fn queue_source_maximum_concurrency(&self) -> Option<u32> {
if !self.has_queue_batches() {
return None;
}
let sources = self
.triggers
.iter()
.filter(|trigger| matches!(trigger, WorkerTrigger::Queue { .. }))
.count() as u32;
let capacity = self.concurrency_limit?.checked_div(sources)?;
(capacity >= 2).then_some(capacity.min(1000))
}
pub fn get_permissions(&self) -> &str {
&self.permissions
}
pub fn configured_primary_public_url(
&self,
urls: Option<&crate::PublicEndpointUrls>,
) -> Option<String> {
let first_declared_endpoint = self
.public_endpoints
.first()
.map(|endpoint| endpoint.name.as_str());
crate::primary_public_endpoint_url(urls?.get(&self.id)?, first_declared_endpoint).cloned()
}
fn validate_public_endpoints(&self) -> Result<()> {
let mut endpoint_names = std::collections::HashSet::new();
let mut apex_endpoint_name: Option<&str> = None;
for endpoint in &self.public_endpoints {
endpoint.validate_for_resource(&self.id)?;
if !endpoint_names.insert(endpoint.name.as_str()) {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: format!("duplicate public endpoint name '{}'", endpoint.name),
}));
}
if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
if let Some(existing_name) = apex_endpoint_name {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: format!(
"only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
existing_name
),
}));
}
apex_endpoint_name = Some(endpoint.name.as_str());
}
}
Ok(())
}
}
fn default_memory_mb() -> u32 {
512
}
fn default_timeout_seconds() -> u32 {
180
}
pub const MAX_WORKER_TIMEOUT_SECONDS: u32 = 3600;
fn deserialize_timeout_seconds<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = u32::deserialize(deserializer)?;
validate_timeout_seconds(value).map_err(serde::de::Error::custom)
}
fn validate_timeout_seconds(timeout_seconds: u32) -> Result<u32> {
if (1..=MAX_WORKER_TIMEOUT_SECONDS).contains(&timeout_seconds) {
return Ok(timeout_seconds);
}
Err(AlienError::new(ErrorData::WorkerTimeoutInvalid {
timeout_seconds,
max_timeout_seconds: MAX_WORKER_TIMEOUT_SECONDS,
}))
}
fn default_commands_enabled() -> bool {
false
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "UPPERCASE")]
#[derive(Default)]
pub enum HttpMethod {
#[default]
Get,
Post,
Put,
Delete,
Head,
Options,
Patch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct ReadinessProbe {
#[serde(default)]
pub method: HttpMethod,
#[serde(default = "default_probe_path")]
pub path: String,
}
fn default_probe_path() -> String {
"/".to_string()
}
impl Default for ReadinessProbe {
fn default() -> Self {
Self {
method: HttpMethod::default(),
path: default_probe_path(),
}
}
}
use crate::resources::worker::worker_builder::State;
impl<S: State> WorkerBuilder<S> {
pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
where
for<'a> &'a R: Into<ResourceRef>, {
let resource_ref: ResourceRef = resource.into();
self.links.push(resource_ref);
self
}
pub fn trigger(mut self, trigger: WorkerTrigger) -> Self {
self.triggers.push(trigger);
self
}
pub fn public_endpoint(mut self, endpoint: WorkerPublicEndpoint) -> Self {
self.public_endpoints.push(endpoint);
self
}
}
impl ResourceDefinition for Worker {
fn get_resource_type(&self) -> ResourceType {
Self::RESOURCE_TYPE
}
fn id(&self) -> &str {
&self.id
}
fn get_dependencies(&self) -> Vec<ResourceRef> {
let mut dependencies = self.links.clone();
for trigger in &self.triggers {
match trigger {
WorkerTrigger::Queue { queue, .. } => {
dependencies.push(queue.clone());
}
WorkerTrigger::Storage { storage, .. } => {
dependencies.push(storage.clone());
}
WorkerTrigger::Schedule { .. } => {
}
}
}
dependencies
}
fn get_permissions(&self) -> Option<&str> {
Some(&self.permissions)
}
fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
let new_worker = new_config
.as_any()
.downcast_ref::<Worker>()
.ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResourceType {
resource_id: self.id.clone(),
expected: Self::RESOURCE_TYPE,
actual: new_config.get_resource_type(),
})
})?;
if self.id != new_worker.id {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'id' field is immutable".to_string(),
}));
}
self.validate_public_endpoints()?;
new_worker.validate_public_endpoints()?;
if self.public_endpoints != new_worker.public_endpoints {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'publicEndpoints' field is immutable".to_string(),
}));
}
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceDefinition> {
Box::new(self.clone())
}
fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
other.as_any().downcast_ref::<Worker>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct WorkerOutputs {
pub worker_name: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub public_endpoints: HashMap<String, PublicEndpointOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commands_push_target: Option<String>,
}
impl ResourceOutputsDefinition for WorkerOutputs {
fn get_resource_type(&self) -> ResourceType {
Worker::RESOURCE_TYPE.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
Box::new(self.clone())
}
fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
other.as_any().downcast_ref::<WorkerOutputs>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Storage;
#[test]
fn test_worker_builder_direct_refs() {
let dummy_storage = Storage::new("test-storage".to_string()).build();
let dummy_storage_2 = Storage::new("test-storage-2".to_string()).build();
let worker = Worker::new("my-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.link(&dummy_storage) .link(&dummy_storage_2) .build();
assert_eq!(worker.id, "my-worker");
assert_eq!(
worker.code,
WorkerCode::Image {
image: "test-image".to_string()
}
);
assert_eq!(worker.permissions, "execution");
assert!(worker
.links
.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
assert!(worker
.links
.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage-2")));
assert_eq!(worker.links.len(), 2); }
#[test]
fn test_worker_with_readiness_probe() {
let probe = ReadinessProbe {
method: HttpMethod::Post,
path: "/health".to_string(),
};
let worker = Worker::new("my-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.public_endpoint(WorkerPublicEndpoint {
name: "api".to_string(),
host_label: None,
wildcard_subdomains: false,
})
.readiness_probe(probe.clone())
.build();
assert_eq!(worker.id, "my-worker");
assert_eq!(worker.public_endpoints[0].name, "api");
assert_eq!(worker.readiness_probe, Some(probe));
}
#[test]
fn test_readiness_probe_defaults() {
let probe = ReadinessProbe::default();
assert_eq!(probe.method, HttpMethod::Get);
assert_eq!(probe.path, "/");
}
#[test]
fn test_worker_with_rust_toolchain() {
let worker = Worker::new("my-rust-worker".to_string())
.code(WorkerCode::Source {
src: "./".to_string(),
toolchain: ToolchainConfig::Rust {
binary_name: "my-app".to_string(),
},
})
.permissions("execution".to_string())
.build();
assert_eq!(worker.id, "my-rust-worker");
match &worker.code {
WorkerCode::Source { src, toolchain } => {
assert_eq!(src, "./");
assert_eq!(
toolchain,
&ToolchainConfig::Rust {
binary_name: "my-app".to_string(),
}
);
}
_ => panic!("Expected Source code"),
}
}
#[test]
fn test_worker_with_typescript_toolchain() {
let worker = Worker::new("my-ts-worker".to_string())
.code(WorkerCode::Source {
src: "./".to_string(),
toolchain: ToolchainConfig::TypeScript {
binary_name: Some("my-ts-worker".to_string()),
},
})
.permissions("execution".to_string())
.build();
assert_eq!(worker.id, "my-ts-worker");
match &worker.code {
WorkerCode::Source { src, toolchain } => {
assert_eq!(src, "./");
assert_eq!(
toolchain,
&ToolchainConfig::TypeScript {
binary_name: Some("my-ts-worker".to_string())
}
);
}
_ => panic!("Expected Source code"),
}
}
#[test]
fn test_worker_with_queue_trigger() {
use crate::Queue;
let queue = Queue::new("test-queue".to_string()).build();
let worker = Worker::new("triggered-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.trigger(WorkerTrigger::queue(&queue))
.build();
assert_eq!(worker.triggers.len(), 1);
if let WorkerTrigger::Queue {
queue: queue_ref, ..
} = &worker.triggers[0]
{
assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
assert_eq!(queue_ref.id, "test-queue");
} else {
panic!("Expected queue trigger");
}
}
#[test]
fn test_worker_trigger_dependencies() {
use crate::Queue;
let queue = Queue::new("test-queue".to_string()).build();
let storage = Storage::new("test-storage".to_string()).build();
let worker = Worker::new("triggered-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.link(&storage) .trigger(WorkerTrigger::queue(&queue)) .build();
let dependencies = worker.get_dependencies();
assert_eq!(dependencies.len(), 2);
assert!(dependencies.contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "test-queue")));
}
#[test]
fn test_worker_trigger_helper_methods() {
use crate::Queue;
let queue = Queue::new("my-queue".to_string()).build();
let trigger = WorkerTrigger::queue(&queue);
if let WorkerTrigger::Queue {
queue: queue_ref, ..
} = trigger
{
assert_eq!(queue_ref.resource_type, Queue::RESOURCE_TYPE);
assert_eq!(queue_ref.id, "my-queue");
} else {
panic!("Expected queue trigger");
}
}
#[test]
fn test_worker_with_multiple_triggers() {
use crate::Queue;
let queue1 = Queue::new("queue-1".to_string()).build();
let queue2 = Queue::new("queue-2".to_string()).build();
let worker = Worker::new("multi-triggered-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.trigger(WorkerTrigger::queue(&queue1))
.trigger(WorkerTrigger::queue(&queue2))
.trigger(WorkerTrigger::schedule("0 * * * *".to_string()))
.build();
assert_eq!(worker.triggers.len(), 3);
if let WorkerTrigger::Queue {
queue: queue_ref, ..
} = &worker.triggers[0]
{
assert_eq!(queue_ref.id, "queue-1");
} else {
panic!("Expected first trigger to be queue-1");
}
if let WorkerTrigger::Queue {
queue: queue_ref, ..
} = &worker.triggers[1]
{
assert_eq!(queue_ref.id, "queue-2");
} else {
panic!("Expected second trigger to be queue-2");
}
if let WorkerTrigger::Schedule { cron } = &worker.triggers[2] {
assert_eq!(cron, "0 * * * *");
} else {
panic!("Expected third trigger to be schedule");
}
let dependencies = worker.get_dependencies();
assert_eq!(dependencies.len(), 2); assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-1")));
assert!(dependencies.contains(&ResourceRef::new(Queue::RESOURCE_TYPE, "queue-2")));
}
#[test]
fn test_worker_with_commands_enabled() {
let worker = Worker::new("cmd-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.commands_enabled(true)
.build();
assert_eq!(worker.id, "cmd-worker");
assert!(worker.public_endpoints.is_empty());
assert_eq!(worker.commands_enabled, true);
}
#[test]
fn test_worker_defaults() {
let worker = Worker::new("default-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.build();
assert!(worker.public_endpoints.is_empty());
assert_eq!(worker.commands_enabled, false);
assert_eq!(worker.memory_mb, 512);
assert_eq!(worker.timeout_seconds, 180);
}
#[test]
fn worker_deserialization_rejects_timeout_outside_supported_range() {
let worker = Worker::new("timeout-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.build();
let mut value = serde_json::to_value(worker).expect("serialize worker");
value["timeoutSeconds"] = serde_json::json!(0);
assert!(serde_json::from_value::<Worker>(value.clone()).is_err());
value["timeoutSeconds"] = serde_json::json!(MAX_WORKER_TIMEOUT_SECONDS + 1);
assert!(serde_json::from_value::<Worker>(value).is_err());
}
#[test]
fn worker_builder_rejects_zero_timeout() {
let Err(error) = Worker::new("timeout-worker".to_string()).timeout_seconds(0) else {
panic!("zero timeout must be rejected");
};
assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
assert_eq!(error.http_status_code, Some(400));
}
#[test]
fn worker_builder_rejects_timeout_above_maximum() {
let Err(error) = Worker::new("timeout-worker".to_string())
.timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS + 1)
else {
panic!("timeout above maximum must be rejected");
};
assert_eq!(error.code, "WORKER_TIMEOUT_INVALID");
}
#[test]
fn worker_builder_accepts_minimum_timeout() {
let worker = Worker::new("timeout-worker".to_string())
.timeout_seconds(1)
.expect("minimum timeout is valid")
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.build();
assert_eq!(worker.timeout_seconds, 1);
}
#[test]
fn worker_builder_accepts_maximum_timeout() {
let worker = Worker::new("timeout-worker".to_string())
.timeout_seconds(MAX_WORKER_TIMEOUT_SECONDS)
.expect("maximum timeout is valid")
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.build();
assert_eq!(worker.timeout_seconds, MAX_WORKER_TIMEOUT_SECONDS);
}
#[test]
fn test_worker_public_ingress_with_commands() {
let worker = Worker::new("public-cmd-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.public_endpoint(WorkerPublicEndpoint {
name: "api".to_string(),
host_label: None,
wildcard_subdomains: false,
})
.commands_enabled(true)
.build();
assert_eq!(worker.public_endpoints[0].name, "api");
assert_eq!(worker.commands_enabled, true);
}
#[test]
fn worker_rejects_multiple_apex_public_endpoints() {
let worker = Worker::new("apex-worker".to_string())
.code(WorkerCode::Image {
image: "test-image".to_string(),
})
.permissions("execution".to_string())
.public_endpoint(WorkerPublicEndpoint {
name: "api".to_string(),
host_label: Some(APEX_HOST_LABEL.to_string()),
wildcard_subdomains: false,
})
.public_endpoint(WorkerPublicEndpoint {
name: "admin".to_string(),
host_label: Some(APEX_HOST_LABEL.to_string()),
wildcard_subdomains: false,
})
.build();
assert!(worker.validate_public_endpoints().is_err());
}
}