1use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use http::StatusCode;
16use serde_json::{json, Map, Value};
17use std::sync::Arc;
18use tokio::sync::Mutex as AsyncMutex;
19
20use fakecloud_core::pagination::paginate_checked;
21use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
22use fakecloud_persistence::SnapshotStore;
23
24use crate::state::SharedCodePipelineState;
25use crate::validate;
26
27pub const CODEPIPELINE_ACTIONS: &[&str] = &[
28 "AcknowledgeJob",
29 "AcknowledgeThirdPartyJob",
30 "CreateCustomActionType",
31 "CreatePipeline",
32 "DeleteCustomActionType",
33 "DeletePipeline",
34 "DeleteWebhook",
35 "DeregisterWebhookWithThirdParty",
36 "DisableStageTransition",
37 "EnableStageTransition",
38 "GetActionType",
39 "GetJobDetails",
40 "GetPipeline",
41 "GetPipelineExecution",
42 "GetPipelineState",
43 "GetThirdPartyJobDetails",
44 "ListActionExecutions",
45 "ListActionTypes",
46 "ListDeployActionExecutionTargets",
47 "ListPipelineExecutions",
48 "ListPipelines",
49 "ListRuleExecutions",
50 "ListRuleTypes",
51 "ListTagsForResource",
52 "ListWebhooks",
53 "OverrideStageCondition",
54 "PollForJobs",
55 "PollForThirdPartyJobs",
56 "PutActionRevision",
57 "PutApprovalResult",
58 "PutJobFailureResult",
59 "PutJobSuccessResult",
60 "PutThirdPartyJobFailureResult",
61 "PutThirdPartyJobSuccessResult",
62 "PutWebhook",
63 "RegisterWebhookWithThirdParty",
64 "RetryStageExecution",
65 "RollbackStage",
66 "StartPipelineExecution",
67 "StopPipelineExecution",
68 "TagResource",
69 "UntagResource",
70 "UpdateActionType",
71 "UpdatePipeline",
72];
73
74pub struct CodePipelineService {
75 state: SharedCodePipelineState,
76 snapshot_store: Option<Arc<dyn SnapshotStore>>,
77 snapshot_lock: Arc<AsyncMutex<()>>,
78}
79
80impl CodePipelineService {
81 pub fn new(state: SharedCodePipelineState) -> Self {
82 Self {
83 state,
84 snapshot_store: None,
85 snapshot_lock: Arc::new(AsyncMutex::new(())),
86 }
87 }
88
89 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
90 self.snapshot_store = Some(store);
91 self
92 }
93
94 async fn save(&self) {
95 crate::persistence::save_snapshot(
96 &self.state,
97 self.snapshot_store.clone(),
98 &self.snapshot_lock,
99 )
100 .await;
101 }
102
103 pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
105 let store = self.snapshot_store.clone()?;
106 let state = self.state.clone();
107 let lock = self.snapshot_lock.clone();
108 Some(Arc::new(move || {
109 let state = state.clone();
110 let store = store.clone();
111 let lock = lock.clone();
112 Box::pin(async move {
113 crate::persistence::save_snapshot(&state, Some(store), &lock).await;
114 })
115 }))
116 }
117}
118
119fn unconditional_mutator(action: &str) -> bool {
128 matches!(
129 action,
130 "CreateCustomActionType"
131 | "CreatePipeline"
132 | "DeleteCustomActionType"
133 | "DeletePipeline"
134 | "DeleteWebhook"
135 | "DisableStageTransition"
136 | "EnableStageTransition"
137 | "PutWebhook"
138 | "StartPipelineExecution"
139 | "StopPipelineExecution"
140 | "TagResource"
141 | "UntagResource"
142 | "UpdatePipeline"
143 )
144}
145
146#[async_trait]
147impl AwsService for CodePipelineService {
148 fn service_name(&self) -> &str {
149 "codepipeline"
150 }
151
152 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
153 let action = req.action.clone();
154 let changed = std::cell::Cell::new(false);
158 let result = self.dispatch(&action, &req, &changed);
159 let should_save = matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
160 && (unconditional_mutator(&action) || changed.get());
161 if should_save {
162 self.save().await;
163 }
164 result
165 }
166
167 fn supported_actions(&self) -> &[&str] {
168 CODEPIPELINE_ACTIONS
169 }
170}
171
172impl CodePipelineService {
173 fn dispatch(
174 &self,
175 action: &str,
176 req: &AwsRequest,
177 changed: &std::cell::Cell<bool>,
178 ) -> Result<AwsResponse, AwsServiceError> {
179 match action {
180 "CreatePipeline" => self.create_pipeline(req),
181 "GetPipeline" => self.get_pipeline(req),
182 "UpdatePipeline" => self.update_pipeline(req),
183 "DeletePipeline" => self.delete_pipeline(req),
184 "ListPipelines" => self.list_pipelines(req),
185 "GetPipelineState" => self.get_pipeline_state(req, changed),
186 "GetPipelineExecution" => self.get_pipeline_execution(req, changed),
187 "ListPipelineExecutions" => self.list_pipeline_executions(req, changed),
188 "StartPipelineExecution" => self.start_pipeline_execution(req),
189 "StopPipelineExecution" => self.stop_pipeline_execution(req),
190 "ListActionExecutions" => self.list_action_executions(req),
191 "ListDeployActionExecutionTargets" => self.list_deploy_action_execution_targets(req),
192 "ListRuleExecutions" => self.list_rule_executions(req),
193 "ListRuleTypes" => self.list_rule_types(req),
194 "PutActionRevision" => self.put_action_revision(req),
195 "PutApprovalResult" => self.put_approval_result(req),
196 "EnableStageTransition" => self.enable_stage_transition(req),
197 "DisableStageTransition" => self.disable_stage_transition(req),
198 "RetryStageExecution" => self.retry_stage_execution(req),
199 "RollbackStage" => self.rollback_stage(req),
200 "OverrideStageCondition" => self.override_stage_condition(req),
201 "CreateCustomActionType" => self.create_custom_action_type(req),
202 "DeleteCustomActionType" => self.delete_custom_action_type(req),
203 "ListActionTypes" => self.list_action_types(req),
204 "GetActionType" => self.get_action_type(req),
205 "UpdateActionType" => self.update_action_type(req),
206 "PutWebhook" => self.put_webhook(req),
207 "DeleteWebhook" => self.delete_webhook(req),
208 "ListWebhooks" => self.list_webhooks(req),
209 "RegisterWebhookWithThirdParty" => self.register_webhook_with_third_party(req),
210 "DeregisterWebhookWithThirdParty" => self.deregister_webhook_with_third_party(req),
211 "AcknowledgeJob" => self.acknowledge_job(req),
212 "AcknowledgeThirdPartyJob" => self.acknowledge_third_party_job(req),
213 "GetJobDetails" => self.get_job_details(req),
214 "GetThirdPartyJobDetails" => self.get_third_party_job_details(req),
215 "PollForJobs" => self.poll_for_jobs(req),
216 "PollForThirdPartyJobs" => self.poll_for_third_party_jobs(req),
217 "PutJobSuccessResult" => self.put_job_success_result(req),
218 "PutJobFailureResult" => self.put_job_failure_result(req),
219 "PutThirdPartyJobSuccessResult" => self.put_third_party_job_success_result(req),
220 "PutThirdPartyJobFailureResult" => self.put_third_party_job_failure_result(req),
221 "TagResource" => self.tag_resource(req),
222 "UntagResource" => self.untag_resource(req),
223 "ListTagsForResource" => self.list_tags_for_resource(req),
224 _ => Err(AwsServiceError::action_not_implemented(
225 self.service_name(),
226 action,
227 )),
228 }
229 }
230}
231
232fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
237 Ok(AwsResponse::json_value(StatusCode::OK, v))
238}
239
240fn ts(dt: DateTime<Utc>) -> Value {
241 json!(dt.timestamp_millis() as f64 / 1000.0)
242}
243
244fn validation(msg: impl Into<String>) -> AwsServiceError {
247 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
248}
249
250fn err(code: &str, msg: impl Into<String>) -> AwsServiceError {
251 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg)
252}
253
254fn body(req: &AwsRequest) -> Value {
255 serde_json::from_slice(&req.body).unwrap_or(Value::Null)
256}
257
258fn str_field(b: &Value, key: &str) -> Option<String> {
259 b.get(key).and_then(|v| v.as_str()).map(str::to_string)
260}
261
262fn len_ok(v: &str, min: usize, max: usize) -> bool {
263 let n = v.chars().count();
264 n >= min && n <= max
265}
266
267fn valid_name(v: &str) -> bool {
270 len_ok(v, 1, 100)
271 && v.chars()
272 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '@' | '-' | '_'))
273}
274
275fn gen_uuid() -> String {
276 uuid::Uuid::new_v4().to_string()
277}
278
279fn req_str(b: &Value, key: &str) -> Result<String, AwsServiceError> {
282 match str_field(b, key) {
283 Some(v) if !v.is_empty() => Ok(v),
284 _ => Err(validation(format!(
285 "1 validation error detected: Value null at '{key}' failed to satisfy constraint: Member must not be null"
286 ))),
287 }
288}
289
290fn req_pipeline_name(b: &Value, key: &str) -> Result<String, AwsServiceError> {
292 let v = req_str(b, key)?;
293 if valid_name(&v) {
294 Ok(v)
295 } else {
296 Err(validation(format!(
297 "Pipeline name in member '{key}' was specified in an invalid format."
298 )))
299 }
300}
301
302fn check_len(b: &Value, key: &str, min: usize, max: usize) -> Result<(), AwsServiceError> {
304 if let Some(v) = str_field(b, key) {
305 if !len_ok(&v, min, max) {
306 return Err(validation(format!(
307 "Value '{v}' at '{key}' failed to satisfy constraint: Member must have length between {min} and {max}."
308 )));
309 }
310 }
311 Ok(())
312}
313
314fn check_int_range(
317 b: &Value,
318 key: &str,
319 min: i64,
320 max: Option<i64>,
321) -> Result<(), AwsServiceError> {
322 if let Some(v) = b.get(key).and_then(Value::as_i64) {
323 if v < min || max.map(|m| v > m).unwrap_or(false) {
324 return Err(validation(format!(
325 "Value '{v}' at '{key}' failed to satisfy constraint: Member must be within the allowed range."
326 )));
327 }
328 }
329 Ok(())
330}
331
332fn opt_enum(b: &Value, key: &str, set: &[&str]) -> Result<(), AwsServiceError> {
333 if let Some(v) = str_field(b, key) {
334 if !validate::is_enum(set, &v) {
335 return Err(validation(format!(
336 "Value '{v}' at '{key}' failed to satisfy constraint: Member must satisfy enum value set."
337 )));
338 }
339 }
340 Ok(())
341}
342
343fn is_codepipeline_arn(s: &str) -> bool {
346 let parts: Vec<&str> = s.splitn(6, ':').collect();
347 parts.len() == 6
348 && parts[0] == "arn"
349 && parts[1].starts_with("aws")
350 && parts[2] == "codepipeline"
351 && parts[4].len() == 12
352 && parts[4].chars().all(|c| c.is_ascii_digit())
353 && !parts[5].is_empty()
354}
355
356fn tag_list(b: &Value) -> Vec<Value> {
357 b.get("tags")
358 .and_then(Value::as_array)
359 .cloned()
360 .unwrap_or_default()
361}
362
363fn paginate_body(
366 items: Vec<Value>,
367 list_key: &str,
368 b: &Value,
369 token_key: &str,
370) -> Result<Value, AwsServiceError> {
371 let token = str_field(b, token_key);
372 let page_size = b
373 .get("maxResults")
374 .or_else(|| b.get("MaxResults"))
375 .and_then(Value::as_u64)
376 .filter(|n| *n > 0)
377 .map(|n| n as usize)
378 .unwrap_or(100);
379 let (page, next) = paginate_checked(&items, token.as_deref(), page_size).map_err(|_| {
380 err(
384 "InvalidNextTokenException",
385 "The next token was specified in an invalid format.",
386 )
387 })?;
388 let mut out = Map::new();
389 out.insert(list_key.to_string(), Value::Array(page));
390 if let Some(n) = next {
391 out.insert(token_key.to_string(), Value::String(n));
392 }
393 Ok(Value::Object(out))
394}
395
396impl CodePipelineService {
397 fn account(&self, req: &AwsRequest) -> String {
398 req.account_id.clone()
399 }
400
401 fn pipeline_arn(&self, req: &AwsRequest, name: &str) -> String {
402 format!(
403 "arn:aws:codepipeline:{}:{}:{}",
404 req.region, req.account_id, name
405 )
406 }
407
408 fn webhook_arn(&self, req: &AwsRequest, name: &str) -> String {
409 format!(
410 "arn:aws:codepipeline:{}:{}:webhook:{}",
411 req.region, req.account_id, name
412 )
413 }
414}
415
416fn validate_pipeline_declaration(pipeline: &Value) -> Result<String, AwsServiceError> {
422 let name = req_pipeline_name(pipeline, "name")?;
423 if pipeline.get("roleArn").and_then(Value::as_str).is_none() {
424 return Err(validation(
425 "Pipeline definition must include a roleArn.".to_string(),
426 ));
427 }
428 let has_store = pipeline
432 .get("artifactStore")
433 .map(|v| v.is_object())
434 .unwrap_or(false);
435 let has_stores = pipeline
436 .get("artifactStores")
437 .map(|v| v.is_object())
438 .unwrap_or(false);
439 if has_store == has_stores {
440 return Err(err(
441 "InvalidStructureException",
442 "Pipeline definition must include exactly one of artifactStore or artifactStores.",
443 ));
444 }
445 let stages = pipeline
446 .get("stages")
447 .and_then(Value::as_array)
448 .filter(|s| !s.is_empty());
449 let Some(stages) = stages else {
450 return Err(err(
451 "InvalidStructureException",
452 "Pipeline definition must include at least one stage.",
453 ));
454 };
455 opt_enum(pipeline, "executionMode", validate::EXECUTION_MODE)?;
456 opt_enum(pipeline, "pipelineType", validate::PIPELINE_TYPE)?;
457 for stage in stages {
460 let Some(stage_name) = stage.get("name").and_then(Value::as_str) else {
463 return Err(err(
464 "InvalidStageDeclarationException",
465 "Each stage must include a name.",
466 ));
467 };
468 if !valid_name(stage_name) {
469 return Err(err(
470 "InvalidStageDeclarationException",
471 format!("Stage name '{stage_name}' was specified in an invalid format."),
472 ));
473 }
474 let actions = stage.get("actions").and_then(Value::as_array);
475 let Some(actions) = actions.filter(|a| !a.is_empty()) else {
476 return Err(err(
477 "InvalidStageDeclarationException",
478 "Each stage must include at least one action.",
479 ));
480 };
481 for action in actions {
482 let Some(action_name) = action.get("name").and_then(Value::as_str) else {
486 return Err(err(
487 "InvalidActionDeclarationException",
488 "Each action must include a name.",
489 ));
490 };
491 if !valid_name(action_name) {
492 return Err(err(
493 "InvalidActionDeclarationException",
494 format!("Action name '{action_name}' was specified in an invalid format."),
495 ));
496 }
497 let tid = action.get("actionTypeId");
498 let Some(tid) = tid else {
499 return Err(err(
500 "InvalidActionDeclarationException",
501 "Each action must include an actionTypeId.",
502 ));
503 };
504 if let Some(cat) = tid.get("category").and_then(Value::as_str) {
505 if !validate::is_enum(validate::ACTION_CATEGORY, cat) {
506 return Err(err(
507 "InvalidActionDeclarationException",
508 "The action category is invalid.",
509 ));
510 }
511 }
512 if let Some(owner) = tid.get("owner").and_then(Value::as_str) {
513 if !validate::is_enum(validate::ACTION_OWNER, owner) {
514 return Err(err(
515 "InvalidActionDeclarationException",
516 "The action owner is invalid.",
517 ));
518 }
519 }
520 }
521 }
522 Ok(name)
523}
524
525fn apply_pipeline_defaults(pipeline: &mut Value) {
529 if let Some(stages) = pipeline.get_mut("stages").and_then(Value::as_array_mut) {
530 for stage in stages {
531 if let Some(actions) = stage.get_mut("actions").and_then(Value::as_array_mut) {
532 for action in actions {
533 if let Some(obj) = action.as_object_mut() {
534 obj.entry("runOrder").or_insert_with(|| json!(1));
535 }
536 }
537 }
538 }
539 }
540}
541
542impl CodePipelineService {
543 fn create_pipeline(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
544 let b = body(req);
545 let Some(pipeline) = b.get("pipeline").filter(|v| v.is_object()).cloned() else {
546 return Err(validation("Pipeline definition is required."));
547 };
548 let name = validate_pipeline_declaration(&pipeline)?;
549 let now = Utc::now();
550 let account = self.account(req);
551 let arn = self.pipeline_arn(req, &name);
552 let tags = tag_list(&b);
553 let mut guard = self.state.write();
554 let st = guard.get_or_create(&account);
555 if st.pipelines.contains_key(&name) {
556 return Err(err(
557 "PipelineNameInUseException",
558 format!("A pipeline with the name {name} already exists."),
559 ));
560 }
561 let mut decl = pipeline;
562 apply_pipeline_defaults(&mut decl);
563 if let Some(obj) = decl.as_object_mut() {
564 obj.insert("version".into(), json!(1));
565 }
566 st.pipelines.insert(name.clone(), decl.clone());
567 st.pipeline_order.push(name.clone());
568 st.pipeline_versions
569 .insert(name.clone(), vec![decl.clone()]);
570 st.pipeline_meta.insert(
571 name.clone(),
572 json!({ "pipelineArn": arn, "created": ts(now), "updated": ts(now) }),
573 );
574 if !tags.is_empty() {
575 st.tags.insert(arn, tags.clone());
576 }
577 drop(guard);
578 let mut out = Map::new();
579 out.insert("pipeline".into(), decl);
580 if !tags.is_empty() {
581 out.insert("tags".into(), Value::Array(tags));
582 }
583 ok(Value::Object(out))
584 }
585
586 fn get_pipeline(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
587 let b = body(req);
588 let name = req_pipeline_name(&b, "name")?;
589 let account = self.account(req);
590 let mut guard = self.state.write();
591 let st = guard.get_or_create(&account);
592 if !st.pipelines.contains_key(&name) {
593 return Err(err(
594 "PipelineNotFoundException",
595 format!("Account does not have a pipeline with name {name}."),
596 ));
597 }
598 let decl = match b.get("version").filter(|v| !v.is_null()) {
599 Some(vv) => {
600 let v = vv.as_u64().filter(|n| *n >= 1).ok_or_else(|| {
604 validation(
605 "Value at 'version' failed to satisfy constraint: Member must be a positive integer.",
606 )
607 })?;
608 let versions = st.pipeline_versions.get(&name);
609 match versions.and_then(|vs| vs.get((v as usize) - 1)) {
610 Some(d) => d.clone(),
611 None => {
612 return Err(err(
613 "PipelineVersionNotFoundException",
614 format!("Pipeline {name} does not have a version {v}."),
615 ))
616 }
617 }
618 }
619 None => st.pipelines.get(&name).cloned().unwrap(),
620 };
621 let meta = st
622 .pipeline_meta
623 .get(&name)
624 .cloned()
625 .unwrap_or_else(|| json!({ "pipelineArn": self.pipeline_arn(req, &name) }));
626 ok(json!({ "pipeline": decl, "metadata": meta }))
627 }
628
629 fn update_pipeline(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
630 let b = body(req);
631 let Some(pipeline) = b.get("pipeline").filter(|v| v.is_object()).cloned() else {
632 return Err(validation("Pipeline definition is required."));
633 };
634 let name = validate_pipeline_declaration(&pipeline)?;
635 let now = Utc::now();
636 let account = self.account(req);
637 let mut guard = self.state.write();
638 let st = guard.get_or_create(&account);
639 if !st.pipelines.contains_key(&name) {
642 return Err(validation(format!(
643 "The pipeline with the name {name} does not exist."
644 )));
645 }
646 let next_version = st
647 .pipeline_versions
648 .get(&name)
649 .map(|v| v.len() as u64 + 1)
650 .unwrap_or(1);
651 let mut decl = pipeline;
652 apply_pipeline_defaults(&mut decl);
653 if let Some(obj) = decl.as_object_mut() {
654 obj.insert("version".into(), json!(next_version));
655 }
656 st.pipelines.insert(name.clone(), decl.clone());
657 st.pipeline_versions
658 .entry(name.clone())
659 .or_default()
660 .push(decl.clone());
661 if let Some(meta) = st.pipeline_meta.get_mut(&name) {
662 if let Some(obj) = meta.as_object_mut() {
663 obj.insert("updated".into(), ts(now));
664 }
665 }
666 ok(json!({ "pipeline": decl }))
667 }
668
669 fn delete_pipeline(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
670 let b = body(req);
671 let name = req_pipeline_name(&b, "name")?;
672 let account = self.account(req);
673 let arn = self.pipeline_arn(req, &name);
674 let mut guard = self.state.write();
675 let st = guard.get_or_create(&account);
676 st.pipelines.remove(&name);
678 st.pipeline_order.retain(|n| n != &name);
679 st.pipeline_meta.remove(&name);
680 st.pipeline_versions.remove(&name);
681 if let Some(ids) = st.execution_order.remove(&name) {
682 for id in ids {
683 st.executions.remove(&id);
684 }
685 }
686 st.transitions_disabled.remove(&name);
687 st.tags.remove(&arn);
688 ok(json!({}))
689 }
690
691 fn list_pipelines(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
692 let b = body(req);
693 check_int_range(&b, "maxResults", 1, Some(1000))?;
694 let account = self.account(req);
695 let mut guard = self.state.write();
696 let st = guard.get_or_create(&account);
697 let summaries: Vec<Value> = st
698 .pipeline_order
699 .iter()
700 .filter_map(|n| {
701 let decl = st.pipelines.get(n)?;
702 let meta = st.pipeline_meta.get(n);
703 let mut s = Map::new();
704 s.insert("name".into(), json!(n));
705 if let Some(v) = decl.get("version") {
706 s.insert("version".into(), v.clone());
707 }
708 for k in ["pipelineType", "executionMode"] {
709 if let Some(v) = decl.get(k) {
710 s.insert(k.into(), v.clone());
711 }
712 }
713 if let Some(m) = meta {
714 for k in ["created", "updated"] {
715 if let Some(v) = m.get(k) {
716 s.insert(k.into(), v.clone());
717 }
718 }
719 }
720 Some(Value::Object(s))
721 })
722 .collect();
723 ok(paginate_body(summaries, "pipelines", &b, "nextToken")?)
724 }
725}
726
727const TERMINAL_EXECUTION_STATUSES: &[&str] =
734 &["Succeeded", "Failed", "Stopped", "Superseded", "Cancelled"];
735
736fn settle_execution(exec: &mut Value) -> bool {
742 let status = exec
743 .get("status")
744 .and_then(Value::as_str)
745 .unwrap_or("InProgress");
746 let next = match status {
747 s if TERMINAL_EXECUTION_STATUSES.contains(&s) => return false,
748 "Stopping" => "Stopped",
749 _ => "Succeeded",
750 };
751 if let Some(obj) = exec.as_object_mut() {
752 obj.insert("status".into(), json!(next));
753 obj.insert("lastUpdateTime".into(), ts(Utc::now()));
754 true
755 } else {
756 false
757 }
758}
759
760fn settle_pipeline_executions(
769 executions: &mut std::collections::BTreeMap<String, Value>,
770 order: &[String],
771 mode: &str,
772) -> bool {
773 let mut changed = false;
774 if mode == "SUPERSEDED" {
775 let in_flight: Vec<String> = order
778 .iter()
779 .filter(|id| {
780 executions
781 .get(*id)
782 .and_then(|e| e.get("status").and_then(Value::as_str))
783 .map(|s| !TERMINAL_EXECUTION_STATUSES.contains(&s))
784 .unwrap_or(false)
785 })
786 .cloned()
787 .collect();
788 if let Some((newest, older)) = in_flight.split_last() {
789 for id in older {
790 if let Some(exec) = executions.get_mut(id) {
791 let is_in_progress =
795 exec.get("status").and_then(Value::as_str) == Some("InProgress");
796 if is_in_progress {
797 if let Some(obj) = exec.as_object_mut() {
798 obj.insert("status".into(), json!("Superseded"));
799 obj.insert("lastUpdateTime".into(), ts(Utc::now()));
800 changed = true;
801 }
802 } else {
803 changed |= settle_execution(exec);
804 }
805 }
806 }
807 if let Some(exec) = executions.get_mut(newest) {
808 changed |= settle_execution(exec);
809 }
810 }
811 } else {
812 for id in order {
813 if let Some(exec) = executions.get_mut(id) {
814 changed |= settle_execution(exec);
815 }
816 }
817 }
818 changed
819}
820
821impl CodePipelineService {
822 fn execution_mode(&self, st: &crate::state::CodePipelineState, name: &str) -> String {
825 st.pipelines
826 .get(name)
827 .and_then(|d| d.get("executionMode").and_then(Value::as_str))
828 .unwrap_or("SUPERSEDED")
829 .to_string()
830 }
831
832 fn start_pipeline_execution(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
833 let b = body(req);
834 let name = req_pipeline_name(&b, "name")?;
835 let account = self.account(req);
836 let now = Utc::now();
837 let exec_id = gen_uuid();
838 let mut guard = self.state.write();
839 let st = guard.get_or_create(&account);
840 let Some(decl) = st.pipelines.get(&name) else {
841 return Err(err(
842 "PipelineNotFoundException",
843 format!("Account does not have a pipeline with name {name}."),
844 ));
845 };
846 let version = decl.get("version").cloned().unwrap_or(json!(1));
847 let mode = decl
848 .get("executionMode")
849 .cloned()
850 .unwrap_or_else(|| json!("SUPERSEDED"));
851 let exec = json!({
852 "pipelineName": name,
853 "pipelineVersion": version,
854 "pipelineExecutionId": exec_id,
855 "status": "InProgress",
856 "executionMode": mode,
857 "executionType": "STANDARD",
858 "startTime": ts(now),
859 "lastUpdateTime": ts(now),
860 "trigger": { "triggerType": "StartPipelineExecution", "triggerDetail": "user" },
861 });
862 st.executions.insert(exec_id.clone(), exec);
863 st.execution_order
864 .entry(name)
865 .or_default()
866 .push(exec_id.clone());
867 ok(json!({ "pipelineExecutionId": exec_id }))
868 }
869
870 fn get_pipeline_execution(
871 &self,
872 req: &AwsRequest,
873 changed: &std::cell::Cell<bool>,
874 ) -> Result<AwsResponse, AwsServiceError> {
875 let b = body(req);
876 let name = req_pipeline_name(&b, "pipelineName")?;
877 let exec_id = req_str(&b, "pipelineExecutionId")?;
878 let account = self.account(req);
879 let mut guard = self.state.write();
880 let st = guard.get_or_create(&account);
881 if !st.pipelines.contains_key(&name) {
882 return Err(err(
883 "PipelineNotFoundException",
884 format!("Account does not have a pipeline with name {name}."),
885 ));
886 }
887 let belongs = st
888 .execution_order
889 .get(&name)
890 .map(|ids| ids.iter().any(|i| i == &exec_id))
891 .unwrap_or(false);
892 if !belongs || !st.executions.contains_key(&exec_id) {
893 return Err(err(
894 "PipelineExecutionNotFoundException",
895 format!("Pipeline execution {exec_id} not found for pipeline {name}."),
896 ));
897 }
898 let mode = self.execution_mode(st, &name);
899 let order = st.execution_order.get(&name).cloned().unwrap_or_default();
900 if settle_pipeline_executions(&mut st.executions, &order, &mode) {
901 changed.set(true);
902 }
903 let exec = st.executions.get(&exec_id).cloned().unwrap();
904 let mut pe = Map::new();
906 for k in [
907 "pipelineName",
908 "pipelineVersion",
909 "pipelineExecutionId",
910 "status",
911 "statusSummary",
912 "executionMode",
913 "executionType",
914 "trigger",
915 ] {
916 if let Some(v) = exec.get(k) {
917 pe.insert(k.into(), v.clone());
918 }
919 }
920 ok(json!({ "pipelineExecution": Value::Object(pe) }))
921 }
922
923 fn list_pipeline_executions(
924 &self,
925 req: &AwsRequest,
926 changed: &std::cell::Cell<bool>,
927 ) -> Result<AwsResponse, AwsServiceError> {
928 let b = body(req);
929 let name = req_pipeline_name(&b, "pipelineName")?;
930 check_int_range(&b, "maxResults", 1, Some(100))?;
932 let account = self.account(req);
933 let mut guard = self.state.write();
934 let st = guard.get_or_create(&account);
935 if !st.pipelines.contains_key(&name) {
936 return Err(err(
937 "PipelineNotFoundException",
938 format!("Account does not have a pipeline with name {name}."),
939 ));
940 }
941 let mode = self.execution_mode(st, &name);
942 let order = st.execution_order.get(&name).cloned().unwrap_or_default();
943 if settle_pipeline_executions(&mut st.executions, &order, &mode) {
944 changed.set(true);
945 }
946 let summaries: Vec<Value> = order
948 .iter()
949 .rev()
950 .filter_map(|id| st.executions.get(id))
951 .map(|exec| {
952 let mut s = Map::new();
953 for k in [
954 "pipelineExecutionId",
955 "status",
956 "statusSummary",
957 "startTime",
958 "lastUpdateTime",
959 "executionMode",
960 "executionType",
961 "trigger",
962 ] {
963 if let Some(v) = exec.get(k) {
964 s.insert(k.into(), v.clone());
965 }
966 }
967 Value::Object(s)
968 })
969 .collect();
970 ok(paginate_body(
971 summaries,
972 "pipelineExecutionSummaries",
973 &b,
974 "nextToken",
975 )?)
976 }
977
978 fn stop_pipeline_execution(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
979 let b = body(req);
980 let name = req_pipeline_name(&b, "pipelineName")?;
981 let exec_id = req_str(&b, "pipelineExecutionId")?;
982 let abandon = b.get("abandon").and_then(Value::as_bool).unwrap_or(false);
983 let account = self.account(req);
984 let mut guard = self.state.write();
985 let st = guard.get_or_create(&account);
986 if !st.pipelines.contains_key(&name) {
987 return Err(err(
988 "PipelineNotFoundException",
989 format!("Account does not have a pipeline with name {name}."),
990 ));
991 }
992 let belongs = st
993 .execution_order
994 .get(&name)
995 .map(|ids| ids.iter().any(|i| i == &exec_id))
996 .unwrap_or(false);
997 let mut exec = st.executions.get(&exec_id).cloned();
998 let status = exec
999 .as_ref()
1000 .and_then(|e| e.get("status").and_then(Value::as_str))
1001 .unwrap_or("");
1002 if belongs && exec.is_some() && status == "Stopping" {
1007 return Err(err(
1008 "DuplicatedStopRequestException",
1009 format!("Pipeline execution {exec_id} is already being stopped."),
1010 ));
1011 }
1012 if !belongs || exec.is_none() || status != "InProgress" {
1013 return Err(err(
1014 "PipelineExecutionNotStoppableException",
1015 format!("Pipeline execution {exec_id} is not in a stoppable state."),
1016 ));
1017 }
1018 if let Some(e) = exec.as_mut() {
1019 if let Some(obj) = e.as_object_mut() {
1020 obj.insert(
1021 "status".into(),
1022 json!(if abandon { "Stopped" } else { "Stopping" }),
1023 );
1024 obj.insert("lastUpdateTime".into(), ts(Utc::now()));
1025 if let Some(reason) = str_field(&b, "reason") {
1026 obj.insert("statusSummary".into(), json!(reason));
1027 }
1028 }
1029 st.executions.insert(exec_id.clone(), e.clone());
1030 }
1031 ok(json!({ "pipelineExecutionId": exec_id }))
1032 }
1033
1034 fn get_pipeline_state(
1035 &self,
1036 req: &AwsRequest,
1037 changed: &std::cell::Cell<bool>,
1038 ) -> Result<AwsResponse, AwsServiceError> {
1039 let b = body(req);
1040 let name = req_pipeline_name(&b, "name")?;
1041 let account = self.account(req);
1042 let mut guard = self.state.write();
1043 let st = guard.get_or_create(&account);
1044 let Some(decl) = st.pipelines.get(&name).cloned() else {
1045 return Err(err(
1046 "PipelineNotFoundException",
1047 format!("Account does not have a pipeline with name {name}."),
1048 ));
1049 };
1050 let version = decl.get("version").cloned().unwrap_or(json!(1));
1051 let mode = self.execution_mode(st, &name);
1054 let order = st.execution_order.get(&name).cloned().unwrap_or_default();
1055 if settle_pipeline_executions(&mut st.executions, &order, &mode) {
1056 changed.set(true);
1057 }
1058 let latest_id = order.last().cloned();
1059 let latest_status = latest_id
1060 .as_ref()
1061 .and_then(|id| st.executions.get(id))
1062 .and_then(|e| e.get("status").and_then(Value::as_str))
1063 .map(str::to_string);
1064 let disabled = st.transitions_disabled.get(&name).cloned();
1065 let meta = st.pipeline_meta.get(&name).cloned();
1066 let stages = decl.get("stages").and_then(Value::as_array).cloned();
1067 let stage_states: Vec<Value> = stages
1068 .unwrap_or_default()
1069 .iter()
1070 .map(|stage| {
1071 let stage_name = stage.get("name").cloned().unwrap_or(json!(""));
1072 let action_states: Vec<Value> = stage
1073 .get("actions")
1074 .and_then(Value::as_array)
1075 .map(|acts| {
1076 acts.iter()
1077 .filter_map(|a| a.get("name").map(|n| json!({ "actionName": n })))
1078 .collect()
1079 })
1080 .unwrap_or_default();
1081 let mut ss = Map::new();
1082 ss.insert("stageName".into(), stage_name.clone());
1083 ss.insert("actionStates".into(), Value::Array(action_states));
1084 let stage_key = format!("{}/Inbound", stage_name.as_str().unwrap_or(""));
1087 let transition_state = disabled
1088 .as_ref()
1089 .and_then(|d| d.get(&stage_key).cloned())
1090 .unwrap_or_else(|| json!({ "enabled": true }));
1091 ss.insert("inboundTransitionState".into(), transition_state);
1092 if let (Some(id), Some(status)) = (&latest_id, &latest_status) {
1093 ss.insert(
1094 "latestExecution".into(),
1095 json!({ "pipelineExecutionId": id, "status": stage_status(status) }),
1096 );
1097 }
1098 Value::Object(ss)
1099 })
1100 .collect();
1101 let mut out = Map::new();
1102 out.insert("pipelineName".into(), json!(name));
1103 out.insert("pipelineVersion".into(), version);
1104 out.insert("stageStates".into(), Value::Array(stage_states));
1105 if let Some(m) = meta {
1106 for k in ["created", "updated"] {
1107 if let Some(v) = m.get(k) {
1108 out.insert(k.into(), v.clone());
1109 }
1110 }
1111 }
1112 ok(Value::Object(out))
1113 }
1114
1115 fn list_action_executions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1116 let b = body(req);
1117 let name = req_pipeline_name(&b, "pipelineName")?;
1118 check_int_range(&b, "maxResults", 1, Some(100))?;
1121 let account = self.account(req);
1122 let mut guard = self.state.write();
1123 let st = guard.get_or_create(&account);
1124 if !st.pipelines.contains_key(&name) {
1125 return Err(err(
1126 "PipelineNotFoundException",
1127 format!("Account does not have a pipeline with name {name}."),
1128 ));
1129 }
1130 ok(paginate_body(
1131 Vec::new(),
1132 "actionExecutionDetails",
1133 &b,
1134 "nextToken",
1135 )?)
1136 }
1137
1138 fn list_deploy_action_execution_targets(
1139 &self,
1140 req: &AwsRequest,
1141 ) -> Result<AwsResponse, AwsServiceError> {
1142 let b = body(req);
1143 let exec_id = req_str(&b, "actionExecutionId")?;
1144 let account = self.account(req);
1145 let mut guard = self.state.write();
1146 let st = guard.get_or_create(&account);
1147 if let Some(name) = str_field(&b, "pipelineName") {
1148 if !st.pipelines.contains_key(&name) {
1149 return Err(err(
1150 "PipelineNotFoundException",
1151 format!("Account does not have a pipeline with name {name}."),
1152 ));
1153 }
1154 }
1155 Err(err(
1157 "ActionExecutionNotFoundException",
1158 format!("Action execution {exec_id} was not found."),
1159 ))
1160 }
1161
1162 fn list_rule_executions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1163 let b = body(req);
1164 let name = req_pipeline_name(&b, "pipelineName")?;
1165 check_int_range(&b, "maxResults", 1, Some(100))?;
1168 let account = self.account(req);
1169 let mut guard = self.state.write();
1170 let st = guard.get_or_create(&account);
1171 if !st.pipelines.contains_key(&name) {
1172 return Err(err(
1173 "PipelineNotFoundException",
1174 format!("Account does not have a pipeline with name {name}."),
1175 ));
1176 }
1177 ok(paginate_body(
1178 Vec::new(),
1179 "ruleExecutionDetails",
1180 &b,
1181 "nextToken",
1182 )?)
1183 }
1184}
1185
1186fn pipeline_has_stage(decl: &Value, stage_name: &str) -> bool {
1189 decl.get("stages")
1190 .and_then(Value::as_array)
1191 .map(|stages| {
1192 stages
1193 .iter()
1194 .any(|s| s.get("name").and_then(Value::as_str) == Some(stage_name))
1195 })
1196 .unwrap_or(false)
1197}
1198
1199fn stage_status(pipeline_status: &str) -> &str {
1201 match pipeline_status {
1202 "Succeeded" => "Succeeded",
1203 "Failed" => "Failed",
1204 "Stopped" => "Stopped",
1205 "Stopping" => "Stopping",
1206 "Cancelled" | "Superseded" => "Cancelled",
1207 _ => "InProgress",
1208 }
1209}
1210
1211impl CodePipelineService {
1216 fn pipeline_must_exist(&self, req: &AwsRequest, name: &str) -> Result<(), AwsServiceError> {
1217 let account = self.account(req);
1218 let mut guard = self.state.write();
1219 let st = guard.get_or_create(&account);
1220 if st.pipelines.contains_key(name) {
1221 Ok(())
1222 } else {
1223 Err(err(
1224 "PipelineNotFoundException",
1225 format!("Account does not have a pipeline with name {name}."),
1226 ))
1227 }
1228 }
1229
1230 fn enable_stage_transition(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1231 let b = body(req);
1232 let name = req_pipeline_name(&b, "pipelineName")?;
1233 let stage_name = req_str(&b, "stageName")?;
1234 opt_enum(&b, "transitionType", validate::STAGE_TRANSITION_TYPE)?;
1235 let Some(transition_type) = str_field(&b, "transitionType") else {
1236 return Err(validation("transitionType is required."));
1237 };
1238 let account = self.account(req);
1239 let mut guard = self.state.write();
1240 let st = guard.get_or_create(&account);
1241 let Some(decl) = st.pipelines.get(&name) else {
1242 return Err(err(
1243 "PipelineNotFoundException",
1244 format!("Account does not have a pipeline with name {name}."),
1245 ));
1246 };
1247 if !pipeline_has_stage(decl, &stage_name) {
1249 return Err(err(
1250 "StageNotFoundException",
1251 format!("Stage {stage_name} was not found in the pipeline {name}."),
1252 ));
1253 }
1254 let key = format!("{stage_name}/{transition_type}");
1256 if let Some(map) = st.transitions_disabled.get_mut(&name) {
1257 map.remove(&key);
1258 if map.is_empty() {
1259 st.transitions_disabled.remove(&name);
1260 }
1261 }
1262 ok(json!({}))
1263 }
1264
1265 fn disable_stage_transition(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1266 let b = body(req);
1267 let name = req_pipeline_name(&b, "pipelineName")?;
1268 let stage_name = req_str(&b, "stageName")?;
1269 let reason = req_str(&b, "reason")?;
1270 opt_enum(&b, "transitionType", validate::STAGE_TRANSITION_TYPE)?;
1271 let Some(transition_type) = str_field(&b, "transitionType") else {
1272 return Err(validation("transitionType is required."));
1273 };
1274 let account = self.account(req);
1275 let mut guard = self.state.write();
1276 let st = guard.get_or_create(&account);
1277 let Some(decl) = st.pipelines.get(&name) else {
1278 return Err(err(
1279 "PipelineNotFoundException",
1280 format!("Account does not have a pipeline with name {name}."),
1281 ));
1282 };
1283 if !pipeline_has_stage(decl, &stage_name) {
1286 return Err(err(
1287 "StageNotFoundException",
1288 format!("Stage {stage_name} was not found in the pipeline {name}."),
1289 ));
1290 }
1291 let key = format!("{stage_name}/{transition_type}");
1292 st.transitions_disabled.entry(name).or_default().insert(
1293 key,
1294 json!({
1295 "enabled": false,
1296 "disabledReason": reason,
1297 "lastChangedBy": req.account_id,
1298 "lastChangedAt": ts(Utc::now()),
1299 }),
1300 );
1301 ok(json!({}))
1302 }
1303
1304 fn retry_stage_execution(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1305 let b = body(req);
1306 let name = req_pipeline_name(&b, "pipelineName")?;
1307 req_str(&b, "stageName")?;
1308 req_str(&b, "pipelineExecutionId")?;
1309 opt_enum(&b, "retryMode", validate::STAGE_RETRY_MODE)?;
1310 self.pipeline_must_exist(req, &name)?;
1311 Err(err(
1313 "StageNotRetryableException",
1314 "The stage has no failed actions to retry.",
1315 ))
1316 }
1317
1318 fn rollback_stage(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1319 let b = body(req);
1320 let name = req_pipeline_name(&b, "pipelineName")?;
1321 req_str(&b, "stageName")?;
1322 req_str(&b, "targetPipelineExecutionId")?;
1323 self.pipeline_must_exist(req, &name)?;
1324 Err(err(
1325 "PipelineExecutionNotFoundException",
1326 "The target pipeline execution was not found.",
1327 ))
1328 }
1329
1330 fn override_stage_condition(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1331 let b = body(req);
1332 let name = req_pipeline_name(&b, "pipelineName")?;
1333 req_str(&b, "stageName")?;
1334 req_str(&b, "pipelineExecutionId")?;
1335 opt_enum(&b, "conditionType", validate::CONDITION_TYPE)?;
1336 if str_field(&b, "conditionType").is_none() {
1337 return Err(validation("conditionType is required."));
1338 }
1339 self.pipeline_must_exist(req, &name)?;
1340 Err(err(
1341 "StageNotFoundException",
1342 "The stage was not found for the pipeline.",
1343 ))
1344 }
1345
1346 fn put_action_revision(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1347 let b = body(req);
1348 let name = req_pipeline_name(&b, "pipelineName")?;
1349 req_str(&b, "stageName")?;
1350 req_str(&b, "actionName")?;
1351 if !b
1352 .get("actionRevision")
1353 .map(Value::is_object)
1354 .unwrap_or(false)
1355 {
1356 return Err(validation("actionRevision is required."));
1357 }
1358 self.pipeline_must_exist(req, &name)?;
1359 Err(err(
1360 "StageNotFoundException",
1361 "The stage was not found for the pipeline.",
1362 ))
1363 }
1364
1365 fn put_approval_result(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1366 let b = body(req);
1367 let name = req_pipeline_name(&b, "pipelineName")?;
1368 req_str(&b, "stageName")?;
1369 req_str(&b, "actionName")?;
1370 req_str(&b, "token")?;
1371 let result = b.get("result").filter(|v| v.is_object());
1372 let Some(result) = result else {
1373 return Err(validation("result is required."));
1374 };
1375 if let Some(status) = result.get("status").and_then(Value::as_str) {
1376 if !validate::is_enum(validate::APPROVAL_STATUS, status) {
1377 return Err(validation("The approval status is invalid."));
1378 }
1379 }
1380 self.pipeline_must_exist(req, &name)?;
1381 Err(err(
1383 "ActionNotFoundException",
1384 "The approval action was not found for the pipeline.",
1385 ))
1386 }
1387}
1388
1389fn action_key(category: &str, provider: &str, version: &str) -> String {
1394 format!("{category}:{provider}:{version}")
1395}
1396
1397fn aws_owned_action_types() -> Vec<Value> {
1404 const CATALOG: &[AwsActionType] = &[
1406 ("Source", "S3", 0, 0, 1, 1),
1407 ("Source", "CodeCommit", 0, 0, 1, 1),
1408 ("Source", "ECR", 0, 0, 1, 1),
1409 ("Source", "CodeStarSourceConnection", 0, 0, 1, 1),
1410 ("Build", "CodeBuild", 1, 5, 0, 5),
1411 ("Build", "CodeBuildBatch", 1, 5, 0, 5),
1412 ("Test", "CodeBuild", 1, 5, 0, 5),
1413 ("Test", "DeviceFarm", 1, 1, 0, 0),
1414 ("Deploy", "CodeDeploy", 1, 1, 0, 0),
1415 ("Deploy", "CloudFormation", 0, 10, 0, 1),
1416 ("Deploy", "ECS", 1, 1, 0, 0),
1417 ("Deploy", "S3", 1, 1, 0, 0),
1418 ("Deploy", "ElasticBeanstalk", 1, 1, 0, 0),
1419 ("Deploy", "CodeDeployToECS", 1, 3, 0, 0),
1420 ("Deploy", "ServiceCatalog", 1, 1, 0, 0),
1421 ("Deploy", "CloudFormationStackSet", 0, 10, 0, 1),
1422 ("Deploy", "CloudFormationStackInstances", 0, 10, 0, 1),
1423 ("Deploy", "AppConfig", 1, 1, 0, 0),
1424 ("Approval", "Manual", 0, 0, 0, 0),
1425 ("Invoke", "Lambda", 0, 5, 0, 5),
1426 ("Invoke", "StepFunctions", 0, 5, 0, 5),
1427 ];
1428 CATALOG
1429 .iter()
1430 .map(|(category, provider, imin, imax, omin, omax)| {
1431 json!({
1432 "id": {
1433 "category": category,
1434 "owner": "AWS",
1435 "provider": provider,
1436 "version": "1",
1437 },
1438 "inputArtifactDetails": { "minimumCount": imin, "maximumCount": imax },
1439 "outputArtifactDetails": { "minimumCount": omin, "maximumCount": omax },
1440 })
1441 })
1442 .collect()
1443}
1444
1445type AwsActionType = (&'static str, &'static str, i64, i64, i64, i64);
1448
1449impl CodePipelineService {
1450 fn create_custom_action_type(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1451 let b = body(req);
1452 let category = req_str(&b, "category")?;
1453 if !validate::is_enum(validate::ACTION_CATEGORY, &category) {
1454 return Err(validation("The action category is invalid."));
1455 }
1456 check_len(&b, "provider", 1, 35)?;
1457 check_len(&b, "version", 1, 9)?;
1458 let provider = req_str(&b, "provider")?;
1459 let version = req_str(&b, "version")?;
1460 let input_details = b
1461 .get("inputArtifactDetails")
1462 .filter(|v| v.is_object())
1463 .cloned();
1464 let output_details = b
1465 .get("outputArtifactDetails")
1466 .filter(|v| v.is_object())
1467 .cloned();
1468 let (Some(input_details), Some(output_details)) = (input_details, output_details) else {
1469 return Err(validation(
1470 "inputArtifactDetails and outputArtifactDetails are required.",
1471 ));
1472 };
1473 let account = self.account(req);
1474 let tags = tag_list(&b);
1475 let key = action_key(&category, &provider, &version);
1476 let arn = format!(
1477 "arn:aws:codepipeline:{}:{}:actiontype:Custom/{}/{}/{}",
1478 req.region, req.account_id, category, provider, version
1479 );
1480 let mut action_type = Map::new();
1481 action_type.insert(
1482 "id".into(),
1483 json!({ "category": category, "owner": "Custom", "provider": provider, "version": version }),
1484 );
1485 if let Some(s) = b.get("settings").filter(|v| v.is_object()) {
1486 action_type.insert("settings".into(), s.clone());
1487 }
1488 if let Some(p) = b.get("configurationProperties").filter(|v| v.is_array()) {
1489 action_type.insert("actionConfigurationProperties".into(), p.clone());
1490 }
1491 action_type.insert("inputArtifactDetails".into(), input_details);
1492 action_type.insert("outputArtifactDetails".into(), output_details);
1493 let action_type = Value::Object(action_type);
1494 let mut guard = self.state.write();
1495 let st = guard.get_or_create(&account);
1496 if !st.custom_actions.contains_key(&key) {
1497 st.custom_action_order.push(key.clone());
1498 }
1499 st.custom_actions.insert(key, action_type.clone());
1500 if !tags.is_empty() {
1501 st.tags.insert(arn, tags.clone());
1502 }
1503 let mut out = Map::new();
1504 out.insert("actionType".into(), action_type);
1505 if !tags.is_empty() {
1506 out.insert("tags".into(), Value::Array(tags));
1507 }
1508 ok(Value::Object(out))
1509 }
1510
1511 fn delete_custom_action_type(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1512 let b = body(req);
1513 let category = req_str(&b, "category")?;
1514 if !validate::is_enum(validate::ACTION_CATEGORY, &category) {
1515 return Err(validation("The action category is invalid."));
1516 }
1517 check_len(&b, "provider", 1, 35)?;
1518 check_len(&b, "version", 1, 9)?;
1519 let provider = req_str(&b, "provider")?;
1520 let version = req_str(&b, "version")?;
1521 let account = self.account(req);
1522 let key = action_key(&category, &provider, &version);
1523 let arn = format!(
1524 "arn:aws:codepipeline:{}:{}:actiontype:Custom/{}/{}/{}",
1525 req.region, req.account_id, category, provider, version
1526 );
1527 let mut guard = self.state.write();
1528 let st = guard.get_or_create(&account);
1529 if st.custom_actions.remove(&key).is_some() {
1531 st.custom_action_order.retain(|k| k != &key);
1532 }
1533 st.tags.remove(&arn);
1534 ok(json!({}))
1535 }
1536
1537 fn list_action_types(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1538 let b = body(req);
1539 opt_enum(&b, "actionOwnerFilter", validate::ACTION_TYPE_OWNER)?;
1540 check_len(&b, "regionFilter", 4, 30)?;
1541 let account = self.account(req);
1542 let mut guard = self.state.write();
1543 let st = guard.get_or_create(&account);
1544 let owner_filter = str_field(&b, "actionOwnerFilter");
1545 let want = |owner: &str| owner_filter.as_deref().map(|o| o == owner).unwrap_or(true);
1546 let mut action_types: Vec<Value> = Vec::new();
1547 if want("AWS") {
1549 action_types.extend(aws_owned_action_types());
1550 }
1551 if want("Custom") {
1553 action_types.extend(
1554 st.custom_action_order
1555 .iter()
1556 .filter_map(|k| st.custom_actions.get(k).cloned()),
1557 );
1558 }
1559 ok(paginate_body(action_types, "actionTypes", &b, "nextToken")?)
1561 }
1562
1563 fn get_action_type(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1564 let b = body(req);
1565 let category = req_str(&b, "category")?;
1566 if !validate::is_enum(validate::ACTION_CATEGORY, &category) {
1567 return Err(validation("The action category is invalid."));
1568 }
1569 let owner = req_str(&b, "owner")?;
1570 if !validate::is_enum(validate::ACTION_TYPE_OWNER, &owner) {
1571 return Err(validation("The action owner is invalid."));
1572 }
1573 let provider = req_str(&b, "provider")?;
1574 let version = req_str(&b, "version")?;
1575 Err(err(
1579 "ActionTypeNotFoundException",
1580 format!("The action type {owner}/{category}/{provider}/{version} was not found."),
1581 ))
1582 }
1583
1584 fn update_action_type(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1585 let b = body(req);
1586 let at = b.get("actionType").filter(|v| v.is_object());
1587 let Some(at) = at else {
1588 return Err(validation("actionType is required."));
1589 };
1590 if !at.get("id").map(Value::is_object).unwrap_or(false) {
1591 return Err(validation("actionType.id is required."));
1592 }
1593 Err(err(
1594 "ActionTypeNotFoundException",
1595 "The action type was not found.",
1596 ))
1597 }
1598}
1599
1600impl CodePipelineService {
1605 fn put_webhook(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1606 let b = body(req);
1607 let webhook = b.get("webhook").filter(|v| v.is_object());
1608 let Some(webhook) = webhook.cloned() else {
1609 return Err(validation("webhook is required."));
1610 };
1611 let name = req_pipeline_name(&webhook, "name")?;
1612 let target_pipeline = req_str(&webhook, "targetPipeline")?;
1613 req_str(&webhook, "targetAction")?;
1614 if !webhook.get("filters").map(Value::is_array).unwrap_or(false) {
1615 return Err(validation("webhook.filters is required."));
1616 }
1617 let auth = webhook.get("authentication").and_then(Value::as_str);
1618 let Some(auth) = auth else {
1619 return Err(validation("webhook.authentication is required."));
1620 };
1621 if !matches!(auth, "GITHUB_HMAC" | "IP" | "UNAUTHENTICATED") {
1622 return Err(validation("The webhook authentication type is invalid."));
1623 }
1624 let account = self.account(req);
1625 let arn = self.webhook_arn(req, &name);
1626 let url = format!(
1627 "https://webhooks.{}.amazonaws.com/trigger?Component=1&webhookName={}",
1628 req.region, name
1629 );
1630 let tags = tag_list(&b);
1631 let mut guard = self.state.write();
1632 let st = guard.get_or_create(&account);
1633 if !st.pipelines.contains_key(&target_pipeline) {
1634 return Err(err(
1635 "PipelineNotFoundException",
1636 format!("Account does not have a pipeline with name {target_pipeline}."),
1637 ));
1638 }
1639 let mut item = Map::new();
1642 item.insert("definition".into(), webhook);
1643 item.insert("url".into(), json!(url));
1644 item.insert("arn".into(), json!(arn.clone()));
1645 let item = Value::Object(item);
1646 if !st.webhooks.contains_key(&name) {
1647 st.webhook_order.push(name.clone());
1648 }
1649 st.webhooks.insert(name, item.clone());
1650 if !tags.is_empty() {
1651 st.tags.insert(arn.clone(), tags);
1652 }
1653 let mut resp = item;
1655 if let Some(current) = st.tags.get(&arn).filter(|t| !t.is_empty()) {
1656 if let Some(obj) = resp.as_object_mut() {
1657 obj.insert("tags".into(), Value::Array(current.clone()));
1658 }
1659 }
1660 ok(json!({ "webhook": resp }))
1661 }
1662
1663 fn delete_webhook(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1664 let b = body(req);
1665 check_len(&b, "name", 1, 100)?;
1666 let name = req_str(&b, "name")?;
1667 let account = self.account(req);
1668 let arn = self.webhook_arn(req, &name);
1669 let mut guard = self.state.write();
1670 let st = guard.get_or_create(&account);
1671 if st.webhooks.remove(&name).is_some() {
1672 st.webhook_order.retain(|n| n != &name);
1673 }
1674 st.tags.remove(&arn);
1675 ok(json!({}))
1676 }
1677
1678 fn list_webhooks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1679 let b = body(req);
1680 check_int_range(&b, "MaxResults", 1, Some(100))?;
1681 let account = self.account(req);
1682 let mut guard = self.state.write();
1683 let st = guard.get_or_create(&account);
1684 let items: Vec<Value> = st
1687 .webhook_order
1688 .iter()
1689 .filter_map(|n| {
1690 let mut item = st.webhooks.get(n)?.clone();
1691 let arn = item.get("arn").and_then(Value::as_str).map(str::to_string);
1692 if let Some(arn) = arn {
1693 if let Some(current) = st.tags.get(&arn).filter(|t| !t.is_empty()) {
1694 if let Some(obj) = item.as_object_mut() {
1695 obj.insert("tags".into(), Value::Array(current.clone()));
1696 }
1697 }
1698 }
1699 Some(item)
1700 })
1701 .collect();
1702 ok(paginate_body(items, "webhooks", &b, "NextToken")?)
1703 }
1704
1705 fn register_webhook_with_third_party(
1706 &self,
1707 req: &AwsRequest,
1708 ) -> Result<AwsResponse, AwsServiceError> {
1709 let b = body(req);
1710 let account = self.account(req);
1711 if let Some(name) = str_field(&b, "webhookName") {
1712 let mut guard = self.state.write();
1713 let st = guard.get_or_create(&account);
1714 if !st.webhooks.contains_key(&name) {
1715 return Err(err(
1716 "WebhookNotFoundException",
1717 format!("Could not find webhook {name}."),
1718 ));
1719 }
1720 }
1721 ok(json!({}))
1722 }
1723
1724 fn deregister_webhook_with_third_party(
1725 &self,
1726 req: &AwsRequest,
1727 ) -> Result<AwsResponse, AwsServiceError> {
1728 let b = body(req);
1729 let account = self.account(req);
1730 if let Some(name) = str_field(&b, "webhookName") {
1731 let mut guard = self.state.write();
1732 let st = guard.get_or_create(&account);
1733 if !st.webhooks.contains_key(&name) {
1734 return Err(err(
1735 "WebhookNotFoundException",
1736 format!("Could not find webhook {name}."),
1737 ));
1738 }
1739 }
1740 ok(json!({}))
1741 }
1742}
1743
1744impl CodePipelineService {
1749 fn acknowledge_job(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1750 let b = body(req);
1751 req_str(&b, "jobId")?;
1752 req_str(&b, "nonce")?;
1753 Err(err(
1754 "JobNotFoundException",
1755 "The job was not found or is no longer available.",
1756 ))
1757 }
1758
1759 fn acknowledge_third_party_job(
1760 &self,
1761 req: &AwsRequest,
1762 ) -> Result<AwsResponse, AwsServiceError> {
1763 let b = body(req);
1764 req_str(&b, "jobId")?;
1765 req_str(&b, "nonce")?;
1766 req_str(&b, "clientToken")?;
1767 Err(err(
1768 "JobNotFoundException",
1769 "The job was not found or is no longer available.",
1770 ))
1771 }
1772
1773 fn get_job_details(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1774 let b = body(req);
1775 req_str(&b, "jobId")?;
1776 Err(err(
1777 "JobNotFoundException",
1778 "The job was not found or is no longer available.",
1779 ))
1780 }
1781
1782 fn get_third_party_job_details(
1783 &self,
1784 req: &AwsRequest,
1785 ) -> Result<AwsResponse, AwsServiceError> {
1786 let b = body(req);
1787 req_str(&b, "jobId")?;
1788 req_str(&b, "clientToken")?;
1789 Err(err(
1790 "JobNotFoundException",
1791 "The job was not found or is no longer available.",
1792 ))
1793 }
1794
1795 fn poll_for_jobs(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1796 let b = body(req);
1797 check_int_range(&b, "maxBatchSize", 1, None)?;
1798 if !b.get("actionTypeId").map(Value::is_object).unwrap_or(false) {
1799 return Err(validation("actionTypeId is required."));
1800 }
1801 ok(json!({ "jobs": [] }))
1803 }
1804
1805 fn poll_for_third_party_jobs(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1806 let b = body(req);
1807 check_int_range(&b, "maxBatchSize", 1, None)?;
1808 if !b.get("actionTypeId").map(Value::is_object).unwrap_or(false) {
1809 return Err(validation("actionTypeId is required."));
1810 }
1811 ok(json!({ "jobs": [] }))
1812 }
1813
1814 fn put_job_success_result(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1815 let b = body(req);
1816 req_str(&b, "jobId")?;
1817 Err(err(
1818 "JobNotFoundException",
1819 "The job was not found or is no longer available.",
1820 ))
1821 }
1822
1823 fn put_job_failure_result(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1824 let b = body(req);
1825 req_str(&b, "jobId")?;
1826 let fd = b.get("failureDetails").filter(|v| v.is_object());
1827 let Some(fd) = fd else {
1828 return Err(validation("failureDetails is required."));
1829 };
1830 if let Some(t) = fd.get("type").and_then(Value::as_str) {
1831 if !validate::is_enum(validate::FAILURE_TYPE, t) {
1832 return Err(validation("The failure type is invalid."));
1833 }
1834 }
1835 Err(err(
1836 "JobNotFoundException",
1837 "The job was not found or is no longer available.",
1838 ))
1839 }
1840
1841 fn put_third_party_job_success_result(
1842 &self,
1843 req: &AwsRequest,
1844 ) -> Result<AwsResponse, AwsServiceError> {
1845 let b = body(req);
1846 req_str(&b, "jobId")?;
1847 req_str(&b, "clientToken")?;
1848 Err(err(
1849 "JobNotFoundException",
1850 "The job was not found or is no longer available.",
1851 ))
1852 }
1853
1854 fn put_third_party_job_failure_result(
1855 &self,
1856 req: &AwsRequest,
1857 ) -> Result<AwsResponse, AwsServiceError> {
1858 let b = body(req);
1859 req_str(&b, "jobId")?;
1860 req_str(&b, "clientToken")?;
1861 if !b
1862 .get("failureDetails")
1863 .map(Value::is_object)
1864 .unwrap_or(false)
1865 {
1866 return Err(validation("failureDetails is required."));
1867 }
1868 Err(err(
1869 "JobNotFoundException",
1870 "The job was not found or is no longer available.",
1871 ))
1872 }
1873}
1874
1875impl CodePipelineService {
1880 fn list_rule_types(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1881 let b = body(req);
1882 opt_enum(&b, "ruleOwnerFilter", validate::RULE_OWNER)?;
1883 check_len(&b, "regionFilter", 4, 30)?;
1884 let rule_types = vec![
1888 json!({
1889 "id": { "category": "Rule", "owner": "AWS", "provider": "DeploymentWindow", "version": "1" },
1890 "inputArtifactDetails": { "minimumCount": 0, "maximumCount": 0 }
1891 }),
1892 json!({
1893 "id": { "category": "Rule", "owner": "AWS", "provider": "LambdaInvoke", "version": "1" },
1894 "inputArtifactDetails": { "minimumCount": 0, "maximumCount": 5 }
1895 }),
1896 json!({
1897 "id": { "category": "Rule", "owner": "AWS", "provider": "VariableCheck", "version": "1" },
1898 "inputArtifactDetails": { "minimumCount": 0, "maximumCount": 0 }
1899 }),
1900 ];
1901 ok(paginate_body(rule_types, "ruleTypes", &b, "nextToken")?)
1902 }
1903}
1904
1905impl CodePipelineService {
1910 fn resource_exists(&self, req: &AwsRequest, arn: &str) -> bool {
1912 let account = self.account(req);
1913 let mut guard = self.state.write();
1914 let st = guard.get_or_create(&account);
1915 let suffix = arn.splitn(6, ':').nth(5);
1918 match suffix {
1919 Some(s) if s.starts_with("webhook:") => {
1920 st.webhooks.contains_key(s.trim_start_matches("webhook:"))
1921 }
1922 Some(s) if s.starts_with("actiontype:") => {
1924 let parts: Vec<&str> = s.trim_start_matches("actiontype:").split('/').collect();
1925 if parts.len() == 4 {
1926 st.custom_actions
1927 .contains_key(&action_key(parts[1], parts[2], parts[3]))
1928 } else {
1929 false
1930 }
1931 }
1932 Some(s) => st.pipelines.contains_key(s),
1933 None => false,
1934 }
1935 }
1936
1937 fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1938 let b = body(req);
1939 let arn = req_str(&b, "resourceArn")?;
1940 if !is_codepipeline_arn(&arn) {
1941 return Err(err(
1942 "InvalidArnException",
1943 "The specified resource ARN is not valid.",
1944 ));
1945 }
1946 let tags = tag_list(&b);
1947 if !self.resource_exists(req, &arn) {
1948 return Err(err(
1949 "ResourceNotFoundException",
1950 format!("The resource with the ARN {arn} was not found."),
1951 ));
1952 }
1953 let account = self.account(req);
1954 let mut guard = self.state.write();
1955 let st = guard.get_or_create(&account);
1956 let existing = st.tags.entry(arn).or_default();
1957 for tag in tags {
1958 let key = tag.get("key").and_then(Value::as_str).map(str::to_string);
1959 if let Some(key) = key {
1960 existing.retain(|t| t.get("key").and_then(Value::as_str) != Some(key.as_str()));
1961 existing.push(tag);
1962 }
1963 }
1964 ok(json!({}))
1965 }
1966
1967 fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1968 let b = body(req);
1969 let arn = req_str(&b, "resourceArn")?;
1970 if !is_codepipeline_arn(&arn) {
1971 return Err(err(
1972 "InvalidArnException",
1973 "The specified resource ARN is not valid.",
1974 ));
1975 }
1976 if !self.resource_exists(req, &arn) {
1977 return Err(err(
1978 "ResourceNotFoundException",
1979 format!("The resource with the ARN {arn} was not found."),
1980 ));
1981 }
1982 let keys: Vec<String> = b
1983 .get("tagKeys")
1984 .and_then(Value::as_array)
1985 .map(|a| {
1986 a.iter()
1987 .filter_map(|v| v.as_str().map(str::to_string))
1988 .collect()
1989 })
1990 .unwrap_or_default();
1991 let account = self.account(req);
1992 let mut guard = self.state.write();
1993 let st = guard.get_or_create(&account);
1994 if let Some(existing) = st.tags.get_mut(&arn) {
1995 existing.retain(|t| {
1996 t.get("key")
1997 .and_then(Value::as_str)
1998 .map(|k| !keys.iter().any(|rk| rk == k))
1999 .unwrap_or(true)
2000 });
2001 }
2002 ok(json!({}))
2003 }
2004
2005 fn list_tags_for_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2006 let b = body(req);
2007 let arn = req_str(&b, "resourceArn")?;
2008 if !is_codepipeline_arn(&arn) {
2009 return Err(err(
2010 "InvalidArnException",
2011 "The specified resource ARN is not valid.",
2012 ));
2013 }
2014 if !self.resource_exists(req, &arn) {
2015 return Err(err(
2016 "ResourceNotFoundException",
2017 format!("The resource with the ARN {arn} was not found."),
2018 ));
2019 }
2020 let account = self.account(req);
2021 let mut guard = self.state.write();
2022 let st = guard.get_or_create(&account);
2023 let tags = st.tags.get(&arn).cloned().unwrap_or_default();
2024 ok(paginate_body(tags, "tags", &b, "nextToken")?)
2025 }
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030 use super::*;
2031 use crate::state::CodePipelineState;
2032 use fakecloud_core::multi_account::MultiAccountState;
2033 use parking_lot::RwLock;
2034
2035 fn svc() -> CodePipelineService {
2036 let state = Arc::new(RwLock::new(MultiAccountState::<CodePipelineState>::new(
2037 "123456789012",
2038 "us-east-1",
2039 "",
2040 )));
2041 CodePipelineService::new(state)
2042 }
2043
2044 fn req(action: &str, body: Value) -> AwsRequest {
2045 AwsRequest {
2046 service: "codepipeline".to_string(),
2047 action: action.to_string(),
2048 region: "us-east-1".to_string(),
2049 account_id: "123456789012".to_string(),
2050 request_id: "rid".to_string(),
2051 headers: http::HeaderMap::new(),
2052 query_params: std::collections::HashMap::new(),
2053 body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
2054 body_stream: parking_lot::Mutex::new(None),
2055 path_segments: vec![],
2056 raw_path: "/".to_string(),
2057 raw_query: String::new(),
2058 method: http::Method::POST,
2059 is_query_protocol: false,
2060 access_key_id: None,
2061 principal: None,
2062 }
2063 }
2064
2065 fn pipeline_decl(name: &str, mode: Option<&str>) -> Value {
2066 let mut decl = json!({
2067 "name": name,
2068 "roleArn": "arn:aws:iam::123456789012:role/cp",
2069 "artifactStore": { "type": "S3", "location": "artifact-bucket" },
2070 "stages": [
2071 { "name": "Source", "actions": [
2072 { "name": "Src", "actionTypeId": { "category": "Source", "owner": "AWS", "provider": "S3", "version": "1" } } ] },
2073 { "name": "Build", "actions": [
2074 { "name": "Bld", "actionTypeId": { "category": "Build", "owner": "AWS", "provider": "CodeBuild", "version": "1" } } ] }
2075 ]
2076 });
2077 if let Some(m) = mode {
2078 decl.as_object_mut()
2079 .unwrap()
2080 .insert("executionMode".into(), json!(m));
2081 }
2082 decl
2083 }
2084
2085 async fn body_of(svc: &CodePipelineService, action: &str, body: Value) -> Value {
2086 let resp = svc.handle(req(action, body)).await.unwrap();
2087 serde_json::from_slice(resp.body.expect_bytes()).unwrap()
2088 }
2089
2090 async fn create(svc: &CodePipelineService, name: &str, mode: Option<&str>) {
2091 svc.handle(req(
2092 "CreatePipeline",
2093 json!({ "pipeline": pipeline_decl(name, mode) }),
2094 ))
2095 .await
2096 .unwrap();
2097 }
2098
2099 #[test]
2101 fn settle_stopping_goes_to_stopped_not_succeeded() {
2102 let mut e = json!({ "status": "Stopping" });
2103 assert!(settle_execution(&mut e));
2104 assert_eq!(e["status"], json!("Stopped"));
2105 }
2106
2107 #[test]
2108 fn settle_in_progress_goes_to_succeeded() {
2109 let mut e = json!({ "status": "InProgress" });
2110 assert!(settle_execution(&mut e));
2111 assert_eq!(e["status"], json!("Succeeded"));
2112 }
2113
2114 #[test]
2115 fn settle_terminal_is_never_resettled() {
2116 for terminal in TERMINAL_EXECUTION_STATUSES {
2117 let mut e = json!({ "status": terminal });
2118 assert!(!settle_execution(&mut e), "{terminal} must stay terminal");
2119 assert_eq!(e["status"], json!(*terminal));
2120 }
2121 }
2122
2123 #[test]
2125 fn superseded_mode_supersedes_all_but_latest() {
2126 let mut execs = std::collections::BTreeMap::new();
2127 execs.insert("a".to_string(), json!({ "status": "InProgress" }));
2128 execs.insert("b".to_string(), json!({ "status": "InProgress" }));
2129 let order = vec!["a".to_string(), "b".to_string()];
2130 assert!(settle_pipeline_executions(&mut execs, &order, "SUPERSEDED"));
2131 assert_eq!(execs["a"]["status"], json!("Superseded"));
2132 assert_eq!(execs["b"]["status"], json!("Succeeded"));
2133 }
2134
2135 #[test]
2136 fn superseded_mode_leaves_stopping_older_execution_to_stop() {
2137 let mut execs = std::collections::BTreeMap::new();
2140 execs.insert("a".to_string(), json!({ "status": "Stopping" }));
2141 execs.insert("b".to_string(), json!({ "status": "InProgress" }));
2142 let order = vec!["a".to_string(), "b".to_string()];
2143 assert!(settle_pipeline_executions(&mut execs, &order, "SUPERSEDED"));
2144 assert_eq!(execs["a"]["status"], json!("Stopped"));
2145 assert_eq!(execs["b"]["status"], json!("Succeeded"));
2146 }
2147
2148 #[test]
2149 fn parallel_mode_settles_each_independently() {
2150 let mut execs = std::collections::BTreeMap::new();
2151 execs.insert("a".to_string(), json!({ "status": "InProgress" }));
2152 execs.insert("b".to_string(), json!({ "status": "InProgress" }));
2153 let order = vec!["a".to_string(), "b".to_string()];
2154 assert!(settle_pipeline_executions(&mut execs, &order, "PARALLEL"));
2155 assert_eq!(execs["a"]["status"], json!("Succeeded"));
2156 assert_eq!(execs["b"]["status"], json!("Succeeded"));
2157 assert!(!settle_pipeline_executions(&mut execs, &order, "PARALLEL"));
2159 }
2160
2161 #[test]
2163 fn pipeline_requires_exactly_one_artifact_store() {
2164 let mut no_store = pipeline_decl("p", None);
2165 no_store.as_object_mut().unwrap().remove("artifactStore");
2166 assert_eq!(
2167 validate_pipeline_declaration(&no_store).unwrap_err().code(),
2168 "InvalidStructureException"
2169 );
2170
2171 let mut both = pipeline_decl("p", None);
2172 both.as_object_mut().unwrap().insert(
2173 "artifactStores".into(),
2174 json!({ "us-east-1": { "type": "S3", "location": "b" } }),
2175 );
2176 assert_eq!(
2177 validate_pipeline_declaration(&both).unwrap_err().code(),
2178 "InvalidStructureException"
2179 );
2180
2181 assert!(validate_pipeline_declaration(&pipeline_decl("p", None)).is_ok());
2182 }
2183
2184 #[tokio::test]
2186 async fn list_action_types_returns_aws_owned_catalog() {
2187 let svc = svc();
2188 let all = body_of(&svc, "ListActionTypes", json!({})).await;
2189 let types = all["actionTypes"].as_array().unwrap();
2190 let providers: Vec<&str> = types
2191 .iter()
2192 .filter_map(|t| t["id"]["provider"].as_str())
2193 .collect();
2194 assert!(providers.contains(&"CodeBuild"));
2195 assert!(providers.contains(&"CodeDeploy"));
2196 assert!(providers.contains(&"Lambda"));
2197 let custom = body_of(
2199 &svc,
2200 "ListActionTypes",
2201 json!({ "actionOwnerFilter": "Custom" }),
2202 )
2203 .await;
2204 assert!(custom["actionTypes"].as_array().unwrap().is_empty());
2205 }
2206
2207 #[tokio::test]
2209 async fn disable_then_enable_stage_transition_reflected_in_state() {
2210 let svc = svc();
2211 create(&svc, "p", None).await;
2212 svc.handle(req(
2213 "DisableStageTransition",
2214 json!({ "pipelineName": "p", "stageName": "Build", "transitionType": "Inbound", "reason": "hold" }),
2215 ))
2216 .await
2217 .unwrap();
2218 let state = body_of(&svc, "GetPipelineState", json!({ "name": "p" })).await;
2219 let build = state["stageStates"]
2220 .as_array()
2221 .unwrap()
2222 .iter()
2223 .find(|s| s["stageName"] == json!("Build"))
2224 .unwrap();
2225 assert_eq!(build["inboundTransitionState"]["enabled"], json!(false));
2226 assert_eq!(
2227 build["inboundTransitionState"]["disabledReason"],
2228 json!("hold")
2229 );
2230 let source = state["stageStates"]
2232 .as_array()
2233 .unwrap()
2234 .iter()
2235 .find(|s| s["stageName"] == json!("Source"))
2236 .unwrap();
2237 assert_eq!(source["inboundTransitionState"]["enabled"], json!(true));
2238
2239 svc.handle(req(
2240 "EnableStageTransition",
2241 json!({ "pipelineName": "p", "stageName": "Build", "transitionType": "Inbound" }),
2242 ))
2243 .await
2244 .unwrap();
2245 let state = body_of(&svc, "GetPipelineState", json!({ "name": "p" })).await;
2246 let build = state["stageStates"]
2247 .as_array()
2248 .unwrap()
2249 .iter()
2250 .find(|s| s["stageName"] == json!("Build"))
2251 .unwrap();
2252 assert_eq!(build["inboundTransitionState"]["enabled"], json!(true));
2253 }
2254
2255 #[tokio::test]
2257 async fn get_pipeline_execution_includes_status_summary() {
2258 let svc = svc();
2259 create(&svc, "p", None).await;
2260 let started = body_of(&svc, "StartPipelineExecution", json!({ "name": "p" })).await;
2261 let exec_id = started["pipelineExecutionId"].as_str().unwrap().to_string();
2262 svc.handle(req(
2263 "StopPipelineExecution",
2264 json!({ "pipelineName": "p", "pipelineExecutionId": exec_id, "reason": "manual stop" }),
2265 ))
2266 .await
2267 .unwrap();
2268 let got = body_of(
2269 &svc,
2270 "GetPipelineExecution",
2271 json!({ "pipelineName": "p", "pipelineExecutionId": exec_id }),
2272 )
2273 .await;
2274 assert_eq!(got["pipelineExecution"]["status"], json!("Stopped"));
2275 assert_eq!(
2276 got["pipelineExecution"]["statusSummary"],
2277 json!("manual stop")
2278 );
2279 }
2280
2281 #[tokio::test]
2283 async fn webhook_tags_reflect_tag_resource_changes() {
2284 let svc = svc();
2285 create(&svc, "p", None).await;
2286 svc.handle(req(
2287 "PutWebhook",
2288 json!({ "webhook": {
2289 "name": "wh", "targetPipeline": "p", "targetAction": "Src",
2290 "filters": [{ "jsonPath": "$.ref" }],
2291 "authentication": "UNAUTHENTICATED",
2292 "authenticationConfiguration": {} } }),
2293 ))
2294 .await
2295 .unwrap();
2296 let arn = "arn:aws:codepipeline:us-east-1:123456789012:webhook:wh";
2297 svc.handle(req(
2298 "TagResource",
2299 json!({ "resourceArn": arn, "tags": [{ "key": "env", "value": "prod" }] }),
2300 ))
2301 .await
2302 .unwrap();
2303 let listed = body_of(&svc, "ListWebhooks", json!({})).await;
2304 let wh = &listed["webhooks"][0];
2305 assert_eq!(wh["tags"], json!([{ "key": "env", "value": "prod" }]));
2306
2307 svc.handle(req(
2308 "UntagResource",
2309 json!({ "resourceArn": arn, "tagKeys": ["env"] }),
2310 ))
2311 .await
2312 .unwrap();
2313 let listed = body_of(&svc, "ListWebhooks", json!({})).await;
2314 assert!(listed["webhooks"][0].get("tags").is_none());
2316 }
2317
2318 #[tokio::test]
2320 async fn settled_read_reports_no_further_change() {
2321 let svc = svc();
2322 create(&svc, "p", None).await;
2323 let started = body_of(&svc, "StartPipelineExecution", json!({ "name": "p" })).await;
2324 let exec_id = started["pipelineExecutionId"].as_str().unwrap().to_string();
2325 let changed1 = std::cell::Cell::new(false);
2327 svc.get_pipeline_execution(
2328 &req(
2329 "GetPipelineExecution",
2330 json!({ "pipelineName": "p", "pipelineExecutionId": exec_id }),
2331 ),
2332 &changed1,
2333 )
2334 .unwrap();
2335 assert!(changed1.get(), "first read should settle and report change");
2336 let changed2 = std::cell::Cell::new(false);
2338 svc.get_pipeline_execution(
2339 &req(
2340 "GetPipelineExecution",
2341 json!({ "pipelineName": "p", "pipelineExecutionId": exec_id }),
2342 ),
2343 &changed2,
2344 )
2345 .unwrap();
2346 assert!(!changed2.get(), "settled read must not report a change");
2347 }
2348
2349 async fn err_of(svc: &CodePipelineService, action: &str, body: Value) -> String {
2350 match svc.handle(req(action, body)).await {
2351 Ok(_) => panic!("expected an error from {action}"),
2352 Err(e) => e.code().to_string(),
2353 }
2354 }
2355
2356 #[tokio::test]
2359 async fn garbage_next_token_is_invalid_next_token_exception() {
2360 let svc = svc();
2361 for action in ["ListPipelines", "ListActionTypes"] {
2362 assert_eq!(
2363 err_of(&svc, action, json!({ "nextToken": "not-a-number" })).await,
2364 "InvalidNextTokenException",
2365 "{action}"
2366 );
2367 }
2368 assert_eq!(
2370 err_of(&svc, "ListWebhooks", json!({ "NextToken": "not-a-number" })).await,
2371 "InvalidNextTokenException"
2372 );
2373 assert_eq!(
2375 err_of(&svc, "ListPipelines", json!({ "nextToken": "-1" })).await,
2376 "InvalidNextTokenException"
2377 );
2378 }
2379
2380 #[tokio::test]
2382 async fn stage_transition_unknown_stage_is_stage_not_found() {
2383 let svc = svc();
2384 create(&svc, "p", None).await;
2385 assert_eq!(
2386 err_of(
2387 &svc,
2388 "DisableStageTransition",
2389 json!({ "pipelineName": "p", "stageName": "Ghost", "transitionType": "Inbound", "reason": "x" }),
2390 )
2391 .await,
2392 "StageNotFoundException"
2393 );
2394 assert_eq!(
2395 err_of(
2396 &svc,
2397 "EnableStageTransition",
2398 json!({ "pipelineName": "p", "stageName": "Ghost", "transitionType": "Inbound" }),
2399 )
2400 .await,
2401 "StageNotFoundException"
2402 );
2403 svc.handle(req(
2405 "DisableStageTransition",
2406 json!({ "pipelineName": "p", "stageName": "Build", "transitionType": "Inbound", "reason": "x" }),
2407 ))
2408 .await
2409 .unwrap();
2410 }
2411
2412 #[tokio::test]
2414 async fn static_list_ops_validate_next_token() {
2415 let svc = svc();
2416 create(&svc, "p", None).await;
2417 for action in ["ListActionExecutions", "ListRuleExecutions"] {
2418 assert_eq!(
2419 err_of(
2420 &svc,
2421 action,
2422 json!({ "pipelineName": "p", "nextToken": "bad" })
2423 )
2424 .await,
2425 "InvalidNextTokenException",
2426 "{action}"
2427 );
2428 }
2429 assert_eq!(
2430 err_of(&svc, "ListRuleTypes", json!({ "nextToken": "bad" })).await,
2431 "InvalidNextTokenException"
2432 );
2433 let ok = body_of(&svc, "ListRuleTypes", json!({})).await;
2435 assert_eq!(ok["ruleTypes"].as_array().unwrap().len(), 3);
2436 }
2437
2438 #[tokio::test]
2440 async fn max_results_range_enforced() {
2441 let svc = svc();
2442 create(&svc, "p", None).await;
2443 for action in [
2444 "ListPipelineExecutions",
2445 "ListActionExecutions",
2446 "ListRuleExecutions",
2447 ] {
2448 assert_eq!(
2449 err_of(
2450 &svc,
2451 action,
2452 json!({ "pipelineName": "p", "maxResults": 0 })
2453 )
2454 .await,
2455 "ValidationException",
2456 "{action} lower bound"
2457 );
2458 assert_eq!(
2459 err_of(
2460 &svc,
2461 action,
2462 json!({ "pipelineName": "p", "maxResults": 101 })
2463 )
2464 .await,
2465 "ValidationException",
2466 "{action} upper bound"
2467 );
2468 }
2469 }
2470
2471 #[tokio::test]
2473 async fn double_stop_is_duplicated_stop_request() {
2474 let svc = svc();
2475 create(&svc, "p", None).await;
2476 let started = body_of(&svc, "StartPipelineExecution", json!({ "name": "p" })).await;
2477 let exec_id = started["pipelineExecutionId"].as_str().unwrap().to_string();
2478 svc.handle(req(
2480 "StopPipelineExecution",
2481 json!({ "pipelineName": "p", "pipelineExecutionId": exec_id }),
2482 ))
2483 .await
2484 .unwrap();
2485 assert_eq!(
2486 err_of(
2487 &svc,
2488 "StopPipelineExecution",
2489 json!({ "pipelineName": "p", "pipelineExecutionId": exec_id }),
2490 )
2491 .await,
2492 "DuplicatedStopRequestException"
2493 );
2494 }
2495
2496 #[test]
2499 fn stage_and_action_names_validated() {
2500 let mut nameless_stage = pipeline_decl("p", None);
2501 nameless_stage["stages"][0]
2502 .as_object_mut()
2503 .unwrap()
2504 .remove("name");
2505 assert_eq!(
2506 validate_pipeline_declaration(&nameless_stage)
2507 .unwrap_err()
2508 .code(),
2509 "InvalidStageDeclarationException"
2510 );
2511
2512 let mut bad_stage = pipeline_decl("p", None);
2513 bad_stage["stages"][0]["name"] = json!("bad name!");
2514 assert_eq!(
2515 validate_pipeline_declaration(&bad_stage)
2516 .unwrap_err()
2517 .code(),
2518 "InvalidStageDeclarationException"
2519 );
2520
2521 let mut nameless_action = pipeline_decl("p", None);
2522 nameless_action["stages"][0]["actions"][0]
2523 .as_object_mut()
2524 .unwrap()
2525 .remove("name");
2526 assert_eq!(
2527 validate_pipeline_declaration(&nameless_action)
2528 .unwrap_err()
2529 .code(),
2530 "InvalidActionDeclarationException"
2531 );
2532
2533 let mut bad_action = pipeline_decl("p", None);
2534 bad_action["stages"][0]["actions"][0]["name"] = json!("bad name!");
2535 assert_eq!(
2536 validate_pipeline_declaration(&bad_action)
2537 .unwrap_err()
2538 .code(),
2539 "InvalidActionDeclarationException"
2540 );
2541
2542 assert!(validate_pipeline_declaration(&pipeline_decl("p", None)).is_ok());
2544 }
2545
2546 #[tokio::test]
2549 async fn get_pipeline_version_validation() {
2550 let svc = svc();
2551 create(&svc, "p", None).await;
2552 assert_eq!(
2553 err_of(
2554 &svc,
2555 "GetPipeline",
2556 json!({ "name": "p", "version": "abc" })
2557 )
2558 .await,
2559 "ValidationException"
2560 );
2561 assert_eq!(
2562 err_of(&svc, "GetPipeline", json!({ "name": "p", "version": 0 })).await,
2563 "ValidationException"
2564 );
2565 assert_eq!(
2566 err_of(&svc, "GetPipeline", json!({ "name": "p", "version": 99 })).await,
2567 "PipelineVersionNotFoundException"
2568 );
2569 let got = body_of(&svc, "GetPipeline", json!({ "name": "p" })).await;
2571 assert_eq!(got["pipeline"]["version"], json!(1));
2572 }
2573}