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