1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
3use crate::resources::{
4 ComputeCluster, ExposeProtocol, HealthCheck, PublicEndpoint, PublicEndpointOutput,
5 ResourceSpec, ToolchainConfig, APEX_HOST_LABEL,
6};
7use alien_error::AlienError;
8use bon::Builder;
9use serde::{Deserialize, Serialize};
10use std::any::Any;
11use std::collections::HashMap;
12use std::fmt::Debug;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
16#[serde(rename_all = "camelCase", tag = "type")]
17pub enum DaemonCode {
18 #[serde(rename_all = "camelCase")]
19 Image { image: String },
20 #[serde(rename_all = "camelCase")]
21 Source {
22 src: String,
23 toolchain: ToolchainConfig,
24 },
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct DaemonRuntimeMount {
31 pub source: String,
33 pub target: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub options: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
41#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43pub struct DaemonRuntime {
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub privileged: Option<bool>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub pid_namespace: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub network_mode: Option<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub mounts: Vec<DaemonRuntimeMount>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub user: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[serde(rename_all = "camelCase", deny_unknown_fields)]
64#[builder(start_fn = new)]
65pub struct Daemon {
66 #[builder(start_fn)]
67 pub id: String,
68 #[builder(field)]
69 pub links: Vec<ResourceRef>,
70 #[builder(field)]
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub public_endpoints: Vec<PublicEndpoint>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub health_check: Option<HealthCheck>,
77 #[serde(skip_serializing_if = "Option::is_none")]
80 pub cluster: Option<String>,
81 pub permissions: String,
82 pub code: DaemonCode,
83 #[builder(default = default_daemon_cpu())]
85 #[serde(default = "default_daemon_cpu")]
86 pub cpu: ResourceSpec,
87 #[builder(default = default_daemon_memory())]
89 #[serde(default = "default_daemon_memory")]
90 pub memory: ResourceSpec,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub pool: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub command: Option<Vec<String>>,
97 #[serde(skip_serializing_if = "Option::is_none")]
102 #[cfg_attr(feature = "openapi", schema(minimum = 1, maximum = 86400))]
103 pub stop_grace_period_seconds: Option<u32>,
104 #[serde(skip_serializing_if = "Option::is_none")]
110 pub runtime: Option<DaemonRuntime>,
111 #[builder(default)]
112 #[serde(default)]
113 pub environment: HashMap<String, String>,
114 #[builder(default = default_commands_enabled())]
115 #[serde(default = "default_commands_enabled")]
116 #[cfg_attr(feature = "openapi", schema(default = default_commands_enabled))]
117 pub commands_enabled: bool,
118}
119
120impl Daemon {
121 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("daemon");
122
123 pub fn get_permissions(&self) -> &str {
124 &self.permissions
125 }
126
127 fn validate_public_endpoints(&self) -> Result<()> {
128 let mut endpoint_names = std::collections::HashSet::new();
129 let mut backend_ports = std::collections::HashSet::new();
130 let mut apex_endpoint_name: Option<&str> = None;
131
132 for endpoint in &self.public_endpoints {
133 endpoint.validate_for_resource(&self.id)?;
134 if !endpoint_names.insert(endpoint.name.as_str()) {
135 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
136 resource_id: self.id.clone(),
137 reason: format!("duplicate public endpoint name '{}'", endpoint.name),
138 }));
139 }
140 if endpoint.host_label.as_deref() == Some(APEX_HOST_LABEL) {
141 if let Some(existing_name) = apex_endpoint_name {
142 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
143 resource_id: self.id.clone(),
144 reason: format!(
145 "only one apex public endpoint is allowed per resource; '{}' already uses hostLabel '@'",
146 existing_name
147 ),
148 }));
149 }
150 apex_endpoint_name = Some(endpoint.name.as_str());
151 }
152 backend_ports.insert(endpoint.port);
153 if endpoint.protocol != ExposeProtocol::Http {
154 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
155 resource_id: self.id.clone(),
156 reason: "daemon public endpoints currently support only HTTP".to_string(),
157 }));
158 }
159 }
160
161 if backend_ports.len() > 1 {
162 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
163 resource_id: self.id.clone(),
164 reason:
165 "public endpoints on one daemon must currently route to the same backend port"
166 .to_string(),
167 }));
168 }
169
170 Ok(())
171 }
172
173 fn validate_runtime(&self) -> Result<()> {
174 let Some(runtime) = &self.runtime else {
175 return Ok(());
176 };
177
178 if let Some(pid_namespace) = &runtime.pid_namespace {
179 if pid_namespace != "host" && pid_namespace != "private" {
180 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
181 resource_id: self.id.clone(),
182 reason: "runtime.pidNamespace must be 'host' or 'private'".to_string(),
183 }));
184 }
185 }
186
187 if let Some(network_mode) = &runtime.network_mode {
188 if network_mode != "host" && network_mode != "appnet" {
189 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
190 resource_id: self.id.clone(),
191 reason: "runtime.networkMode must be 'host' or 'appnet'".to_string(),
192 }));
193 }
194 }
195
196 if let Some(user) = &runtime.user {
197 let valid = match user.split_once(':') {
198 Some((uid, gid)) => {
199 !uid.is_empty()
200 && !gid.is_empty()
201 && uid.chars().all(|c| c.is_ascii_digit())
202 && gid.chars().all(|c| c.is_ascii_digit())
203 }
204 None => !user.is_empty() && user.chars().all(|c| c.is_ascii_digit()),
205 };
206 if !valid {
207 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
208 resource_id: self.id.clone(),
209 reason: "runtime.user must be a numeric uid or uid:gid".to_string(),
210 }));
211 }
212 }
213
214 for mount in &runtime.mounts {
215 if mount.source.is_empty() || mount.target.is_empty() {
216 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
217 resource_id: self.id.clone(),
218 reason: "runtime.mounts source and target must be non-empty".to_string(),
219 }));
220 }
221 if !mount.source.starts_with('/') || !mount.target.starts_with('/') {
222 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
223 resource_id: self.id.clone(),
224 reason: "runtime.mounts source and target must be absolute paths".to_string(),
225 }));
226 }
227 }
228
229 Ok(())
230 }
231}
232
233fn default_commands_enabled() -> bool {
234 false
235}
236
237fn default_daemon_cpu() -> ResourceSpec {
238 ResourceSpec {
239 min: "0.1".to_string(),
240 desired: "0.1".to_string(),
241 }
242}
243
244fn default_daemon_memory() -> ResourceSpec {
245 ResourceSpec {
246 min: "128Mi".to_string(),
247 desired: "128Mi".to_string(),
248 }
249}
250
251impl<S: daemon_builder::State> DaemonBuilder<S> {
252 pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
253 where
254 for<'a> &'a R: Into<ResourceRef>,
255 {
256 let resource_ref: ResourceRef = resource.into();
257 self.links.push(resource_ref);
258 self
259 }
260
261 pub fn public_endpoint(mut self, endpoint: PublicEndpoint) -> Self {
262 self.public_endpoints.push(endpoint);
263 self
264 }
265}
266
267impl ResourceDefinition for Daemon {
268 fn get_resource_type(&self) -> ResourceType {
269 Self::RESOURCE_TYPE
270 }
271
272 fn id(&self) -> &str {
273 &self.id
274 }
275
276 fn get_dependencies(&self) -> Vec<ResourceRef> {
277 let mut dependencies = self.links.clone();
278 if let Some(cluster) = &self.cluster {
279 dependencies.push(ResourceRef::new(
280 ComputeCluster::RESOURCE_TYPE,
281 cluster.clone(),
282 ));
283 }
284 dependencies
285 }
286
287 fn get_permissions(&self) -> Option<&str> {
288 Some(&self.permissions)
289 }
290
291 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
292 let new_daemon = new_config
293 .as_any()
294 .downcast_ref::<Daemon>()
295 .ok_or_else(|| {
296 AlienError::new(ErrorData::UnexpectedResourceType {
297 resource_id: self.id.clone(),
298 expected: Self::RESOURCE_TYPE,
299 actual: new_config.get_resource_type(),
300 })
301 })?;
302
303 if self.id != new_daemon.id {
304 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
305 resource_id: self.id.clone(),
306 reason: "the 'id' field is immutable".to_string(),
307 }));
308 }
309
310 self.validate_public_endpoints()?;
311 new_daemon.validate_public_endpoints()?;
312 self.validate_runtime()?;
313 new_daemon.validate_runtime()?;
314
315 if self.public_endpoints != new_daemon.public_endpoints {
316 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
317 resource_id: self.id.clone(),
318 reason: "the 'publicEndpoints' field is immutable".to_string(),
319 }));
320 }
321
322 Ok(())
323 }
324
325 fn as_any(&self) -> &dyn Any {
326 self
327 }
328
329 fn as_any_mut(&mut self) -> &mut dyn Any {
330 self
331 }
332
333 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
334 Box::new(self.clone())
335 }
336
337 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
338 other.as_any().downcast_ref::<Daemon>() == Some(self)
339 }
340
341 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
342 serde_json::to_value(self)
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
348#[serde(rename_all = "camelCase")]
349pub struct DaemonOutputs {
350 pub daemon_name: String,
351 pub running: bool,
352 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
353 pub public_endpoints: HashMap<String, PublicEndpointOutput>,
354}
355
356impl ResourceOutputsDefinition for DaemonOutputs {
357 fn get_resource_type(&self) -> ResourceType {
358 Daemon::RESOURCE_TYPE.clone()
359 }
360
361 fn as_any(&self) -> &dyn Any {
362 self
363 }
364
365 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
366 Box::new(self.clone())
367 }
368
369 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
370 other.as_any().downcast_ref::<DaemonOutputs>() == Some(self)
371 }
372
373 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
374 serde_json::to_value(self)
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn daemon_serializes_with_resource_type() {
384 let daemon = Daemon::new("endpoint-agent".to_string())
385 .code(DaemonCode::Source {
386 src: "./agent".to_string(),
387 toolchain: ToolchainConfig::Rust {
388 binary_name: "agent".to_string(),
389 },
390 })
391 .permissions("execution".to_string())
392 .commands_enabled(true)
393 .build();
394
395 let resource = crate::Resource::new(daemon);
396 let json = serde_json::to_value(&resource).expect("daemon should serialize");
397 assert_eq!(json["type"], "daemon");
398
399 let roundtrip: crate::Resource =
400 serde_json::from_value(json).expect("daemon should deserialize");
401 assert_eq!(roundtrip.resource_type().as_ref(), "daemon");
402 }
403
404 #[test]
405 fn daemon_accepts_one_public_http_endpoint() {
406 let daemon = Daemon::new("gateway".to_string())
407 .code(DaemonCode::Image {
408 image: "gateway:latest".to_string(),
409 })
410 .public_endpoint(PublicEndpoint {
411 name: "public".to_string(),
412 port: 8080,
413 protocol: ExposeProtocol::Http,
414 host_label: Some("public".to_string()),
415 wildcard_subdomains: true,
416 })
417 .permissions("gateway".to_string())
418 .build();
419
420 assert!(daemon.validate_public_endpoints().is_ok());
421 assert_eq!(daemon.public_endpoints.len(), 1);
422 assert_eq!(
423 daemon.public_endpoints[0].host_label.as_deref(),
424 Some("public")
425 );
426 assert!(daemon.public_endpoints[0].wildcard_subdomains);
427 }
428
429 #[test]
430 fn daemon_serializes_stop_grace_period_when_set() {
431 let daemon = Daemon::new("gateway".to_string())
432 .code(DaemonCode::Image {
433 image: "gateway:latest".to_string(),
434 })
435 .permissions("gateway".to_string())
436 .stop_grace_period_seconds(21_600)
437 .build();
438
439 let json = serde_json::to_value(&daemon).expect("daemon should serialize");
440 assert_eq!(json["stopGracePeriodSeconds"], 21_600);
441 }
442
443 #[test]
444 fn daemon_omits_stop_grace_period_when_absent() {
445 let daemon = Daemon::new("gateway".to_string())
446 .code(DaemonCode::Image {
447 image: "gateway:latest".to_string(),
448 })
449 .permissions("gateway".to_string())
450 .build();
451
452 let json = serde_json::to_value(&daemon).expect("daemon should serialize");
453 assert!(json.get("stopGracePeriodSeconds").is_none());
454 }
455
456 #[test]
457 fn daemon_rejects_multiple_backend_ports_or_non_http_public_endpoints() {
458 let multiple = Daemon::new("gateway".to_string())
459 .code(DaemonCode::Image {
460 image: "gateway:latest".to_string(),
461 })
462 .public_endpoint(PublicEndpoint {
463 name: "api".to_string(),
464 port: 8080,
465 protocol: ExposeProtocol::Http,
466 host_label: None,
467 wildcard_subdomains: false,
468 })
469 .public_endpoint(PublicEndpoint {
470 name: "admin".to_string(),
471 port: 9090,
472 protocol: ExposeProtocol::Http,
473 host_label: None,
474 wildcard_subdomains: false,
475 })
476 .permissions("gateway".to_string())
477 .build();
478 assert!(multiple.validate_public_endpoints().is_err());
479
480 let tcp = Daemon::new("gateway".to_string())
481 .code(DaemonCode::Image {
482 image: "gateway:latest".to_string(),
483 })
484 .public_endpoint(PublicEndpoint {
485 name: "api".to_string(),
486 port: 8080,
487 protocol: ExposeProtocol::Tcp,
488 host_label: None,
489 wildcard_subdomains: false,
490 })
491 .permissions("gateway".to_string())
492 .build();
493 assert!(tcp.validate_public_endpoints().is_err());
494 }
495
496 #[test]
497 fn daemon_rejects_multiple_apex_public_endpoints() {
498 let daemon = Daemon::new("gateway".to_string())
499 .code(DaemonCode::Image {
500 image: "gateway:latest".to_string(),
501 })
502 .public_endpoint(PublicEndpoint {
503 name: "api".to_string(),
504 port: 8080,
505 protocol: ExposeProtocol::Http,
506 host_label: Some(APEX_HOST_LABEL.to_string()),
507 wildcard_subdomains: false,
508 })
509 .public_endpoint(PublicEndpoint {
510 name: "admin".to_string(),
511 port: 8080,
512 protocol: ExposeProtocol::Http,
513 host_label: Some(APEX_HOST_LABEL.to_string()),
514 wildcard_subdomains: false,
515 })
516 .permissions("gateway".to_string())
517 .build();
518
519 assert!(daemon.validate_public_endpoints().is_err());
520 }
521}