alien_core/resources/
public_endpoint.rs1use crate::error::{ErrorData, Result};
2use crate::LoadBalancerEndpoint;
3use alien_error::AlienError;
4use serde::{Deserialize, Serialize};
5
6pub const APEX_HOST_LABEL: &str = "@";
8
9#[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,
16 Tcp,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase")]
24pub struct PublicEndpoint {
25 pub name: String,
27 pub port: u16,
29 pub protocol: ExposeProtocol,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub host_label: Option<String>,
34 #[serde(default)]
36 pub wildcard_subdomains: bool,
37}
38
39impl PublicEndpoint {
40 pub fn effective_host_label(&self) -> &str {
42 self.host_label.as_deref().unwrap_or(&self.name)
43 }
44
45 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
65#[serde(rename_all = "camelCase")]
66pub struct WorkerPublicEndpoint {
67 pub name: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub host_label: Option<String>,
72 #[serde(default)]
74 pub wildcard_subdomains: bool,
75}
76
77impl WorkerPublicEndpoint {
78 pub fn effective_host_label(&self) -> &str {
80 self.host_label.as_deref().unwrap_or(&self.name)
81 }
82
83 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
103#[serde(rename_all = "camelCase")]
104pub struct PublicEndpointOutput {
105 pub url: String,
107 pub host: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub wildcard_host: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
115}
116
117pub 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
139pub 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}