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