Skip to main content

alien_core/resources/
daemon.rs

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    /// Absolute host path to mount into the daemon container.
32    pub source: String,
33    /// Absolute container path where the source is mounted.
34    pub target: String,
35    /// Optional mount options understood by the backend runtime.
36    #[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    /// Run the daemon container with elevated host capabilities.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub privileged: Option<bool>,
47    /// Process namespace mode. Supported values are `host` and `private`.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub pid_namespace: Option<String>,
50    /// Network mode. Supported values are `host` and `appnet`.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub network_mode: Option<String>,
53    /// Host mounts exposed to the daemon container.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub mounts: Vec<DaemonRuntimeMount>,
56    /// Runtime user, as a numeric uid or uid:gid string.
57    #[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    /// Public endpoints exposed by the daemon.
71    #[builder(field)]
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub public_endpoints: Vec<PublicEndpoint>,
74    /// HTTP health check for public daemon endpoint load balancers.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub health_check: Option<HealthCheck>,
77    /// ComputeCluster resource ID that this daemon runs on for managed cloud
78    /// compute backends. Kubernetes and Local runtimes ignore this field.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub cluster: Option<String>,
81    pub permissions: String,
82    pub code: DaemonCode,
83    /// CPU resource requirements for each daemon instance.
84    #[builder(default = default_daemon_cpu())]
85    #[serde(default = "default_daemon_cpu")]
86    pub cpu: ResourceSpec,
87    /// Memory resource requirements for each daemon instance.
88    #[builder(default = default_daemon_memory())]
89    #[serde(default = "default_daemon_memory")]
90    pub memory: ResourceSpec,
91    /// Capacity group/pool to run on for backends that expose machine pools.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub pool: Option<String>,
94    /// Command to override the image default.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub command: Option<Vec<String>>,
97    /// Grace period in seconds for stopping daemon instances during updates, drains, and deletes.
98    ///
99    /// When omitted, the runtime backend applies its default. Valid values are
100    /// 1 second through 24 hours.
101    #[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    /// Optional backend runtime settings for trusted daemons.
105    ///
106    /// These settings are intended for daemon-style infrastructure that must
107    /// operate on the host. Backends that do not support a setting may reject
108    /// it during provisioning.
109    #[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    /// Whether an app-owned command receiver can lease pending commands for
115    /// this Daemon and execute registered handlers.
116    #[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}