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 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")]
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}