Skip to main content

alien_core/resources/
build.rs

1use crate::error::{ErrorData, Result};
2use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
3use alien_error::AlienError;
4use bon::Builder;
5use serde::{Deserialize, Serialize};
6use std::any::Any;
7use std::collections::HashMap;
8use std::fmt::Debug;
9
10/// Status of a build execution.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
14pub enum BuildStatus {
15    /// Build is queued and waiting to start
16    Queued,
17    /// Build is currently running
18    Running,
19    /// Build completed successfully
20    Succeeded,
21    /// Build failed with errors
22    Failed,
23    /// Build was cancelled or stopped
24    Cancelled,
25    /// Build timed out
26    TimedOut,
27}
28
29/// Compute type for build resources.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
32#[serde(rename_all = "kebab-case")]
33pub enum ComputeType {
34    /// Small compute resources (e.g., 0.25 vCPU, 0.5 GB RAM)
35    Small,
36    /// Medium compute resources (e.g., 0.5 vCPU, 1 GB RAM)
37    Medium,
38    /// Large compute resources (e.g., 1 vCPU, 2 GB RAM)
39    Large,
40    /// Extra large compute resources (e.g., 2 vCPU, 4 GB RAM)
41    XLarge,
42}
43
44impl Default for ComputeType {
45    fn default() -> Self {
46        ComputeType::Medium
47    }
48}
49
50/// Configuration for monitoring and observability.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "camelCase")]
54pub struct MonitoringConfig {
55    /// The monitoring endpoint URL (e.g., "https://otel-collector.example.com:4318")
56    pub endpoint: String,
57    /// Optional HTTP headers to include in requests to the monitoring endpoint
58    #[serde(default)]
59    pub headers: HashMap<String, String>,
60    /// Optional URI path for logs (defaults to "/v1/logs")
61    #[serde(default = "default_logs_uri")]
62    pub logs_uri: String,
63    /// Whether to enable TLS/HTTPS (defaults to true)
64    #[serde(default = "default_tls_enabled")]
65    pub tls_enabled: bool,
66    /// Whether to verify TLS certificates (defaults to true)
67    #[serde(default = "default_tls_verify")]
68    pub tls_verify: bool,
69}
70
71fn default_logs_uri() -> String {
72    "/v1/logs".to_string()
73}
74
75fn default_tls_enabled() -> bool {
76    true
77}
78
79fn default_tls_verify() -> bool {
80    true
81}
82
83/// Configuration for starting a build.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
86#[serde(rename_all = "camelCase")]
87pub struct BuildConfig {
88    /// Base container image to use for the build environment.
89    pub image: String,
90    /// Bash script to execute for the build.
91    pub script: String,
92    /// Key-value pairs to set as environment variables for the build.
93    #[serde(default)]
94    pub environment: HashMap<String, String>,
95    /// Maximum execution time for the build in seconds.
96    pub timeout_seconds: u32,
97    /// Amount of compute resources allocated to the build.
98    #[serde(default)]
99    pub compute_type: ComputeType,
100    /// Optional monitoring configuration for sending build logs
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub monitoring: Option<MonitoringConfig>,
103}
104
105/// Represents a build resource that executes bash scripts to build code.
106/// Builds are designed to be stateless and can be triggered on-demand to compile,
107/// test, or package application code.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
110#[serde(rename_all = "camelCase", deny_unknown_fields)]
111#[builder(start_fn = new)]
112pub struct Build {
113    /// Identifier for the build resource. Must contain only alphanumeric characters, hyphens, and underscores ([A-Za-z0-9-_]).
114    /// Maximum 64 characters.
115    #[builder(start_fn)]
116    pub id: String,
117
118    /// List of resource references this build depends on.
119    #[builder(field)]
120    pub links: Vec<ResourceRef>,
121
122    /// Permission profile name that defines the permissions granted to this build.
123    /// This references a profile defined in the stack's permission definitions.
124    pub permissions: String,
125
126    /// Key-value pairs to set as environment variables for the build.
127    #[builder(default)]
128    #[serde(default)]
129    pub environment: HashMap<String, String>,
130
131    /// Amount of compute resources allocated to the build.
132    #[builder(default)]
133    #[serde(default)]
134    pub compute_type: ComputeType,
135}
136
137impl Build {
138    /// The resource type identifier for Builds
139    pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("build");
140
141    /// Returns the permission profile name for this build.
142    pub fn get_permissions(&self) -> &str {
143        &self.permissions
144    }
145}
146
147use crate::resources::build::build_builder::State;
148
149impl<S: State> BuildBuilder<S> {
150    /// Links the build to another resource with specified permissions.
151    /// Accepts a reference to any type `R` where `&R` can be converted into `ResourceRef`.
152    pub fn link<R: ?Sized>(mut self, resource: &R) -> Self
153    where
154        for<'a> &'a R: Into<ResourceRef>, // Use Higher-Rank Trait Bound (HRTB)
155    {
156        // Perform the conversion from &R to ResourceRef using .into()
157        let resource_ref: ResourceRef = resource.into();
158        self.links.push(resource_ref);
159        self
160    }
161}
162
163// Implementation of ResourceDefinition trait for Build
164#[typetag::serde(name = "build")]
165impl ResourceDefinition for Build {
166    fn resource_type() -> ResourceType {
167        Self::RESOURCE_TYPE.clone()
168    }
169
170    fn get_resource_type(&self) -> ResourceType {
171        Self::resource_type()
172    }
173
174    fn id(&self) -> &str {
175        &self.id
176    }
177
178    fn get_dependencies(&self) -> Vec<ResourceRef> {
179        // Builds only depend on their linked resources
180        // The permission profile is resolved at the stack level to create ServiceAccount resources
181        self.links.clone()
182    }
183
184    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
185        // Downcast to Build type to use the existing validate_update method
186        let new_build = new_config.as_any().downcast_ref::<Build>().ok_or_else(|| {
187            AlienError::new(ErrorData::UnexpectedResourceType {
188                resource_id: self.id.clone(),
189                expected: Self::RESOURCE_TYPE,
190                actual: new_config.get_resource_type(),
191            })
192        })?;
193
194        if self.id != new_build.id {
195            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
196                resource_id: self.id.clone(),
197                reason: "the 'id' field is immutable".to_string(),
198            }));
199        }
200        Ok(())
201    }
202
203    fn as_any(&self) -> &dyn Any {
204        self
205    }
206
207    fn as_any_mut(&mut self) -> &mut dyn Any {
208        self
209    }
210
211    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
212        Box::new(self.clone())
213    }
214
215    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
216        other.as_any().downcast_ref::<Build>() == Some(self)
217    }
218}
219
220/// Outputs generated by a successfully provisioned Build.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
223#[serde(rename_all = "camelCase")]
224pub struct BuildOutputs {
225    /// The platform-specific build project identifier (ARN for AWS, project ID for GCP, resource ID for Azure).
226    pub identifier: String,
227}
228
229#[typetag::serde(name = "build")]
230impl ResourceOutputsDefinition for BuildOutputs {
231    fn resource_type() -> ResourceType {
232        Build::RESOURCE_TYPE.clone()
233    }
234
235    fn as_any(&self) -> &dyn Any {
236        self
237    }
238
239    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
240        Box::new(self.clone())
241    }
242
243    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
244        other.as_any().downcast_ref::<BuildOutputs>() == Some(self)
245    }
246}
247
248/// Information about a build execution.
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
251#[serde(rename_all = "camelCase")]
252pub struct BuildExecution {
253    /// Unique identifier for this build execution.
254    pub id: String,
255    /// Current status of the build.
256    pub status: BuildStatus,
257    /// Build start time (ISO 8601 format).
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub start_time: Option<String>,
260    /// Build end time (ISO 8601 format).
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub end_time: Option<String>,
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::Storage;
269
270    #[test]
271    fn test_build_builder_direct_refs() {
272        let dummy_storage = Storage::new("test-storage".to_string()).build();
273
274        let build = Build::new("my-build".to_string())
275            .permissions("build-execution".to_string())
276            .link(&dummy_storage) // Pass reference directly
277            .compute_type(ComputeType::Large)
278            .build();
279
280        assert_eq!(build.id, "my-build");
281        assert_eq!(build.compute_type, ComputeType::Large);
282
283        // Verify permissions was set correctly
284        assert_eq!(build.permissions, "build-execution");
285
286        // Verify links were added correctly
287        assert!(build
288            .links
289            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
290        assert_eq!(build.links.len(), 1);
291    }
292
293    #[test]
294    fn test_build_defaults() {
295        let build = Build::new("default-build".to_string())
296            .permissions("build-execution".to_string())
297            .build();
298
299        assert_eq!(build.compute_type, ComputeType::Medium);
300        assert!(build.environment.is_empty());
301        assert!(build.links.is_empty());
302    }
303
304    #[test]
305    fn test_build_with_environment() {
306        let mut env = HashMap::new();
307        env.insert("NODE_ENV".to_string(), "production".to_string());
308        env.insert("API_KEY".to_string(), "secret".to_string());
309
310        let build = Build::new("env-build".to_string())
311            .environment(env.clone())
312            .permissions("test".to_string())
313            .build();
314
315        assert_eq!(build.environment, env);
316    }
317
318    #[test]
319    fn test_build_config_with_monitoring() {
320        let mut headers = HashMap::new();
321        headers.insert("Authorization".to_string(), "Bearer token123".to_string());
322        headers.insert("X-Custom-Header".to_string(), "custom-value".to_string());
323
324        let monitoring_config = MonitoringConfig {
325            endpoint: "https://otel-collector.example.com:4318".to_string(),
326            headers,
327            logs_uri: "/v1/logs".to_string(),
328            tls_enabled: true,
329            tls_verify: false,
330        };
331
332        let build_config = BuildConfig {
333            image: "ubuntu:20.04".to_string(),
334            script: "echo 'Hello World'".to_string(),
335            environment: HashMap::new(),
336            timeout_seconds: 300,
337            compute_type: ComputeType::Medium,
338            monitoring: Some(monitoring_config.clone()),
339        };
340
341        assert_eq!(build_config.image, "ubuntu:20.04");
342        assert_eq!(build_config.script, "echo 'Hello World'");
343        assert_eq!(build_config.timeout_seconds, 300);
344        assert_eq!(build_config.compute_type, ComputeType::Medium);
345        assert!(build_config.monitoring.is_some());
346
347        let monitoring = build_config.monitoring.unwrap();
348        assert_eq!(
349            monitoring.endpoint,
350            "https://otel-collector.example.com:4318"
351        );
352        assert_eq!(monitoring.logs_uri, "/v1/logs");
353        assert!(monitoring.tls_enabled);
354        assert!(!monitoring.tls_verify);
355        assert_eq!(monitoring.headers.len(), 2);
356        assert_eq!(
357            monitoring.headers.get("Authorization"),
358            Some(&"Bearer token123".to_string())
359        );
360        assert_eq!(
361            monitoring.headers.get("X-Custom-Header"),
362            Some(&"custom-value".to_string())
363        );
364    }
365
366    #[test]
367    fn test_monitoring_config_defaults() {
368        let monitoring_config = MonitoringConfig {
369            endpoint: "https://otel-collector.example.com:4318".to_string(),
370            headers: HashMap::new(),
371            logs_uri: "/v1/logs".to_string(),
372            tls_enabled: true,
373            tls_verify: true,
374        };
375
376        assert_eq!(monitoring_config.logs_uri, "/v1/logs");
377        assert!(monitoring_config.tls_enabled);
378        assert!(monitoring_config.tls_verify);
379        assert!(monitoring_config.headers.is_empty());
380    }
381}