Skip to main content

alien_core/resources/
public_endpoint.rs

1use crate::error::{ErrorData, Result};
2use crate::LoadBalancerEndpoint;
3use alien_error::AlienError;
4use serde::{Deserialize, Serialize};
5
6/// Host label that places a generated public endpoint at the deployment base hostname.
7pub const APEX_HOST_LABEL: &str = "@";
8
9/// Protocol for public workload endpoints.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
12#[serde(rename_all = "lowercase")]
13pub enum ExposeProtocol {
14    /// HTTP/HTTPS with TLS termination at load balancer.
15    Http,
16    /// TCP passthrough without TLS.
17    Tcp,
18}
19
20/// Public endpoint configuration for port-backed workload resources.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase")]
24pub struct PublicEndpoint {
25    /// Endpoint name within the resource.
26    pub name: String,
27    /// Workload port served by the public endpoint.
28    pub port: u16,
29    /// Public protocol.
30    pub protocol: ExposeProtocol,
31    /// Optional DNS label override for generated endpoint hostnames.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub host_label: Option<String>,
34    /// Whether to route wildcard subdomains to this endpoint.
35    #[serde(default)]
36    pub wildcard_subdomains: bool,
37}
38
39impl PublicEndpoint {
40    /// Returns the DNS label used for generated hostnames.
41    pub fn effective_host_label(&self) -> &str {
42        self.host_label.as_deref().unwrap_or(&self.name)
43    }
44
45    /// Validates the endpoint options for a resource.
46    pub fn validate_for_resource(&self, resource_id: &str) -> Result<()> {
47        validate_endpoint_name(resource_id, &self.name)?;
48        if let Some(host_label) = &self.host_label {
49            validate_endpoint_host_label(resource_id, host_label)?;
50        }
51        if self.host_label.as_deref() == Some(APEX_HOST_LABEL) && self.wildcard_subdomains {
52            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
53                resource_id: resource_id.to_string(),
54                reason: "an apex public endpoint cannot also route wildcard subdomains".to_string(),
55            }));
56        }
57
58        Ok(())
59    }
60}
61
62/// Public endpoint configuration for Worker resources.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
65#[serde(rename_all = "camelCase")]
66pub struct WorkerPublicEndpoint {
67    /// Endpoint name within the resource.
68    pub name: String,
69    /// Optional DNS label override for generated endpoint hostnames.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub host_label: Option<String>,
72    /// Whether to route wildcard subdomains to this endpoint.
73    #[serde(default)]
74    pub wildcard_subdomains: bool,
75}
76
77impl WorkerPublicEndpoint {
78    /// Returns the DNS label used for generated hostnames.
79    pub fn effective_host_label(&self) -> &str {
80        self.host_label.as_deref().unwrap_or(&self.name)
81    }
82
83    /// Validates the endpoint options for a resource.
84    pub fn validate_for_resource(&self, resource_id: &str) -> Result<()> {
85        validate_endpoint_name(resource_id, &self.name)?;
86        if let Some(host_label) = &self.host_label {
87            validate_endpoint_host_label(resource_id, host_label)?;
88        }
89        if self.host_label.as_deref() == Some(APEX_HOST_LABEL) && self.wildcard_subdomains {
90            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
91                resource_id: resource_id.to_string(),
92                reason: "an apex public endpoint cannot also route wildcard subdomains".to_string(),
93            }));
94        }
95
96        Ok(())
97    }
98}
99
100/// Runtime-resolved public endpoint metadata.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
103#[serde(rename_all = "camelCase")]
104pub struct PublicEndpointOutput {
105    /// Base URL for this endpoint.
106    pub url: String,
107    /// Hostname for this endpoint.
108    pub host: String,
109    /// Wildcard hostname routed to this endpoint, when configured.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub wildcard_host: Option<String>,
112    /// Load balancer endpoint information for DNS management.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
115}
116
117/// Validates a public endpoint name within a resource.
118pub fn validate_endpoint_name(resource_id: &str, name: &str) -> Result<()> {
119    let valid = !name.is_empty()
120        && name.len() <= 63
121        && !name.starts_with('-')
122        && !name.ends_with('-')
123        && name
124            .bytes()
125            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
126
127    if !valid {
128        return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
129            resource_id: resource_id.to_string(),
130            reason:
131                "public endpoint name must be a single lowercase DNS label: letters, numbers, hyphens, no dots, and no leading or trailing hyphen"
132                    .to_string(),
133        }));
134    }
135
136    Ok(())
137}
138
139/// Validates a single DNS label used in generated endpoint hostnames.
140pub fn validate_endpoint_host_label(resource_id: &str, host_label: &str) -> Result<()> {
141    if host_label == APEX_HOST_LABEL {
142        return Ok(());
143    }
144
145    let valid = !host_label.is_empty()
146        && host_label.len() <= 63
147        && !host_label.starts_with('-')
148        && !host_label.ends_with('-')
149        && host_label
150            .bytes()
151            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
152
153    if !valid {
154        return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
155            resource_id: resource_id.to_string(),
156            reason:
157                "public endpoint hostLabel must be '@' for apex or a single lowercase DNS label: letters, numbers, hyphens, no dots, and no leading or trailing hyphen"
158                    .to_string(),
159        }));
160    }
161
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn host_label_allows_apex_marker() {
171        validate_endpoint_host_label("gateway", APEX_HOST_LABEL).expect("apex host label");
172    }
173
174    #[test]
175    fn public_endpoint_rejects_apex_wildcard_combination() {
176        let endpoint = PublicEndpoint {
177            name: "api".to_string(),
178            port: 8080,
179            protocol: ExposeProtocol::Http,
180            host_label: Some(APEX_HOST_LABEL.to_string()),
181            wildcard_subdomains: true,
182        };
183
184        let error = endpoint
185            .validate_for_resource("gateway")
186            .expect_err("apex wildcard should be rejected");
187
188        assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
189        assert!(error.message.contains("apex"));
190    }
191
192    #[test]
193    fn worker_endpoint_rejects_apex_wildcard_combination() {
194        let endpoint = WorkerPublicEndpoint {
195            name: "api".to_string(),
196            host_label: Some(APEX_HOST_LABEL.to_string()),
197            wildcard_subdomains: true,
198        };
199
200        let error = endpoint
201            .validate_for_resource("handler")
202            .expect_err("apex wildcard should be rejected");
203
204        assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
205        assert!(error.message.contains("apex"));
206    }
207}