Skip to main content

fakecloud_codepipeline/
service.rs

1//! AWS CodePipeline awsJson1.1 dispatch + operation handlers.
2//!
3//! Implements the full CodePipeline control plane: pipelines and their
4//! versions, pipeline/action/rule executions, custom action types, webhooks,
5//! job and third-party-job polling, stage transitions, approvals, and resource
6//! tagging. There is no real release engine, so a `StartPipelineExecution`
7//! mints a pipeline execution in `InProgress` that deterministically advances
8//! to a terminal `Succeeded` state across successive `GetPipelineExecution`,
9//! `GetPipelineState`, and `ListPipelineExecutions` reads (the same lazy-settle
10//! pattern CodeDeploy and CodeBuild use for async state). Everything else is
11//! real, persisted, account-partitioned CRUD.
12
13use 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    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
104    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
119/// Whether an action mutates persisted state (and therefore triggers a
120/// snapshot). The execution-settling reads advance in-progress executions to a
121/// terminal state on read, so they persist too.
122/// Actions that always mutate persisted state on a successful response (so a
123/// snapshot is taken unconditionally). The settling read ops
124/// (`GetPipelineExecution`, `GetPipelineState`, `ListPipelineExecutions`) are
125/// deliberately absent: they persist only when a settle actually changed an
126/// execution, signalled at runtime via the per-call `changed` flag.
127fn 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        // `changed` is set by settling reads when a settle actually mutated an
155        // execution. It never crosses the `.await` boundary below as a live
156        // borrow, so `handle` stays `Send`.
157        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
232// ---------------------------------------------------------------------------
233// Free helpers
234// ---------------------------------------------------------------------------
235
236fn 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
244/// Every CodePipeline operation declares `ValidationException`; it is the
245/// canonical input-shape error, so absent/malformed inputs land on it.
246fn 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
267/// A `PipelineName` / `StageName` / `ActionName`: 1-100 chars matching
268/// `^[A-Za-z0-9.@\-_]+$`.
269fn 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
279/// Require a present, non-empty string member, erroring with
280/// `ValidationException` when absent or empty.
281fn 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
290/// Require a valid pipeline name in member `key`.
291fn 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
302/// Validate the `@length` constraint of a present string member.
303fn 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
314/// Validate the `@range` constraint of a present integer member (`max = None`
315/// for a lower-bound-only range).
316fn 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
343/// Whether `s` is a syntactically valid CodePipeline resource ARN
344/// (`arn:aws...:codepipeline:<region>:<12-digit-account>:<resource>`).
345fn 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
363/// Pull `nextToken` from the body and paginate a list of JSON values into
364/// `{list_key: [...], nextToken?}` with a page size honoring `maxResults`.
365fn 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        // Every paginated CodePipeline op declares InvalidNextTokenException as
381        // the wire error for a token not produced by a prior page, so a
382        // non-numeric/negative token maps there, not to ValidationException.
383        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
416// ---------------------------------------------------------------------------
417// Pipelines
418// ---------------------------------------------------------------------------
419
420/// Validate the `PipelineDeclaration` structure, returning the pipeline name.
421fn 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    // A pipeline must declare exactly one artifact store: either a single
429    // `artifactStore` or a per-region `artifactStores` map, never both and
430    // never neither.
431    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    // Validate every stage carries a name and at least one action, and every
458    // action carries a well-formed actionTypeId.
459    for stage in stages {
460        // StageName is `@length(1,100)` + `@pattern ^[A-Za-z0-9.@\-_]+$`; a
461        // missing or malformed name is an InvalidStageDeclarationException.
462        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            // ActionName shares StageName's `@length(1,100)` + pattern; a
483            // nameless or malformed action is InvalidActionDeclarationException
484            // (AWS otherwise silently drops it from GetPipelineState).
485            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
525/// Fill the AWS-applied defaults on a `PipelineDeclaration` in place: each
526/// action's `runOrder` defaults to 1 when omitted, matching what CodePipeline
527/// stores and returns from `GetPipeline`.
528fn 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                // PipelineVersion is `@range(min: 1)`; a non-integer or
601                // non-positive value is a ValidationException, not a silent
602                // fall-back to the current version.
603                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        // UpdatePipeline does not create pipelines; the Smithy contract has no
640        // "not found" error, so a missing pipeline is a validation error.
641        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        // DeletePipeline is idempotent: no "does not exist" error is declared.
677        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
727// ---------------------------------------------------------------------------
728// Pipeline executions
729// ---------------------------------------------------------------------------
730
731/// The terminal `PipelineExecutionStatus` values: an execution in any of these
732/// is never re-settled.
733const TERMINAL_EXECUTION_STATUSES: &[&str] =
734    &["Succeeded", "Failed", "Stopped", "Superseded", "Cancelled"];
735
736/// Advance a non-terminal execution one step toward a terminal state on read:
737/// an `InProgress` execution settles to `Succeeded`, a `Stopping` execution
738/// settles to `Stopped`. Terminal executions are left untouched. Returns
739/// whether the execution was actually mutated (so the caller can gate a
740/// snapshot on real change rather than persisting on every poll).
741fn 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
760/// Settle every execution of a pipeline in place, honoring `executionMode`.
761/// In `SUPERSEDED` mode, when more than one execution is still non-terminal,
762/// all but the most-recent `InProgress` runs become `Superseded` and only the
763/// latest advances to `Succeeded`. A `Stopping` execution is left to settle to
764/// `Stopped` (an explicit stop wins over supersession), and terminal executions
765/// are never touched. In `PARALLEL`/`QUEUED` mode every execution settles
766/// independently. `order` is the pipeline's execution ids oldest-first.
767/// Returns whether any execution was mutated.
768fn 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        // Non-terminal executions, oldest-first; all but the newest are
776        // superseded by a later run.
777        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                    // A user-requested stop (`Stopping`) settles to `Stopped`,
792                    // not `Superseded`: the explicit stop intent wins. Only an
793                    // `InProgress` run is superseded by a later execution.
794                    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    /// The pipeline's configured `executionMode` (defaults to `SUPERSEDED`,
823    /// matching CodePipeline's default for pipelines that don't set it).
824    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        // Project the stored execution onto the PipelineExecution output shape.
905        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        // maxResults is `@range(1, 100)`; enforce it before paginating.
931        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        // Summaries are returned most-recent first.
947        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        // An already-`Stopping` execution has a stop in flight: a second stop is
1003        // a DuplicatedStopRequestException, not a fresh stop. Only an
1004        // `InProgress` execution is stoppable; anything else (terminal or
1005        // unknown) is not stoppable.
1006        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        // Settle the pipeline's executions (honoring executionMode), then read
1052        // the latest execution's status for the per-stage summary.
1053        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                // The inbound transition is enabled unless DisableStageTransition
1085                // recorded it as disabled for this stage.
1086                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        // maxResults is `@range(1, 100)`; route the (empty) list through
1119        // paginate_body so a malformed nextToken -> InvalidNextTokenException.
1120        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        // No action executions are tracked, so any id is unknown.
1156        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        // maxResults is `@range(1, 100)`; route the (empty) list through
1166        // paginate_body so a malformed nextToken -> InvalidNextTokenException.
1167        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
1186/// Whether the pipeline declaration `decl` currently declares a stage named
1187/// `stage_name`.
1188fn 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
1199/// Map a `PipelineExecutionStatus` onto the closest `StageExecutionStatus`.
1200fn 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
1211// ---------------------------------------------------------------------------
1212// Stage operations (transitions, retry, rollback, override, approvals, revisions)
1213// ---------------------------------------------------------------------------
1214
1215impl 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        // The stage must be one of the pipeline's current stages.
1248        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        // Re-enabling clears any recorded disabled state for this transition.
1255        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        // The stage must be one of the pipeline's current stages; without this
1284        // check a disabled-transition entry for an unknown stage is persisted.
1285        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        // No failed stage execution is tracked to retry.
1312        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        // No manual-approval action is awaiting a result.
1382        Err(err(
1383            "ActionNotFoundException",
1384            "The approval action was not found for the pipeline.",
1385        ))
1386    }
1387}
1388
1389// ---------------------------------------------------------------------------
1390// Custom action types
1391// ---------------------------------------------------------------------------
1392
1393fn action_key(category: &str, provider: &str, version: &str) -> String {
1394    format!("{category}:{provider}:{version}")
1395}
1396
1397/// The canonical AWS-owned action-type catalog returned by `ListActionTypes`
1398/// (owner `AWS`), transcribed from the providers CodePipeline ships across each
1399/// category. Every entry carries the required `id`, `inputArtifactDetails`, and
1400/// `outputArtifactDetails` members. Artifact counts follow each category's
1401/// contract (sources produce but don't consume artifacts; approvals do
1402/// neither; build/test/deploy/invoke consume and may produce).
1403fn aws_owned_action_types() -> Vec<Value> {
1404    // (category, provider, input_min, input_max, output_min, output_max)
1405    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
1445/// One AWS-owned action type: `(category, provider, input_min, input_max,
1446/// output_min, output_max)`.
1447type 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        // Idempotent: no "does not exist" error is declared.
1530        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        // AWS-owned built-in providers.
1548        if want("AWS") {
1549            action_types.extend(aws_owned_action_types());
1550        }
1551        // Custom action types registered via CreateCustomActionType.
1552        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        // No ThirdParty (marketplace) action types are registered.
1560        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        // GetActionType surfaces the newer ActionTypeDeclaration registry, which
1576        // is not populated by CreateCustomActionType (that uses the legacy
1577        // ActionType shape), so any lookup is a miss.
1578        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
1600// ---------------------------------------------------------------------------
1601// Webhooks
1602// ---------------------------------------------------------------------------
1603
1604impl 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        // The webhook item is stored WITHOUT an embedded tag copy; `st.tags` is
1640        // the single source of truth so Tag/UntagResource stay authoritative.
1641        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        // Project the response with the webhook's current tags.
1654        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        // Populate each item's tags from `st.tags` (the single source of truth)
1685        // so Tag/UntagResource changes are always reflected.
1686        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
1744// ---------------------------------------------------------------------------
1745// Jobs + third-party jobs
1746// ---------------------------------------------------------------------------
1747
1748impl 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        // No job workers are queued; the poll drains empty.
1802        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
1875// ---------------------------------------------------------------------------
1876// Rule types
1877// ---------------------------------------------------------------------------
1878
1879impl 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        // The predefined AWS-owned rule types, always resolvable. Routed through
1885        // paginate_body so a malformed nextToken -> InvalidNextTokenException
1886        // consistently with the other list ops.
1887        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
1905// ---------------------------------------------------------------------------
1906// Tagging
1907// ---------------------------------------------------------------------------
1908
1909impl CodePipelineService {
1910    /// Whether an ARN names a currently-existing pipeline or webhook.
1911    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        // pipeline ARN: arn:aws:codepipeline:region:acct:<name>
1916        // webhook ARN:  arn:aws:codepipeline:region:acct:webhook:<name>
1917        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            // actiontype:<owner>/<category>/<provider>/<version>
1923            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    // Fix 1 + 9: settle state machine + returned changed-bool.
2100    #[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    // Fix 5: SUPERSEDED mode supersedes older in-flight executions.
2124    #[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        // An older execution the user explicitly stopped must settle to
2138        // `Stopped`, not be force-superseded by the newer run.
2139        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        // Second pass is a no-op (all terminal) -> gates a snapshot off.
2158        assert!(!settle_pipeline_executions(&mut execs, &order, "PARALLEL"));
2159    }
2160
2161    // Fix 4: artifact-store structural validation.
2162    #[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    // Fix 2: AWS-owned action-type catalog.
2185    #[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        // owner=Custom filter excludes the AWS built-ins.
2198        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    // Fix 3: transition state persists into GetPipelineState.
2208    #[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        // Source stage stays enabled.
2231        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    // Fix 6: statusSummary survives into GetPipelineExecution after a stop.
2256    #[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    // Fix 7: webhook tags read from st.tags (single source of truth).
2282    #[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        // With no tags, the field is omitted (single source of truth is empty).
2315        assert!(listed["webhooks"][0].get("tags").is_none());
2316    }
2317
2318    // Fix 8: a settling read that changes nothing must not report a change.
2319    #[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        // First read settles InProgress -> Succeeded (changes state).
2326        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        // Second read is a no-op: execution is terminal, nothing to persist.
2337        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    // M1: a garbage nextToken maps to InvalidNextTokenException, not
2357    // ValidationException, on the shared paginate_body helper.
2358    #[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        // ListWebhooks paginates on the capitalized `NextToken` member.
2369        assert_eq!(
2370            err_of(&svc, "ListWebhooks", json!({ "NextToken": "not-a-number" })).await,
2371            "InvalidNextTokenException"
2372        );
2373        // Negative offsets are rejected too.
2374        assert_eq!(
2375            err_of(&svc, "ListPipelines", json!({ "nextToken": "-1" })).await,
2376            "InvalidNextTokenException"
2377        );
2378    }
2379
2380    // M2: Enable/DisableStageTransition on an unknown stage -> StageNotFound.
2381    #[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        // A real stage still succeeds.
2404        svc.handle(req(
2405            "DisableStageTransition",
2406            json!({ "pipelineName": "p", "stageName": "Build", "transitionType": "Inbound", "reason": "x" }),
2407        ))
2408        .await
2409        .unwrap();
2410    }
2411
2412    // L1: static/empty list ops still validate a malformed nextToken.
2413    #[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        // Happy path is unaffected: ListRuleTypes still returns its catalog.
2434        let ok = body_of(&svc, "ListRuleTypes", json!({})).await;
2435        assert_eq!(ok["ruleTypes"].as_array().unwrap().len(), 3);
2436    }
2437
2438    // L2: maxResults @range(1,100) enforced on the execution list ops.
2439    #[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    // L3: re-stopping an already-Stopping execution is a duplicated request.
2472    #[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        // First stop (non-abandon) moves it to Stopping.
2479        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    // L4: stage/action names are validated against @pattern; nameless actions
2497    // are rejected rather than silently dropped.
2498    #[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        // The well-formed happy path still validates.
2543        assert!(validate_pipeline_declaration(&pipeline_decl("p", None)).is_ok());
2544    }
2545
2546    // L5: a non-integer/non-positive version errors instead of falling back to
2547    // the current version; a valid-but-missing version stays a version-not-found.
2548    #[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        // No version -> current (version 1).
2570        let got = body_of(&svc, "GetPipeline", json!({ "name": "p" })).await;
2571        assert_eq!(got["pipeline"]["version"], json!(1));
2572    }
2573}