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    #[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}