Skip to main content

winterbaume_codebuild/
state.rs

1use std::collections::HashMap;
2
3use chrono::Utc;
4
5use crate::types::*;
6
7#[derive(Debug, Default)]
8pub struct CodeBuildState {
9    pub projects: HashMap<String, Project>,
10    /// Builds keyed by build ID.
11    pub builds: HashMap<String, Build>,
12    /// Ordered list of build IDs (maintains insertion order).
13    pub build_ids: Vec<String>,
14    /// Next build number per project.
15    pub build_counters: HashMap<String, i64>,
16    /// Webhooks keyed by project name.
17    pub webhooks: HashMap<String, crate::types::Webhook>,
18    /// Source credentials keyed by ARN.
19    pub source_credentials: HashMap<String, crate::types::SourceCredential>,
20    /// Resource policies keyed by resource ARN.
21    pub resource_policies: HashMap<String, String>,
22    /// Report groups keyed by ARN.
23    pub report_groups: HashMap<String, crate::types::ReportGroup>,
24    /// Ordered list of report group ARNs (maintains insertion order).
25    pub report_group_arns: Vec<String>,
26}
27
28#[derive(Debug, thiserror::Error)]
29pub enum CodeBuildError {
30    #[error("Only alphanumeric characters, dash, and underscore are supported")]
31    InvalidProjectName,
32    #[error("Invalid service role: Service role account ID does not match caller's account")]
33    InvalidServiceRole,
34    #[error("Invalid build ID provided")]
35    InvalidBuildId,
36    #[error("Project already exists: arn:aws:codebuild:{region}:{account_id}:project/{name}")]
37    ProjectAlreadyExists {
38        region: String,
39        account_id: String,
40        name: String,
41    },
42    #[error("Project cannot be found: arn:aws:codebuild:{region}:{account_id}:project/{name}")]
43    ProjectNotFound {
44        region: String,
45        account_id: String,
46        name: String,
47    },
48    #[error("Build {build_id} does not exist")]
49    BuildNotFound { build_id: String },
50    #[error("Project {name} does not exist")]
51    ProjectDoesNotExist { name: String },
52    #[error("Webhook for project {project_name} already exists")]
53    WebhookAlreadyExists { project_name: String },
54    #[error("Webhook for project {project_name} does not exist")]
55    WebhookNotFound { project_name: String },
56    #[error("Source credentials {arn} do not exist")]
57    SourceCredentialsNotFound { arn: String },
58    #[error("Resource policy for {resource_arn} does not exist")]
59    ResourcePolicyNotFound { resource_arn: String },
60    #[error("Report group with name {name} already exists")]
61    ReportGroupAlreadyExists { name: String },
62    #[error("Report group {arn} does not exist")]
63    ReportGroupNotFound { arn: String },
64}
65
66fn validate_project_name(name: &str) -> Result<(), CodeBuildError> {
67    if name.len() >= 150 {
68        return Err(CodeBuildError::InvalidProjectName);
69    }
70
71    // Must start with a letter, must not end with special characters
72    let re = regex::Regex::new(r"^[A-Za-z].*[^!£$%^&*()\+=\|?`¬{}\@~#:;<>\\/\[\]]$").unwrap();
73    if !re.is_match(name) {
74        return Err(CodeBuildError::InvalidProjectName);
75    }
76
77    Ok(())
78}
79
80fn validate_service_role(account_id: &str, service_role: &str) -> Result<(), CodeBuildError> {
81    let prefix = format!("arn:aws:iam::{account_id}:role/");
82    if !service_role.starts_with(&prefix) {
83        return Err(CodeBuildError::InvalidServiceRole);
84    }
85    Ok(())
86}
87
88fn validate_build_id(build_id: &str) -> Result<(), CodeBuildError> {
89    if !build_id.contains(':') {
90        return Err(CodeBuildError::InvalidBuildId);
91    }
92    Ok(())
93}
94
95impl CodeBuildState {
96    pub fn create_project(
97        &mut self,
98        name: &str,
99        description: &str,
100        source_type: &str,
101        source_location: &str,
102        artifact_type: &str,
103        artifact_location: Option<&str>,
104        env_type: &str,
105        env_image: &str,
106        env_compute: &str,
107        service_role: &str,
108        tags: Vec<Tag>,
109        account_id: &str,
110        region: &str,
111    ) -> Result<&Project, CodeBuildError> {
112        validate_project_name(name)?;
113        validate_service_role(account_id, service_role)?;
114
115        if self.projects.contains_key(name) {
116            return Err(CodeBuildError::ProjectAlreadyExists {
117                region: region.to_string(),
118                account_id: account_id.to_string(),
119                name: name.to_string(),
120            });
121        }
122
123        let arn = format!("arn:aws:codebuild:{region}:{account_id}:project/{name}");
124        let now = Utc::now();
125
126        let project = Project {
127            name: name.to_string(),
128            arn,
129            description: description.to_string(),
130            source_type: source_type.to_string(),
131            source_location: source_location.to_string(),
132            artifact_type: artifact_type.to_string(),
133            artifact_location: artifact_location.map(|s| s.to_string()),
134            environment_type: env_type.to_string(),
135            environment_image: env_image.to_string(),
136            environment_compute_type: env_compute.to_string(),
137            service_role: service_role.to_string(),
138            tags,
139            created: now,
140            last_modified: now,
141        };
142
143        self.projects.insert(name.to_string(), project);
144        Ok(self.projects.get(name).unwrap())
145    }
146
147    pub fn batch_get_projects(&self, names: &[String]) -> Vec<&Project> {
148        names
149            .iter()
150            .filter_map(|n| {
151                // Try by name first
152                if let Some(p) = self.projects.get(n.as_str()) {
153                    return Some(p);
154                }
155                // Try by ARN
156                if n.starts_with("arn:") {
157                    for project in self.projects.values() {
158                        if project.arn == *n {
159                            return Some(project);
160                        }
161                    }
162                }
163                None
164            })
165            .collect()
166    }
167
168    pub fn delete_project(&mut self, name: &str) -> Result<(), CodeBuildError> {
169        // moto's delete_project does not raise an error if the project doesn't exist
170        self.projects.remove(name);
171        // Also remove build history for the project
172        self.build_ids.retain(|id| {
173            if let Some(build) = self.builds.get(id) {
174                build.project_name != name
175            } else {
176                true
177            }
178        });
179        self.builds.retain(|_, b| b.project_name != name);
180        self.build_counters.remove(name);
181        Ok(())
182    }
183
184    pub fn list_projects(&self) -> Vec<&str> {
185        self.projects.keys().map(|s| s.as_str()).collect()
186    }
187
188    pub fn start_build(
189        &mut self,
190        project_name: &str,
191        source_version: Option<&str>,
192        account_id: &str,
193        region: &str,
194    ) -> Result<&Build, CodeBuildError> {
195        let project = self
196            .projects
197            .get(project_name)
198            .ok_or_else(|| CodeBuildError::ProjectNotFound {
199                region: region.to_string(),
200                account_id: account_id.to_string(),
201                name: project_name.to_string(),
202            })?
203            .clone();
204
205        let counter = self
206            .build_counters
207            .entry(project_name.to_string())
208            .or_insert(0);
209        *counter += 1;
210        let build_number = *counter;
211
212        let build_id = format!("{project_name}:{}", uuid::Uuid::new_v4());
213        let arn = format!("arn:aws:codebuild:{region}:{account_id}:build/{build_id}");
214
215        let now = Utc::now();
216        let now_ts = now.timestamp() as f64;
217
218        let resolved_source_version = source_version.unwrap_or("refs/heads/main").to_string();
219
220        // Initial phases: SUBMITTED (complete) and QUEUED (in progress)
221        let phases = vec![
222            BuildPhase {
223                phase_type: "SUBMITTED".to_string(),
224                phase_status: Some("SUCCEEDED".to_string()),
225                start_time: now_ts,
226                end_time: Some(now_ts),
227                duration_in_seconds: Some(0),
228            },
229            BuildPhase {
230                phase_type: "QUEUED".to_string(),
231                phase_status: None,
232                start_time: now_ts,
233                end_time: None,
234                duration_in_seconds: None,
235            },
236        ];
237
238        let build = Build {
239            id: build_id.clone(),
240            arn,
241            project_name: project_name.to_string(),
242            build_status: "IN_PROGRESS".to_string(),
243            current_phase: "QUEUED".to_string(),
244            source_type: project.source_type.clone(),
245            source_location: project.source_location.clone(),
246            source_version: resolved_source_version,
247            artifact_type: project.artifact_type.clone(),
248            artifact_location: project.artifact_location.clone(),
249            environment_type: project.environment_type.clone(),
250            environment_image: project.environment_image.clone(),
251            environment_compute_type: project.environment_compute_type.clone(),
252            service_role: project.service_role.clone(),
253            start_time: now,
254            end_time: None,
255            build_number,
256            phases,
257        };
258
259        self.builds.insert(build_id.clone(), build);
260        self.build_ids.push(build_id.clone());
261        Ok(self.builds.get(&build_id).unwrap())
262    }
263
264    pub fn stop_build(&mut self, build_id: &str) -> Result<&Build, CodeBuildError> {
265        validate_build_id(build_id)?;
266
267        let build = self
268            .builds
269            .get_mut(build_id)
270            .ok_or_else(|| CodeBuildError::BuildNotFound {
271                build_id: build_id.to_string(),
272            })?;
273
274        // Set completion phases
275        set_completion_phases(&mut build.phases);
276        build.build_status = "STOPPED".to_string();
277        build.current_phase = "COMPLETED".to_string();
278        build.end_time = Some(Utc::now());
279        Ok(self.builds.get(build_id).unwrap())
280    }
281
282    pub fn batch_get_builds(&self, build_ids: &[String]) -> Result<Vec<Build>, CodeBuildError> {
283        // Validate all IDs first
284        for id in build_ids {
285            validate_build_id(id)?;
286        }
287
288        let mut result = Vec::new();
289        for id in build_ids {
290            if let Some(build) = self.builds.get(id.as_str()) {
291                let mut build = build.clone();
292                // When retrieving builds, set them to COMPLETED/SUCCEEDED with all phases
293                set_completion_phases(&mut build.phases);
294                build.current_phase = "COMPLETED".to_string();
295                build.build_status = "SUCCEEDED".to_string();
296                if build.end_time.is_none() {
297                    build.end_time = Some(Utc::now());
298                }
299                result.push(build);
300            }
301        }
302        Ok(result)
303    }
304
305    pub fn list_builds(&self) -> Vec<&str> {
306        self.build_ids.iter().map(|s| s.as_str()).collect()
307    }
308
309    pub fn list_builds_for_project(&self, project_name: &str) -> Vec<&str> {
310        self.build_ids
311            .iter()
312            .filter(|id| {
313                self.builds
314                    .get(id.as_str())
315                    .map(|b| b.project_name == project_name)
316                    .unwrap_or(false)
317            })
318            .map(|s| s.as_str())
319            .collect()
320    }
321
322    pub fn batch_delete_builds(&mut self, ids: &[String]) -> Vec<String> {
323        let mut deleted = Vec::new();
324        for id in ids {
325            if self.builds.remove(id.as_str()).is_some() {
326                self.build_ids.retain(|bid| bid != id);
327                deleted.push(id.clone());
328            }
329        }
330        deleted
331    }
332
333    pub fn update_project(
334        &mut self,
335        name: &str,
336        description: Option<&str>,
337        source_type: Option<&str>,
338        source_location: Option<&str>,
339        artifact_type: Option<&str>,
340        artifact_location: Option<Option<&str>>,
341        env_type: Option<&str>,
342        env_image: Option<&str>,
343        env_compute: Option<&str>,
344        service_role: Option<&str>,
345        tags: Option<Vec<crate::types::Tag>>,
346        account_id: &str,
347    ) -> Result<&Project, CodeBuildError> {
348        let project =
349            self.projects
350                .get_mut(name)
351                .ok_or_else(|| CodeBuildError::ProjectDoesNotExist {
352                    name: name.to_string(),
353                })?;
354        if let Some(d) = description {
355            project.description = d.to_string();
356        }
357        if let Some(t) = source_type {
358            project.source_type = t.to_string();
359        }
360        if let Some(l) = source_location {
361            project.source_location = l.to_string();
362        }
363        if let Some(t) = artifact_type {
364            project.artifact_type = t.to_string();
365        }
366        if let Some(l) = artifact_location {
367            project.artifact_location = l.map(|s| s.to_string());
368        }
369        if let Some(t) = env_type {
370            project.environment_type = t.to_string();
371        }
372        if let Some(i) = env_image {
373            project.environment_image = i.to_string();
374        }
375        if let Some(c) = env_compute {
376            project.environment_compute_type = c.to_string();
377        }
378        if let Some(r) = service_role {
379            // Validate service role
380            let prefix = format!("arn:aws:iam::{account_id}:role/");
381            if !r.starts_with(&prefix) {
382                return Err(CodeBuildError::InvalidServiceRole);
383            }
384            project.service_role = r.to_string();
385        }
386        if let Some(t) = tags {
387            project.tags = t;
388        }
389        project.last_modified = Utc::now();
390        Ok(self.projects.get(name).unwrap())
391    }
392
393    pub fn retry_build(
394        &mut self,
395        build_id: &str,
396        account_id: &str,
397        region: &str,
398    ) -> Result<&Build, CodeBuildError> {
399        validate_build_id(build_id)?;
400
401        let original = self
402            .builds
403            .get(build_id)
404            .ok_or_else(|| CodeBuildError::BuildNotFound {
405                build_id: build_id.to_string(),
406            })?
407            .clone();
408
409        let project_name = original.project_name.clone();
410        let source_version = original.source_version.clone();
411
412        self.start_build(&project_name, Some(&source_version), account_id, region)
413    }
414
415    // ── Webhook ──
416
417    pub fn create_webhook(
418        &mut self,
419        project_name: &str,
420        branch_filter: Option<&str>,
421        build_type: Option<&str>,
422        account_id: &str,
423        region: &str,
424    ) -> Result<&crate::types::Webhook, CodeBuildError> {
425        if !self.projects.contains_key(project_name) {
426            return Err(CodeBuildError::ProjectNotFound {
427                region: region.to_string(),
428                account_id: account_id.to_string(),
429                name: project_name.to_string(),
430            });
431        }
432        if self.webhooks.contains_key(project_name) {
433            return Err(CodeBuildError::WebhookAlreadyExists {
434                project_name: project_name.to_string(),
435            });
436        }
437        let webhook = crate::types::Webhook {
438            project_name: project_name.to_string(),
439            url: format!("https://codebuild.{region}.amazonaws.com/webhooks/{project_name}"),
440            branch_filter: branch_filter.map(|s| s.to_string()),
441            build_type: build_type.map(|s| s.to_string()),
442            secret: Some(uuid::Uuid::new_v4().to_string()),
443        };
444        self.webhooks.insert(project_name.to_string(), webhook);
445        Ok(self.webhooks.get(project_name).unwrap())
446    }
447
448    pub fn update_webhook(
449        &mut self,
450        project_name: &str,
451        branch_filter: Option<&str>,
452        build_type: Option<&str>,
453    ) -> Result<&crate::types::Webhook, CodeBuildError> {
454        let webhook =
455            self.webhooks
456                .get_mut(project_name)
457                .ok_or_else(|| CodeBuildError::WebhookNotFound {
458                    project_name: project_name.to_string(),
459                })?;
460        if let Some(bf) = branch_filter {
461            webhook.branch_filter = Some(bf.to_string());
462        }
463        if let Some(bt) = build_type {
464            webhook.build_type = Some(bt.to_string());
465        }
466        Ok(self.webhooks.get(project_name).unwrap())
467    }
468
469    pub fn delete_webhook(&mut self, project_name: &str) -> Result<(), CodeBuildError> {
470        match self.webhooks.remove(project_name) {
471            Some(_) => Ok(()),
472            None => Err(CodeBuildError::WebhookNotFound {
473                project_name: project_name.to_string(),
474            }),
475        }
476    }
477
478    // ── Source Credentials ──
479
480    pub fn import_source_credentials(
481        &mut self,
482        token: &str,
483        server_type: &str,
484        auth_type: &str,
485        username: Option<&str>,
486        account_id: &str,
487        region: &str,
488    ) -> Result<&crate::types::SourceCredential, CodeBuildError> {
489        let id = uuid::Uuid::new_v4().to_string();
490        let arn = format!("arn:aws:codebuild:{region}:{account_id}:token/{server_type}/{id}");
491        let _ = token; // token is not stored in mock (security)
492        let cred = crate::types::SourceCredential {
493            arn: arn.clone(),
494            server_type: server_type.to_string(),
495            auth_type: auth_type.to_string(),
496            resource: username.map(|s| s.to_string()),
497        };
498        self.source_credentials.insert(arn.clone(), cred);
499        Ok(self.source_credentials.get(&arn).unwrap())
500    }
501
502    pub fn list_source_credentials(&self) -> Vec<&crate::types::SourceCredential> {
503        self.source_credentials.values().collect()
504    }
505
506    pub fn delete_source_credentials(&mut self, arn: &str) -> Result<(), CodeBuildError> {
507        match self.source_credentials.remove(arn) {
508            Some(_) => Ok(()),
509            None => Err(CodeBuildError::SourceCredentialsNotFound {
510                arn: arn.to_string(),
511            }),
512        }
513    }
514
515    // ── Resource Policies ──
516
517    pub fn put_resource_policy(
518        &mut self,
519        resource_arn: &str,
520        policy: &str,
521    ) -> Result<String, CodeBuildError> {
522        self.resource_policies
523            .insert(resource_arn.to_string(), policy.to_string());
524        Ok(resource_arn.to_string())
525    }
526
527    pub fn get_resource_policy(&self, resource_arn: &str) -> Result<String, CodeBuildError> {
528        self.resource_policies
529            .get(resource_arn)
530            .cloned()
531            .ok_or_else(|| CodeBuildError::ResourcePolicyNotFound {
532                resource_arn: resource_arn.to_string(),
533            })
534    }
535
536    pub fn delete_resource_policy(&mut self, resource_arn: &str) -> Result<(), CodeBuildError> {
537        self.resource_policies.remove(resource_arn);
538        Ok(())
539    }
540
541    // ── Report Groups ──
542
543    pub fn create_report_group(
544        &mut self,
545        name: &str,
546        report_type: &str,
547        export_config_type: Option<&str>,
548        tags: Vec<crate::types::Tag>,
549        account_id: &str,
550        region: &str,
551    ) -> Result<&crate::types::ReportGroup, CodeBuildError> {
552        // Check for duplicate name
553        for rg in self.report_groups.values() {
554            if rg.name == name {
555                return Err(CodeBuildError::ReportGroupAlreadyExists {
556                    name: name.to_string(),
557                });
558            }
559        }
560
561        let id = uuid::Uuid::new_v4().to_string();
562        let arn = format!("arn:aws:codebuild:{region}:{account_id}:report-group/{name}-{id}");
563        let now = Utc::now();
564
565        let rg = crate::types::ReportGroup {
566            arn: arn.clone(),
567            name: name.to_string(),
568            r#type: report_type.to_string(),
569            export_config_type: export_config_type.map(|s| s.to_string()),
570            tags,
571            created: now,
572            last_modified: now,
573            status: "ACTIVE".to_string(),
574        };
575
576        self.report_groups.insert(arn.clone(), rg);
577        self.report_group_arns.push(arn.clone());
578        Ok(self.report_groups.get(&arn).unwrap())
579    }
580
581    pub fn batch_get_report_groups(&self, arns: &[String]) -> Vec<&crate::types::ReportGroup> {
582        arns.iter()
583            .filter_map(|arn| self.report_groups.get(arn.as_str()))
584            .collect()
585    }
586
587    pub fn list_report_groups(&self) -> Vec<&str> {
588        self.report_group_arns.iter().map(|s| s.as_str()).collect()
589    }
590
591    pub fn delete_report_group(&mut self, arn: &str) -> Result<(), CodeBuildError> {
592        match self.report_groups.remove(arn) {
593            Some(_) => {
594                self.report_group_arns.retain(|a| a != arn);
595                Ok(())
596            }
597            None => Err(CodeBuildError::ReportGroupNotFound {
598                arn: arn.to_string(),
599            }),
600        }
601    }
602
603    pub fn update_report_group(
604        &mut self,
605        arn: &str,
606        export_config_type: Option<&str>,
607        tags: Option<Vec<crate::types::Tag>>,
608    ) -> Result<&crate::types::ReportGroup, CodeBuildError> {
609        let rg =
610            self.report_groups
611                .get_mut(arn)
612                .ok_or_else(|| CodeBuildError::ReportGroupNotFound {
613                    arn: arn.to_string(),
614                })?;
615
616        if let Some(ect) = export_config_type {
617            rg.export_config_type = Some(ect.to_string());
618        }
619        if let Some(t) = tags {
620            rg.tags = t;
621        }
622        rg.last_modified = Utc::now();
623        Ok(self.report_groups.get(arn).unwrap())
624    }
625}
626
627fn set_completion_phases(phases: &mut Vec<BuildPhase>) {
628    let now_ts = Utc::now().timestamp() as f64;
629
630    // Set QUEUED phase status to SUCCEEDED
631    for phase in phases.iter_mut() {
632        if phase.phase_type == "QUEUED" && phase.phase_status.is_none() {
633            phase.phase_status = Some("SUCCEEDED".to_string());
634        }
635    }
636
637    let additional_phases = [
638        "PROVISIONING",
639        "DOWNLOAD_SOURCE",
640        "INSTALL",
641        "PRE_BUILD",
642        "BUILD",
643        "POST_BUILD",
644        "UPLOAD_ARTIFACTS",
645        "FINALIZING",
646        "COMPLETED",
647    ];
648
649    for phase_type in &additional_phases {
650        // Only add if not already present
651        if !phases.iter().any(|p| p.phase_type == *phase_type) {
652            phases.push(BuildPhase {
653                phase_type: phase_type.to_string(),
654                phase_status: Some("SUCCEEDED".to_string()),
655                start_time: now_ts,
656                end_time: Some(now_ts),
657                duration_in_seconds: Some(0),
658            });
659        }
660    }
661}