Skip to main content

alien_core/resources/
public_endpoint.rs

1use crate::error::{ErrorData, Result};
2use crate::LoadBalancerEndpoint;
3use alien_error::AlienError;
4use serde::{de, Deserialize, Deserializer, Serialize};
5use url::Url;
6
7/// Host label that places a generated public endpoint at the deployment base hostname.
8pub const APEX_HOST_LABEL: &str = "@";
9
10/// Protocol for public workload endpoints.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "lowercase")]
14pub enum ExposeProtocol {
15    /// HTTP/HTTPS with TLS termination at load balancer.
16    #[default]
17    Http,
18    /// TCP passthrough without TLS.
19    Tcp,
20}
21
22/// Public endpoint configuration for port-backed workload resources.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25#[serde(rename_all = "camelCase")]
26pub struct PublicEndpoint {
27    /// Endpoint name within the resource.
28    pub name: String,
29    /// Workload port served by the public endpoint.
30    pub port: u16,
31    /// Public protocol.
32    pub protocol: ExposeProtocol,
33    /// Optional DNS label override for generated endpoint hostnames.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub host_label: Option<String>,
36    /// Whether to route wildcard subdomains to this endpoint.
37    #[serde(default)]
38    pub wildcard_subdomains: bool,
39}
40
41impl PublicEndpoint {
42    /// Returns the DNS label used for generated hostnames.
43    pub fn effective_host_label(&self) -> &str {
44        self.host_label.as_deref().unwrap_or(&self.name)
45    }
46
47    /// Validates the endpoint options for a resource.
48    pub fn validate_for_resource(&self, resource_id: &str) -> Result<()> {
49        validate_endpoint_name(resource_id, &self.name)?;
50        if let Some(host_label) = &self.host_label {
51            validate_endpoint_host_label(resource_id, host_label)?;
52        }
53        if self.host_label.as_deref() == Some(APEX_HOST_LABEL) && self.wildcard_subdomains {
54            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
55                resource_id: resource_id.to_string(),
56                reason: "an apex public endpoint cannot also route wildcard subdomains".to_string(),
57            }));
58        }
59
60        Ok(())
61    }
62}
63
64/// Public endpoint configuration for Worker resources.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67#[serde(rename_all = "camelCase")]
68pub struct WorkerPublicEndpoint {
69    /// Endpoint name within the resource.
70    pub name: String,
71    /// Optional DNS label override for generated endpoint hostnames.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub host_label: Option<String>,
74    /// Whether to route wildcard subdomains to this endpoint.
75    #[serde(default)]
76    pub wildcard_subdomains: bool,
77}
78
79impl WorkerPublicEndpoint {
80    /// Returns the DNS label used for generated hostnames.
81    pub fn effective_host_label(&self) -> &str {
82        self.host_label.as_deref().unwrap_or(&self.name)
83    }
84
85    /// Validates the endpoint options for a resource.
86    pub fn validate_for_resource(&self, resource_id: &str) -> Result<()> {
87        validate_endpoint_name(resource_id, &self.name)?;
88        if let Some(host_label) = &self.host_label {
89            validate_endpoint_host_label(resource_id, host_label)?;
90        }
91        if self.host_label.as_deref() == Some(APEX_HOST_LABEL) && self.wildcard_subdomains {
92            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
93                resource_id: resource_id.to_string(),
94                reason: "an apex public endpoint cannot also route wildcard subdomains".to_string(),
95            }));
96        }
97
98        Ok(())
99    }
100}
101
102/// Runtime-resolved public endpoint metadata.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
105#[serde(rename_all = "camelCase")]
106pub struct PublicEndpointOutput {
107    /// Base URL for this endpoint.
108    pub url: String,
109    /// Hostname for this endpoint.
110    pub host: String,
111    /// Public connection protocol.
112    pub protocol: ExposeProtocol,
113    /// Public connection port.
114    #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 65535))]
115    pub port: u16,
116    /// Wildcard hostname routed to this endpoint, when configured.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub wildcard_host: Option<String>,
119    /// Load balancer endpoint information for DNS management.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
122}
123
124#[derive(Deserialize)]
125#[serde(rename_all = "camelCase")]
126struct PublicEndpointOutputWire {
127    url: String,
128    #[serde(default)]
129    host: Option<String>,
130    #[serde(default)]
131    protocol: Option<ExposeProtocol>,
132    #[serde(default)]
133    port: Option<u16>,
134    #[serde(default)]
135    wildcard_host: Option<String>,
136    #[serde(default)]
137    load_balancer_endpoint: Option<LoadBalancerEndpoint>,
138}
139
140impl<'de> Deserialize<'de> for PublicEndpointOutput {
141    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
142    where
143        D: Deserializer<'de>,
144    {
145        let wire = PublicEndpointOutputWire::deserialize(deserializer)?;
146        let parsed = Url::parse(&wire.url).map_err(de::Error::custom)?;
147        let protocol = wire.protocol.unwrap_or_default();
148        let expected_schemes: &[&str] = match protocol {
149            ExposeProtocol::Http => &["http", "https"],
150            ExposeProtocol::Tcp => &["tcp"],
151        };
152        if !expected_schemes.contains(&parsed.scheme()) {
153            return Err(de::Error::custom(format!(
154                "public endpoint protocol '{protocol:?}' is inconsistent with URL scheme '{}'",
155                parsed.scheme()
156            )));
157        }
158        if !parsed.username().is_empty()
159            || parsed.password().is_some()
160            || parsed.query().is_some()
161            || parsed.fragment().is_some()
162            || (!parsed.path().is_empty() && parsed.path() != "/")
163        {
164            return Err(de::Error::custom(
165                "public endpoint URL must not include credentials, a path, query parameters, or a fragment",
166            ));
167        }
168
169        let parsed_host = parsed
170            .host_str()
171            .map(|host| host.trim_end_matches('.').to_string())
172            .filter(|host| !host.is_empty())
173            .ok_or_else(|| de::Error::custom("public endpoint URL must include a host"))?;
174        if let Some(host) = &wire.host {
175            if host != &parsed_host {
176                return Err(de::Error::custom(format!(
177                    "public endpoint host '{host}' is inconsistent with URL host '{parsed_host}'"
178                )));
179            }
180        }
181
182        let parsed_port = parsed.port_or_known_default().ok_or_else(|| {
183            de::Error::custom("public endpoint URL must include a port for this protocol")
184        })?;
185        if parsed_port == 0 {
186            return Err(de::Error::custom(
187                "public endpoint URL port must be between 1 and 65535",
188            ));
189        }
190        if let Some(port) = wire.port {
191            if port != parsed_port {
192                return Err(de::Error::custom(format!(
193                    "public endpoint port '{port}' is inconsistent with URL port '{parsed_port}'"
194                )));
195            }
196        }
197
198        Ok(Self {
199            url: wire.url,
200            host: parsed_host,
201            protocol,
202            port: parsed_port,
203            wildcard_host: wire.wildcard_host,
204            load_balancer_endpoint: wire.load_balancer_endpoint,
205        })
206    }
207}
208
209/// Validates a public endpoint name within a resource.
210pub fn validate_endpoint_name(resource_id: &str, name: &str) -> Result<()> {
211    let valid = !name.is_empty()
212        && name.len() <= 63
213        && !name.starts_with('-')
214        && !name.ends_with('-')
215        && name
216            .bytes()
217            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
218
219    if !valid {
220        return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
221            resource_id: resource_id.to_string(),
222            reason:
223                "public endpoint name must be a single lowercase DNS label: letters, numbers, hyphens, no dots, and no leading or trailing hyphen"
224                    .to_string(),
225        }));
226    }
227
228    Ok(())
229}
230
231/// Validates a single DNS label used in generated endpoint hostnames.
232pub fn validate_endpoint_host_label(resource_id: &str, host_label: &str) -> Result<()> {
233    if host_label == APEX_HOST_LABEL {
234        return Ok(());
235    }
236
237    let valid = !host_label.is_empty()
238        && host_label.len() <= 63
239        && !host_label.starts_with('-')
240        && !host_label.ends_with('-')
241        && host_label
242            .bytes()
243            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
244
245    if !valid {
246        return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
247            resource_id: resource_id.to_string(),
248            reason:
249                "public endpoint hostLabel must be '@' for apex or a single lowercase DNS label: letters, numbers, hyphens, no dots, and no leading or trailing hyphen"
250                    .to_string(),
251        }));
252    }
253
254    Ok(())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn host_label_allows_apex_marker() {
263        validate_endpoint_host_label("gateway", APEX_HOST_LABEL).expect("apex host label");
264    }
265
266    #[test]
267    fn public_endpoint_rejects_apex_wildcard_combination() {
268        let endpoint = PublicEndpoint {
269            name: "api".to_string(),
270            port: 8080,
271            protocol: ExposeProtocol::Http,
272            host_label: Some(APEX_HOST_LABEL.to_string()),
273            wildcard_subdomains: true,
274        };
275
276        let error = endpoint
277            .validate_for_resource("gateway")
278            .expect_err("apex wildcard should be rejected");
279
280        assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
281        assert!(error.message.contains("apex"));
282    }
283
284    #[test]
285    fn worker_endpoint_rejects_apex_wildcard_combination() {
286        let endpoint = WorkerPublicEndpoint {
287            name: "api".to_string(),
288            host_label: Some(APEX_HOST_LABEL.to_string()),
289            wildcard_subdomains: true,
290        };
291
292        let error = endpoint
293            .validate_for_resource("handler")
294            .expect_err("apex wildcard should be rejected");
295
296        assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
297        assert!(error.message.contains("apex"));
298    }
299
300    #[test]
301    fn old_http_output_derives_current_connection_metadata() {
302        let output: PublicEndpointOutput = serde_json::from_value(serde_json::json!({
303            "url": "https://gateway.example.test",
304            "host": "gateway.example.test"
305        }))
306        .expect("old HTTP output should deserialize");
307
308        assert_eq!(output.protocol, ExposeProtocol::Http);
309        assert_eq!(output.host, "gateway.example.test");
310        assert_eq!(output.port, 443);
311    }
312
313    #[test]
314    fn captured_container_outputs_deserialize_through_current_contract() {
315        let outputs: crate::ContainerOutputs = serde_json::from_value(serde_json::json!({
316            "name": "gateway",
317            "status": "running",
318            "currentReplicas": 1,
319            "desiredReplicas": 1,
320            "internalDns": "gateway.svc",
321            "replicas": [],
322            "publicEndpoints": {
323                "api": {
324                    "url": "https://gateway.example.test",
325                    "host": "gateway.example.test"
326                }
327            }
328        }))
329        .expect("captured container outputs should deserialize");
330
331        let endpoint = &outputs.public_endpoints["api"];
332        assert_eq!(endpoint.protocol, ExposeProtocol::Http);
333        assert_eq!(endpoint.port, 443);
334    }
335
336    #[test]
337    fn tcp_output_requires_truthful_connection_metadata() {
338        let output: PublicEndpointOutput = serde_json::from_value(serde_json::json!({
339            "url": "tcp://database.example.test:6432",
340            "host": "database.example.test",
341            "protocol": "tcp",
342            "port": 6432
343        }))
344        .expect("TCP output should deserialize");
345
346        assert_eq!(output.protocol, ExposeProtocol::Tcp);
347        assert_eq!(output.port, 6432);
348    }
349
350    #[test]
351    fn inconsistent_output_is_rejected() {
352        let error = serde_json::from_value::<PublicEndpointOutput>(serde_json::json!({
353            "url": "https://gateway.example.test",
354            "host": "other.example.test",
355            "protocol": "http",
356            "port": 80
357        }))
358        .expect_err("inconsistent output should fail");
359
360        assert!(error.to_string().contains("inconsistent"));
361    }
362}