1use crate::error::{ErrorData, Result};
2use crate::LoadBalancerEndpoint;
3use alien_error::AlienError;
4use serde::{de, Deserialize, Deserializer, Serialize};
5use url::Url;
6
7pub const APEX_HOST_LABEL: &str = "@";
9
10#[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 #[default]
17 Http,
18 Tcp,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25#[serde(rename_all = "camelCase")]
26pub struct PublicEndpoint {
27 pub name: String,
29 pub port: u16,
31 pub protocol: ExposeProtocol,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub host_label: Option<String>,
36 #[serde(default)]
38 pub wildcard_subdomains: bool,
39}
40
41impl PublicEndpoint {
42 pub fn effective_host_label(&self) -> &str {
44 self.host_label.as_deref().unwrap_or(&self.name)
45 }
46
47 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67#[serde(rename_all = "camelCase")]
68pub struct WorkerPublicEndpoint {
69 pub name: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub host_label: Option<String>,
74 #[serde(default)]
76 pub wildcard_subdomains: bool,
77}
78
79impl WorkerPublicEndpoint {
80 pub fn effective_host_label(&self) -> &str {
82 self.host_label.as_deref().unwrap_or(&self.name)
83 }
84
85 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
105#[serde(rename_all = "camelCase")]
106pub struct PublicEndpointOutput {
107 pub url: String,
109 pub host: String,
111 pub protocol: ExposeProtocol,
113 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 65535))]
115 pub port: u16,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub wildcard_host: Option<String>,
119 #[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
209pub 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
231pub 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}