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
164impl ResourceDefinition for Build {
165    fn get_resource_type(&self) -> ResourceType {
166        Self::RESOURCE_TYPE
167    }
168
169    fn id(&self) -> &str {
170        &self.id
171    }
172
173    fn get_dependencies(&self) -> Vec<ResourceRef> {
174        // Builds only depend on their linked resources
175        // The permission profile is resolved at the stack level to create ServiceAccount resources
176        self.links.clone()
177    }
178
179    fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
180        // Downcast to Build type to use the existing validate_update method
181        let new_build = new_config.as_any().downcast_ref::<Build>().ok_or_else(|| {
182            AlienError::new(ErrorData::UnexpectedResourceType {
183                resource_id: self.id.clone(),
184                expected: Self::RESOURCE_TYPE,
185                actual: new_config.get_resource_type(),
186            })
187        })?;
188
189        if self.id != new_build.id {
190            return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
191                resource_id: self.id.clone(),
192                reason: "the 'id' field is immutable".to_string(),
193            }));
194        }
195        Ok(())
196    }
197
198    fn as_any(&self) -> &dyn Any {
199        self
200    }
201
202    fn as_any_mut(&mut self) -> &mut dyn Any {
203        self
204    }
205
206    fn box_clone(&self) -> Box<dyn ResourceDefinition> {
207        Box::new(self.clone())
208    }
209
210    fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
211        other.as_any().downcast_ref::<Build>() == Some(self)
212    }
213
214    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
215        serde_json::to_value(self)
216    }
217}
218
219/// Outputs generated by a successfully provisioned Build.
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
222#[serde(rename_all = "camelCase")]
223pub struct BuildOutputs {
224    /// The platform-specific build project identifier (ARN for AWS, project ID for GCP, resource ID for Azure).
225    pub identifier: String,
226}
227
228impl ResourceOutputsDefinition for BuildOutputs {
229    fn get_resource_type(&self) -> ResourceType {
230        Build::RESOURCE_TYPE.clone()
231    }
232
233    fn as_any(&self) -> &dyn Any {
234        self
235    }
236
237    fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
238        Box::new(self.clone())
239    }
240
241    fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
242        other.as_any().downcast_ref::<BuildOutputs>() == Some(self)
243    }
244
245    fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
246        serde_json::to_value(self)
247    }
248}
249
250/// Information about a build execution.
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
253#[serde(rename_all = "camelCase")]
254pub struct BuildExecution {
255    /// Unique identifier for this build execution.
256    pub id: String,
257    /// Current status of the build.
258    pub status: BuildStatus,
259    /// Build start time (ISO 8601 format).
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub start_time: Option<String>,
262    /// Build end time (ISO 8601 format).
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub end_time: Option<String>,
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::Storage;
271
272    #[test]
273    fn test_build_builder_direct_refs() {
274        let dummy_storage = Storage::new("test-storage".to_string()).build();
275
276        let build = Build::new("my-build".to_string())
277            .permissions("build-execution".to_string())
278            .link(&dummy_storage) // Pass reference directly
279            .compute_type(ComputeType::Large)
280            .build();
281
282        assert_eq!(build.id, "my-build");
283        assert_eq!(build.compute_type, ComputeType::Large);
284
285        // Verify permissions was set correctly
286        assert_eq!(build.permissions, "build-execution");
287
288        // Verify links were added correctly
289        assert!(build
290            .links
291            .contains(&ResourceRef::new(Storage::RESOURCE_TYPE, "test-storage")));
292        assert_eq!(build.links.len(), 1);
293    }
294
295    #[test]
296    fn test_build_defaults() {
297        let build = Build::new("default-build".to_string())
298            .permissions("build-execution".to_string())
299            .build();
300
301        assert_eq!(build.compute_type, ComputeType::Medium);
302        assert!(build.environment.is_empty());
303        assert!(build.links.is_empty());
304    }
305
306    #[test]
307    fn test_build_with_environment() {
308        let mut env = HashMap::new();
309        env.insert("NODE_ENV".to_string(), "production".to_string());
310        env.insert("API_KEY".to_string(), "secret".to_string());
311
312        let build = Build::new("env-build".to_string())
313            .environment(env.clone())
314            .permissions("test".to_string())
315            .build();
316
317        assert_eq!(build.environment, env);
318    }
319
320    #[test]
321    fn test_build_config_with_monitoring() {
322        let mut headers = HashMap::new();
323        headers.insert("Authorization".to_string(), "Bearer token123".to_string());
324        headers.insert("X-Custom-Header".to_string(), "custom-value".to_string());
325
326        let monitoring_config = MonitoringConfig {
327            endpoint: "https://otel-collector.example.com:4318".to_string(),
328            headers,
329            logs_uri: "/v1/logs".to_string(),
330            tls_enabled: true,
331            tls_verify: false,
332        };
333
334        let build_config = BuildConfig {
335            image: "ubuntu:20.04".to_string(),
336            script: "echo 'Hello World'".to_string(),
337            environment: HashMap::new(),
338            timeout_seconds: 300,
339            compute_type: ComputeType::Medium,
340            monitoring: Some(monitoring_config.clone()),
341        };
342
343        assert_eq!(build_config.image, "ubuntu:20.04");
344        assert_eq!(build_config.script, "echo 'Hello World'");
345        assert_eq!(build_config.timeout_seconds, 300);
346        assert_eq!(build_config.compute_type, ComputeType::Medium);
347        assert!(build_config.monitoring.is_some());
348
349        let monitoring = build_config.monitoring.unwrap();
350        assert_eq!(
351            monitoring.endpoint,
352            "https://otel-collector.example.com:4318"
353        );
354        assert_eq!(monitoring.logs_uri, "/v1/logs");
355        assert!(monitoring.tls_enabled);
356        assert!(!monitoring.tls_verify);
357        assert_eq!(monitoring.headers.len(), 2);
358        assert_eq!(
359            monitoring.headers.get("Authorization"),
360            Some(&"Bearer token123".to_string())
361        );
362        assert_eq!(
363            monitoring.headers.get("X-Custom-Header"),
364            Some(&"custom-value".to_string())
365        );
366    }
367
368    #[test]
369    fn test_monitoring_config_defaults() {
370        let monitoring_config = MonitoringConfig {
371            endpoint: "https://otel-collector.example.com:4318".to_string(),
372            headers: HashMap::new(),
373            logs_uri: "/v1/logs".to_string(),
374            tls_enabled: true,
375            tls_verify: true,
376        };
377
378        assert_eq!(monitoring_config.logs_uri, "/v1/logs");
379        assert!(monitoring_config.tls_enabled);
380        assert!(monitoring_config.tls_verify);
381        assert!(monitoring_config.headers.is_empty());
382    }
383}