Skip to main content

fakecloud_codedeploy/
service.rs

1//! AWS CodeDeploy awsJson1.1 dispatch + operation handlers.
2//!
3//! Implements the full CodeDeploy control plane: applications, application
4//! revisions, deployment groups, deployment configurations, deployments and
5//! their targets/instances, on-premises instances, GitHub account tokens, and
6//! resource tagging. There is no real deployment engine, so a `CreateDeployment`
7//! mints a deployment in `Created` that deterministically advances through
8//! `InProgress` to a terminal `Succeeded` state across successive
9//! `GetDeployment`/`BatchGetDeployments` reads (the same lazy-settle pattern
10//! CodeBuild, EKS, and Cloud Map use for async state). The predefined
11//! `CodeDeployDefault.*` deployment configurations are always resolvable.
12//! Everything else is real, persisted, account-partitioned CRUD.
13
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use http::StatusCode;
17use serde_json::{json, Map, Value};
18use std::sync::Arc;
19use tokio::sync::Mutex as AsyncMutex;
20
21use fakecloud_core::pagination::paginate_checked;
22use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
23use fakecloud_persistence::SnapshotStore;
24
25use crate::state::{CodeDeployState, SharedCodeDeployState};
26use crate::validate;
27
28pub const CODEDEPLOY_ACTIONS: &[&str] = &[
29    "AddTagsToOnPremisesInstances",
30    "BatchGetApplicationRevisions",
31    "BatchGetApplications",
32    "BatchGetDeploymentGroups",
33    "BatchGetDeploymentInstances",
34    "BatchGetDeploymentTargets",
35    "BatchGetDeployments",
36    "BatchGetOnPremisesInstances",
37    "ContinueDeployment",
38    "CreateApplication",
39    "CreateDeployment",
40    "CreateDeploymentConfig",
41    "CreateDeploymentGroup",
42    "DeleteApplication",
43    "DeleteDeploymentConfig",
44    "DeleteDeploymentGroup",
45    "DeleteGitHubAccountToken",
46    "DeleteResourcesByExternalId",
47    "DeregisterOnPremisesInstance",
48    "GetApplication",
49    "GetApplicationRevision",
50    "GetDeployment",
51    "GetDeploymentConfig",
52    "GetDeploymentGroup",
53    "GetDeploymentInstance",
54    "GetDeploymentTarget",
55    "GetOnPremisesInstance",
56    "ListApplicationRevisions",
57    "ListApplications",
58    "ListDeploymentConfigs",
59    "ListDeploymentGroups",
60    "ListDeploymentInstances",
61    "ListDeploymentTargets",
62    "ListDeployments",
63    "ListGitHubAccountTokenNames",
64    "ListOnPremisesInstances",
65    "ListTagsForResource",
66    "PutLifecycleEventHookExecutionStatus",
67    "RegisterApplicationRevision",
68    "RegisterOnPremisesInstance",
69    "RemoveTagsFromOnPremisesInstances",
70    "SkipWaitTimeForInstanceTermination",
71    "StopDeployment",
72    "TagResource",
73    "UntagResource",
74    "UpdateApplication",
75    "UpdateDeploymentGroup",
76];
77
78pub struct CodeDeployService {
79    state: SharedCodeDeployState,
80    snapshot_store: Option<Arc<dyn SnapshotStore>>,
81    snapshot_lock: Arc<AsyncMutex<()>>,
82}
83
84impl CodeDeployService {
85    pub fn new(state: SharedCodeDeployState) -> Self {
86        Self {
87            state,
88            snapshot_store: None,
89            snapshot_lock: Arc::new(AsyncMutex::new(())),
90        }
91    }
92
93    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
94        self.snapshot_store = Some(store);
95        self
96    }
97
98    async fn save(&self) {
99        crate::persistence::save_snapshot(
100            &self.state,
101            self.snapshot_store.clone(),
102            &self.snapshot_lock,
103        )
104        .await;
105    }
106
107    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
108    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
109        let store = self.snapshot_store.clone()?;
110        let state = self.state.clone();
111        let lock = self.snapshot_lock.clone();
112        Some(Arc::new(move || {
113            let state = state.clone();
114            let store = store.clone();
115            let lock = lock.clone();
116            Box::pin(async move {
117                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
118            })
119        }))
120    }
121}
122
123/// Whether an action mutates persisted state (and therefore triggers a
124/// snapshot). `GetDeployment`/`BatchGetDeployments` advance in-progress
125/// deployments to a terminal state on read, so they persist too.
126fn is_mutating(action: &str) -> bool {
127    matches!(
128        action,
129        "AddTagsToOnPremisesInstances"
130            | "BatchGetDeployments"
131            | "CreateApplication"
132            | "CreateDeployment"
133            | "CreateDeploymentConfig"
134            | "CreateDeploymentGroup"
135            | "DeleteApplication"
136            | "DeleteDeploymentConfig"
137            | "DeleteDeploymentGroup"
138            | "DeleteResourcesByExternalId"
139            | "DeregisterOnPremisesInstance"
140            | "GetDeployment"
141            | "RegisterApplicationRevision"
142            | "RegisterOnPremisesInstance"
143            | "RemoveTagsFromOnPremisesInstances"
144            | "StopDeployment"
145            | "TagResource"
146            | "UntagResource"
147            | "UpdateApplication"
148            | "UpdateDeploymentGroup"
149    )
150}
151
152#[async_trait]
153impl AwsService for CodeDeployService {
154    fn service_name(&self) -> &str {
155        "codedeploy"
156    }
157
158    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
159        let action = req.action.clone();
160        let result = self.dispatch(&action, &req);
161        if is_mutating(&action) && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
162            self.save().await;
163        }
164        result
165    }
166
167    fn supported_actions(&self) -> &[&str] {
168        CODEDEPLOY_ACTIONS
169    }
170}
171
172impl CodeDeployService {
173    fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
174        match action {
175            "CreateApplication" => self.create_application(req),
176            "GetApplication" => self.get_application(req),
177            "UpdateApplication" => self.update_application(req),
178            "DeleteApplication" => self.delete_application(req),
179            "ListApplications" => self.list_applications(req),
180            "BatchGetApplications" => self.batch_get_applications(req),
181            "RegisterApplicationRevision" => self.register_application_revision(req),
182            "GetApplicationRevision" => self.get_application_revision(req),
183            "ListApplicationRevisions" => self.list_application_revisions(req),
184            "BatchGetApplicationRevisions" => self.batch_get_application_revisions(req),
185            "CreateDeploymentGroup" => self.create_deployment_group(req),
186            "GetDeploymentGroup" => self.get_deployment_group(req),
187            "UpdateDeploymentGroup" => self.update_deployment_group(req),
188            "DeleteDeploymentGroup" => self.delete_deployment_group(req),
189            "ListDeploymentGroups" => self.list_deployment_groups(req),
190            "BatchGetDeploymentGroups" => self.batch_get_deployment_groups(req),
191            "CreateDeploymentConfig" => self.create_deployment_config(req),
192            "GetDeploymentConfig" => self.get_deployment_config(req),
193            "DeleteDeploymentConfig" => self.delete_deployment_config(req),
194            "ListDeploymentConfigs" => self.list_deployment_configs(req),
195            "CreateDeployment" => self.create_deployment(req),
196            "GetDeployment" => self.get_deployment(req),
197            "BatchGetDeployments" => self.batch_get_deployments(req),
198            "ListDeployments" => self.list_deployments(req),
199            "StopDeployment" => self.stop_deployment(req),
200            "ContinueDeployment" => self.continue_deployment(req),
201            "SkipWaitTimeForInstanceTermination" => self.skip_wait_time(req),
202            "GetDeploymentTarget" => self.get_deployment_target(req),
203            "ListDeploymentTargets" => self.list_deployment_targets(req),
204            "BatchGetDeploymentTargets" => self.batch_get_deployment_targets(req),
205            "GetDeploymentInstance" => self.get_deployment_instance(req),
206            "ListDeploymentInstances" => self.list_deployment_instances(req),
207            "BatchGetDeploymentInstances" => self.batch_get_deployment_instances(req),
208            "RegisterOnPremisesInstance" => self.register_on_premises_instance(req),
209            "DeregisterOnPremisesInstance" => self.deregister_on_premises_instance(req),
210            "GetOnPremisesInstance" => self.get_on_premises_instance(req),
211            "ListOnPremisesInstances" => self.list_on_premises_instances(req),
212            "BatchGetOnPremisesInstances" => self.batch_get_on_premises_instances(req),
213            "AddTagsToOnPremisesInstances" => self.add_tags_to_on_premises(req),
214            "RemoveTagsFromOnPremisesInstances" => self.remove_tags_from_on_premises(req),
215            "ListGitHubAccountTokenNames" => self.list_github_account_token_names(req),
216            "DeleteGitHubAccountToken" => self.delete_github_account_token(req),
217            "PutLifecycleEventHookExecutionStatus" => self.put_lifecycle_status(req),
218            "DeleteResourcesByExternalId" => self.delete_resources_by_external_id(req),
219            "TagResource" => self.tag_resource(req),
220            "UntagResource" => self.untag_resource(req),
221            "ListTagsForResource" => self.list_tags_for_resource(req),
222            _ => Err(AwsServiceError::action_not_implemented(
223                self.service_name(),
224                action,
225            )),
226        }
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Free helpers
232// ---------------------------------------------------------------------------
233
234fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
235    Ok(AwsResponse::json_value(StatusCode::OK, v))
236}
237
238fn ts(dt: DateTime<Utc>) -> Value {
239    json!(dt.timestamp_millis() as f64 / 1000.0)
240}
241
242fn e(code: &str, msg: impl Into<String>) -> AwsServiceError {
243    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg)
244}
245
246fn body(req: &AwsRequest) -> Value {
247    serde_json::from_slice(&req.body).unwrap_or(Value::Null)
248}
249
250fn str_field(b: &Value, key: &str) -> Option<String> {
251    b.get(key).and_then(|v| v.as_str()).map(str::to_string)
252}
253
254fn str_list(b: &Value, key: &str) -> Vec<String> {
255    b.get(key)
256        .and_then(|v| v.as_array())
257        .map(|a| {
258            a.iter()
259                .filter_map(|x| x.as_str().map(str::to_string))
260                .collect()
261        })
262        .unwrap_or_default()
263}
264
265fn len_ok(v: &str, min: usize, max: usize) -> bool {
266    let n = v.chars().count();
267    n >= min && n <= max
268}
269
270/// A short random deployment id in AWS `d-XXXXXXXXX` form (9 uppercase
271/// base-36 characters).
272fn gen_deployment_id() -> String {
273    let raw = uuid::Uuid::new_v4().simple().to_string().to_uppercase();
274    let suffix: String = raw
275        .chars()
276        .filter(|c| c.is_ascii_alphanumeric())
277        .take(9)
278        .collect();
279    format!("d-{suffix}")
280}
281
282fn gen_uuid() -> String {
283    uuid::Uuid::new_v4().to_string()
284}
285
286// Field validators. Each returns the validated value or the operation-declared
287// error (`*RequiredException` for absence, `Invalid*Exception` for a malformed
288// value) so the negative conformance variants land on a declared error.
289
290fn req_application_name(b: &Value) -> Result<String, AwsServiceError> {
291    match str_field(b, "applicationName") {
292        None => Err(e(
293            "ApplicationNameRequiredException",
294            "The minimum number of required application inputs was not satisfied.",
295        )),
296        Some(v) if !len_ok(&v, 1, 100) => Err(e(
297            "InvalidApplicationNameException",
298            "The application name was specified in an invalid format.",
299        )),
300        Some(v) => Ok(v),
301    }
302}
303
304fn check_application_name(v: &str) -> Result<(), AwsServiceError> {
305    if len_ok(v, 1, 100) {
306        Ok(())
307    } else {
308        Err(e(
309            "InvalidApplicationNameException",
310            "The application name was specified in an invalid format.",
311        ))
312    }
313}
314
315fn req_deployment_group_name(b: &Value, key: &str) -> Result<String, AwsServiceError> {
316    match str_field(b, key) {
317        None => Err(e(
318            "DeploymentGroupNameRequiredException",
319            "The deployment group name was not specified.",
320        )),
321        Some(v) if !len_ok(&v, 1, 100) => Err(e(
322            "InvalidDeploymentGroupNameException",
323            "The deployment group name was specified in an invalid format.",
324        )),
325        Some(v) => Ok(v),
326    }
327}
328
329fn check_deployment_group_name(v: &str) -> Result<(), AwsServiceError> {
330    if len_ok(v, 1, 100) {
331        Ok(())
332    } else {
333        Err(e(
334            "InvalidDeploymentGroupNameException",
335            "The deployment group name was specified in an invalid format.",
336        ))
337    }
338}
339
340fn req_deployment_config_name(b: &Value) -> Result<String, AwsServiceError> {
341    match str_field(b, "deploymentConfigName") {
342        None => Err(e(
343            "DeploymentConfigNameRequiredException",
344            "The deployment configuration name was not specified.",
345        )),
346        Some(v) if !len_ok(&v, 1, 100) => Err(e(
347            "InvalidDeploymentConfigNameException",
348            "The deployment configuration name was specified in an invalid format.",
349        )),
350        Some(v) => Ok(v),
351    }
352}
353
354fn check_deployment_config_name(v: &str) -> Result<(), AwsServiceError> {
355    if len_ok(v, 1, 100) {
356        Ok(())
357    } else {
358        Err(e(
359            "InvalidDeploymentConfigNameException",
360            "The deployment configuration name was specified in an invalid format.",
361        ))
362    }
363}
364
365fn req_deployment_id(b: &Value) -> Result<String, AwsServiceError> {
366    match str_field(b, "deploymentId") {
367        None => Err(e(
368            "DeploymentIdRequiredException",
369            "The deployment ID was not specified.",
370        )),
371        Some(v) if v.is_empty() => Err(e(
372            "InvalidDeploymentIdException",
373            "The deployment ID was specified in an invalid format.",
374        )),
375        Some(v) => Ok(v),
376    }
377}
378
379fn req_instance_name(b: &Value) -> Result<String, AwsServiceError> {
380    match str_field(b, "instanceName") {
381        None => Err(e(
382            "InstanceNameRequiredException",
383            "An on-premises instance name was not specified.",
384        )),
385        Some(v) if v.is_empty() => Err(e(
386            "InvalidInstanceNameException",
387            "The on-premises instance name was specified in an invalid format.",
388        )),
389        Some(v) => Ok(v),
390    }
391}
392
393fn req_resource_arn(b: &Value) -> Result<String, AwsServiceError> {
394    match str_field(b, "ResourceArn") {
395        None => Err(e(
396            "ResourceArnRequiredException",
397            "The ARN of a resource is required, but was not specified.",
398        )),
399        Some(v) if !len_ok(&v, 1, 1011) || !is_codedeploy_arn(&v) => Err(e(
400            "InvalidArnException",
401            "The specified ARN is not in a valid format.",
402        )),
403        Some(v) => Ok(v),
404    }
405}
406
407/// Whether `s` is a syntactically valid CodeDeploy resource ARN of a supported
408/// type (`application`, `deploymentgroup`, or `deploymentconfig`).
409fn is_codedeploy_arn(s: &str) -> bool {
410    let parts: Vec<&str> = s.splitn(7, ':').collect();
411    if parts.len() < 7 || parts[0] != "arn" || parts[2] != "codedeploy" {
412        return false;
413    }
414    matches!(
415        parts[5],
416        "application" | "deploymentgroup" | "deploymentconfig"
417    )
418}
419
420fn application_arn(region: &str, account: &str, name: &str) -> String {
421    format!("arn:aws:codedeploy:{region}:{account}:application:{name}")
422}
423
424fn deployment_group_arn(region: &str, account: &str, app: &str, group: &str) -> String {
425    format!("arn:aws:codedeploy:{region}:{account}:deploymentgroup:{app}/{group}")
426}
427
428fn deployment_config_arn(region: &str, account: &str, name: &str) -> String {
429    format!("arn:aws:codedeploy:{region}:{account}:deploymentconfig:{name}")
430}
431
432fn opt_enum(
433    b: &Value,
434    key: &str,
435    set: &[&str],
436    code: &str,
437    msg: &str,
438) -> Result<(), AwsServiceError> {
439    if let Some(v) = str_field(b, key) {
440        if !validate::is_enum(set, &v) {
441            return Err(e(code, msg));
442        }
443    }
444    Ok(())
445}
446
447/// Merge an incoming `TagList` into `stored`, replacing any existing tag that
448/// shares a `Key` (last-write-wins), matching `TagResource` semantics.
449fn merge_tags(stored: &mut Vec<Value>, incoming: &[Value]) {
450    for t in incoming {
451        if let Some(k) = t.get("Key").and_then(Value::as_str).map(str::to_string) {
452            stored.retain(|x| x.get("Key").and_then(Value::as_str) != Some(k.as_str()));
453        }
454        stored.push(t.clone());
455    }
456}
457
458/// Validate a list of tag filters' `Type` members against `TagFilterType`,
459/// returning `err_code` for any value outside the model enum.
460fn validate_tag_filters(
461    b: &Value,
462    key: &str,
463    err_code: &str,
464    msg: &str,
465) -> Result<(), AwsServiceError> {
466    if let Some(filters) = b.get(key).and_then(Value::as_array) {
467        for f in filters {
468            if let Some(t) = f.get("Type").and_then(Value::as_str) {
469                if !validate::is_enum(validate::TAG_FILTER_TYPE, t) {
470                    return Err(e(err_code, msg));
471                }
472            }
473        }
474    }
475    Ok(())
476}
477
478/// Validate the nested enums shared by CreateDeploymentGroup and
479/// UpdateDeploymentGroup: EC2/on-premises tag-filter `Type`, trigger
480/// `triggerEvents`, and auto-rollback `events`. Each malformed member maps to
481/// the operation-declared `Invalid*Exception`.
482fn validate_deployment_group_config(b: &Value) -> Result<(), AwsServiceError> {
483    validate_tag_filters(
484        b,
485        "ec2TagFilters",
486        "InvalidEC2TagException",
487        "The EC2 tag was specified in an invalid format.",
488    )?;
489    validate_tag_filters(
490        b,
491        "onPremisesInstanceTagFilters",
492        "InvalidOnPremisesTagCombinationException",
493        "The on-premises instance tag was specified in an invalid format.",
494    )?;
495    if let Some(triggers) = b.get("triggerConfigurations").and_then(Value::as_array) {
496        for tc in triggers {
497            if let Some(events) = tc.get("triggerEvents").and_then(Value::as_array) {
498                for ev in events.iter().filter_map(Value::as_str) {
499                    if !validate::is_enum(validate::TRIGGER_EVENT_TYPE, ev) {
500                        return Err(e(
501                            "InvalidTriggerConfigException",
502                            "The trigger was specified in an invalid format.",
503                        ));
504                    }
505                }
506            }
507        }
508    }
509    if let Some(events) = b
510        .get("autoRollbackConfiguration")
511        .and_then(|c| c.get("events"))
512        .and_then(Value::as_array)
513    {
514        for ev in events.iter().filter_map(Value::as_str) {
515            if !validate::is_enum(validate::AUTO_ROLLBACK_EVENT, ev) {
516                return Err(e(
517                    "InvalidAutoRollbackConfigException",
518                    "The automatic rollback configuration was specified in an invalid format.",
519                ));
520            }
521        }
522    }
523    Ok(())
524}
525
526/// Validate a `minimumHealthyHosts` structure's `type` enum and `value` range
527/// for CreateDeploymentConfig, returning the declared
528/// `InvalidMinimumHealthyHostValueException` for a bad type
529/// (must be HOST_COUNT|FLEET_PERCENT) or an out-of-range value
530/// (FLEET_PERCENT: 0-100; HOST_COUNT: >= 0).
531fn validate_minimum_healthy_hosts(b: &Value) -> Result<(), AwsServiceError> {
532    let Some(mhh) = b.get("minimumHealthyHosts").filter(|v| !v.is_null()) else {
533        return Ok(());
534    };
535    let bad = || {
536        e(
537            "InvalidMinimumHealthyHostValueException",
538            "The minimum healthy instances value was specified in an invalid format.",
539        )
540    };
541    let mhh_type = mhh.get("type").and_then(Value::as_str);
542    if let Some(t) = mhh_type {
543        if !validate::is_enum(validate::MINIMUM_HEALTHY_HOSTS_TYPE, t) {
544            return Err(bad());
545        }
546    }
547    if let Some(value) = mhh.get("value").and_then(Value::as_i64) {
548        let out_of_range = match mhh_type {
549            Some("FLEET_PERCENT") => !(0..=100).contains(&value),
550            _ => value < 0,
551        };
552        if out_of_range {
553            return Err(bad());
554        }
555    }
556    Ok(())
557}
558
559/// Pull `nextToken` from the body and paginate a list of JSON values into
560/// `{list_key: [...], nextToken?}`. CodeDeploy list ops have no `maxResults`
561/// member, so a fixed page size of 100 matches the service default.
562fn paginate_body(items: Vec<Value>, list_key: &str, b: &Value) -> Result<Value, AwsServiceError> {
563    let token = str_field(b, "nextToken");
564    let (page, next) = paginate_checked(&items, token.as_deref(), 100).map_err(|_| {
565        e(
566            "InvalidNextTokenException",
567            "The next token was specified in an invalid format.",
568        )
569    })?;
570    let mut out = Map::new();
571    out.insert(list_key.to_string(), Value::Array(page));
572    if let Some(n) = next {
573        out.insert("nextToken".to_string(), Value::String(n));
574    }
575    Ok(Value::Object(out))
576}
577
578impl CodeDeployService {
579    fn region_account(&self, req: &AwsRequest) -> (String, String) {
580        (req.region.clone(), req.account_id.clone())
581    }
582}
583
584// ---------------------------------------------------------------------------
585// Predefined deployment configurations
586// ---------------------------------------------------------------------------
587
588/// Return the synthesized `DeploymentConfigInfo` for a predefined
589/// `CodeDeployDefault.*` configuration, or `None` if `name` is not predefined.
590fn predefined_config(name: &str) -> Option<Value> {
591    let server = |mhh_type: &str, value: i64| {
592        json!({
593            "deploymentConfigId": name,
594            "deploymentConfigName": name,
595            "computePlatform": "Server",
596            "minimumHealthyHosts": { "type": mhh_type, "value": value },
597        })
598    };
599    let canary = |platform: &str, pct: i64, mins: i64| {
600        json!({
601            "deploymentConfigId": name,
602            "deploymentConfigName": name,
603            "computePlatform": platform,
604            "trafficRoutingConfig": {
605                "type": "TimeBasedCanary",
606                "timeBasedCanary": { "canaryPercentage": pct, "canaryInterval": mins },
607            },
608        })
609    };
610    let linear = |platform: &str, pct: i64, mins: i64| {
611        json!({
612            "deploymentConfigId": name,
613            "deploymentConfigName": name,
614            "computePlatform": platform,
615            "trafficRoutingConfig": {
616                "type": "TimeBasedLinear",
617                "timeBasedLinear": { "linearPercentage": pct, "linearInterval": mins },
618            },
619        })
620    };
621    let all_at_once = |platform: &str| {
622        json!({
623            "deploymentConfigId": name,
624            "deploymentConfigName": name,
625            "computePlatform": platform,
626            "trafficRoutingConfig": { "type": "AllAtOnce" },
627        })
628    };
629    match name {
630        // EC2/On-Premises (Server).
631        "CodeDeployDefault.OneAtATime" => Some(server("HOST_COUNT", 99)),
632        "CodeDeployDefault.HalfAtATime" => Some(server("FLEET_PERCENT", 50)),
633        "CodeDeployDefault.AllAtOnce" => Some(server("HOST_COUNT", 0)),
634        // Lambda.
635        "CodeDeployDefault.LambdaAllAtOnce" => Some(all_at_once("Lambda")),
636        "CodeDeployDefault.LambdaCanary10Percent5Minutes" => Some(canary("Lambda", 10, 5)),
637        "CodeDeployDefault.LambdaCanary10Percent10Minutes" => Some(canary("Lambda", 10, 10)),
638        "CodeDeployDefault.LambdaCanary10Percent15Minutes" => Some(canary("Lambda", 10, 15)),
639        "CodeDeployDefault.LambdaCanary10Percent30Minutes" => Some(canary("Lambda", 10, 30)),
640        "CodeDeployDefault.LambdaLinear10PercentEvery1Minute" => Some(linear("Lambda", 10, 1)),
641        "CodeDeployDefault.LambdaLinear10PercentEvery2Minutes" => Some(linear("Lambda", 10, 2)),
642        "CodeDeployDefault.LambdaLinear10PercentEvery3Minutes" => Some(linear("Lambda", 10, 3)),
643        "CodeDeployDefault.LambdaLinear10PercentEvery10Minutes" => Some(linear("Lambda", 10, 10)),
644        // ECS.
645        "CodeDeployDefault.ECSAllAtOnce" => Some(all_at_once("ECS")),
646        "CodeDeployDefault.ECSCanary10Percent5Minutes" => Some(canary("ECS", 10, 5)),
647        "CodeDeployDefault.ECSCanary10Percent15Minutes" => Some(canary("ECS", 10, 15)),
648        "CodeDeployDefault.ECSLinear10PercentEvery1Minutes" => Some(linear("ECS", 10, 1)),
649        "CodeDeployDefault.ECSLinear10PercentEvery3Minutes" => Some(linear("ECS", 10, 3)),
650        _ => None,
651    }
652}
653
654const PREDEFINED_CONFIG_NAMES: &[&str] = &[
655    "CodeDeployDefault.OneAtATime",
656    "CodeDeployDefault.HalfAtATime",
657    "CodeDeployDefault.AllAtOnce",
658    "CodeDeployDefault.LambdaAllAtOnce",
659    "CodeDeployDefault.LambdaCanary10Percent5Minutes",
660    "CodeDeployDefault.LambdaCanary10Percent10Minutes",
661    "CodeDeployDefault.LambdaCanary10Percent15Minutes",
662    "CodeDeployDefault.LambdaCanary10Percent30Minutes",
663    "CodeDeployDefault.LambdaLinear10PercentEvery1Minute",
664    "CodeDeployDefault.LambdaLinear10PercentEvery2Minutes",
665    "CodeDeployDefault.LambdaLinear10PercentEvery3Minutes",
666    "CodeDeployDefault.LambdaLinear10PercentEvery10Minutes",
667    "CodeDeployDefault.ECSAllAtOnce",
668    "CodeDeployDefault.ECSCanary10Percent5Minutes",
669    "CodeDeployDefault.ECSCanary10Percent15Minutes",
670    "CodeDeployDefault.ECSLinear10PercentEvery1Minutes",
671    "CodeDeployDefault.ECSLinear10PercentEvery3Minutes",
672];
673
674// ---------------------------------------------------------------------------
675// Applications
676// ---------------------------------------------------------------------------
677
678impl CodeDeployService {
679    fn create_application(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
680        let b = body(req);
681        let name = req_application_name(&b)?;
682        opt_enum(
683            &b,
684            "computePlatform",
685            validate::COMPUTE_PLATFORM,
686            "InvalidComputePlatformException",
687            "The computePlatform is invalid.",
688        )?;
689        let (region, account) = self.region_account(req);
690        let compute = str_field(&b, "computePlatform").unwrap_or_else(|| "Server".into());
691        let app_id = gen_uuid();
692        let now = Utc::now();
693        let mut guard = self.state.write();
694        let st = guard.get_or_create(&account);
695        if st.applications.contains_key(&name) {
696            return Err(e(
697                "ApplicationAlreadyExistsException",
698                format!("An application already exists with the name {name}."),
699            ));
700        }
701        let info = json!({
702            "applicationId": app_id,
703            "applicationName": name,
704            "createTime": ts(now),
705            "linkedToGitHub": false,
706            "computePlatform": compute,
707        });
708        st.applications.insert(name.clone(), info);
709        st.application_order.push(name.clone());
710        // Persist create-time `tags` under the application ARN so
711        // ListTagsForResource echoes them, matching TagResource storage.
712        if let Some(tags) = b.get("tags").and_then(Value::as_array) {
713            if !tags.is_empty() {
714                let arn = application_arn(&region, &account, &name);
715                merge_tags(st.tags.entry(arn).or_default(), tags);
716            }
717        }
718        ok(json!({ "applicationId": app_id }))
719    }
720
721    fn get_application(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
722        let b = body(req);
723        let name = req_application_name(&b)?;
724        let (_region, account) = self.region_account(req);
725        let mut guard = self.state.write();
726        let st = guard.get_or_create(&account);
727        let Some(info) = st.applications.get(&name) else {
728            return Err(e(
729                "ApplicationDoesNotExistException",
730                format!("No application found for name: {name}"),
731            ));
732        };
733        ok(json!({ "application": info.clone() }))
734    }
735
736    fn update_application(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
737        let b = body(req);
738        // Both names are optional in the model but length-constrained.
739        if let Some(v) = str_field(&b, "applicationName") {
740            check_application_name(&v)?;
741        }
742        if let Some(v) = str_field(&b, "newApplicationName") {
743            check_application_name(&v)?;
744        }
745        let Some(current) = str_field(&b, "applicationName") else {
746            return Err(e(
747                "ApplicationNameRequiredException",
748                "The minimum number of required application inputs was not satisfied.",
749            ));
750        };
751        let (_region, account) = self.region_account(req);
752        let mut guard = self.state.write();
753        let st = guard.get_or_create(&account);
754        if !st.applications.contains_key(&current) {
755            return Err(e(
756                "ApplicationDoesNotExistException",
757                format!("No application found for name: {current}"),
758            ));
759        }
760        if let Some(new_name) = str_field(&b, "newApplicationName") {
761            if new_name != current && st.applications.contains_key(&new_name) {
762                return Err(e(
763                    "ApplicationAlreadyExistsException",
764                    format!("An application already exists with the name {new_name}."),
765                ));
766            }
767            if new_name != current {
768                let mut info = st.applications.remove(&current).unwrap();
769                if let Some(obj) = info.as_object_mut() {
770                    obj.insert("applicationName".into(), json!(new_name));
771                }
772                st.applications.insert(new_name.clone(), info);
773                for n in st.application_order.iter_mut() {
774                    if *n == current {
775                        *n = new_name.clone();
776                    }
777                }
778                if let Some(revs) = st.app_revisions.remove(&current) {
779                    st.app_revisions.insert(new_name.clone(), revs);
780                }
781                // Re-key the deployment groups and rewrite the embedded
782                // `applicationName` in each stored `DeploymentGroupInfo` so a
783                // later GetDeploymentGroup reports the renamed application.
784                if let Some(mut groups) = st.deployment_groups.remove(&current) {
785                    for group in groups.values_mut() {
786                        if let Some(obj) = group.as_object_mut() {
787                            obj.insert("applicationName".into(), json!(new_name));
788                        }
789                    }
790                    st.deployment_groups.insert(new_name.clone(), groups);
791                }
792                // Rewrite the `applicationName` on every stored deployment that
793                // referenced the old name so ListDeployments(new-name) and
794                // GetDeployment report the renamed application.
795                for deployment in st.deployments.values_mut() {
796                    if deployment.get("applicationName").and_then(Value::as_str)
797                        == Some(current.as_str())
798                    {
799                        if let Some(obj) = deployment.as_object_mut() {
800                            obj.insert("applicationName".into(), json!(new_name));
801                        }
802                    }
803                }
804            }
805        }
806        ok(json!({}))
807    }
808
809    fn delete_application(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
810        let b = body(req);
811        let name = req_application_name(&b)?;
812        let (region, account) = self.region_account(req);
813        let mut guard = self.state.write();
814        let st = guard.get_or_create(&account);
815        // DeleteApplication is idempotent: its Smithy contract declares no
816        // "does not exist" error, so a missing application is a silent success.
817        st.applications.remove(&name);
818        st.application_order.retain(|n| n != &name);
819        st.app_revisions.remove(&name);
820        // Purge tags for the application and every deployment group under it, so
821        // a later same-named resource does not resurrect stale tags.
822        st.tags.remove(&application_arn(&region, &account, &name));
823        if let Some(groups) = st.deployment_groups.remove(&name) {
824            for dg in groups.keys() {
825                st.tags
826                    .remove(&deployment_group_arn(&region, &account, &name, dg));
827            }
828        }
829        ok(json!({}))
830    }
831
832    fn list_applications(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
833        let b = body(req);
834        let (_region, account) = self.region_account(req);
835        let mut guard = self.state.write();
836        let st = guard.get_or_create(&account);
837        let names: Vec<Value> = st.application_order.iter().map(|n| json!(n)).collect();
838        ok(paginate_body(names, "applications", &b)?)
839    }
840
841    fn batch_get_applications(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
842        let b = body(req);
843        let names = str_list(&b, "applicationNames");
844        let (_region, account) = self.region_account(req);
845        let mut guard = self.state.write();
846        let st = guard.get_or_create(&account);
847        // The output shape carries no per-item error field, so AWS raises a
848        // whole-call `ApplicationDoesNotExistException` (declared) when any
849        // requested name is unknown rather than silently omitting it.
850        let mut infos = Vec::with_capacity(names.len());
851        for n in &names {
852            match st.applications.get(n) {
853                Some(info) => infos.push(info.clone()),
854                None => {
855                    return Err(e(
856                        "ApplicationDoesNotExistException",
857                        format!("No application found for name: {n}"),
858                    ))
859                }
860            }
861        }
862        ok(json!({ "applicationsInfo": infos }))
863    }
864}
865
866// ---------------------------------------------------------------------------
867// Application revisions
868// ---------------------------------------------------------------------------
869
870/// Validate a `RevisionLocation` structure's `revisionType` enum, when present.
871fn validate_revision(rev: &Value) -> Result<(), AwsServiceError> {
872    if let Some(t) = rev.get("revisionType").and_then(Value::as_str) {
873        if !validate::is_enum(validate::REVISION_LOCATION_TYPE, t) {
874            return Err(e(
875                "InvalidRevisionException",
876                "The revision was specified in an invalid format.",
877            ));
878        }
879    }
880    Ok(())
881}
882
883impl CodeDeployService {
884    fn register_application_revision(
885        &self,
886        req: &AwsRequest,
887    ) -> Result<AwsResponse, AwsServiceError> {
888        let b = body(req);
889        let name = req_application_name(&b)?;
890        let Some(revision) = b.get("revision").filter(|v| !v.is_null()).cloned() else {
891            return Err(e(
892                "RevisionRequiredException",
893                "The revision ID was not specified.",
894            ));
895        };
896        validate_revision(&revision)?;
897        let now = Utc::now();
898        let (_region, account) = self.region_account(req);
899        let mut guard = self.state.write();
900        let st = guard.get_or_create(&account);
901        if !st.applications.contains_key(&name) {
902            return Err(e(
903                "ApplicationDoesNotExistException",
904                format!("No application found for name: {name}"),
905            ));
906        }
907        let description = str_field(&b, "description");
908        let revs = st.app_revisions.entry(name).or_default();
909        // If this revision location was already registered, refresh its last-used
910        // time; otherwise append a new record.
911        if let Some(existing) = revs
912            .iter_mut()
913            .find(|r| r.get("revisionLocation") == Some(&revision))
914        {
915            if let Some(obj) = existing.as_object_mut() {
916                let generic = obj
917                    .entry("genericRevisionInfo")
918                    .or_insert_with(|| json!({}));
919                if let Some(g) = generic.as_object_mut() {
920                    g.insert("lastUsedTime".into(), ts(now));
921                    if let Some(d) = &description {
922                        g.insert("description".into(), json!(d));
923                    }
924                }
925            }
926        } else {
927            let mut generic = Map::new();
928            generic.insert("registerTime".into(), ts(now));
929            generic.insert("firstUsedTime".into(), ts(now));
930            generic.insert("lastUsedTime".into(), ts(now));
931            generic.insert("deploymentGroups".into(), json!([]));
932            if let Some(d) = &description {
933                generic.insert("description".into(), json!(d));
934            }
935            revs.push(json!({
936                "revisionLocation": revision,
937                "genericRevisionInfo": Value::Object(generic),
938            }));
939        }
940        ok(json!({}))
941    }
942
943    fn get_application_revision(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
944        let b = body(req);
945        let name = req_application_name(&b)?;
946        let Some(revision) = b.get("revision").filter(|v| !v.is_null()).cloned() else {
947            return Err(e(
948                "RevisionRequiredException",
949                "The revision ID was not specified.",
950            ));
951        };
952        validate_revision(&revision)?;
953        let (_region, account) = self.region_account(req);
954        let mut guard = self.state.write();
955        let st = guard.get_or_create(&account);
956        if !st.applications.contains_key(&name) {
957            return Err(e(
958                "ApplicationDoesNotExistException",
959                format!("No application found for name: {name}"),
960            ));
961        }
962        let record = st.app_revisions.get(&name).and_then(|revs| {
963            revs.iter()
964                .find(|r| r.get("revisionLocation") == Some(&revision))
965        });
966        let Some(record) = record else {
967            return Err(e(
968                "RevisionDoesNotExistException",
969                "The named revision does not exist with the applicable IAM user or AWS account.",
970            ));
971        };
972        ok(json!({
973            "applicationName": name,
974            "revision": revision,
975            "revisionInfo": record.get("genericRevisionInfo").cloned().unwrap_or_else(|| json!({})),
976        }))
977    }
978
979    fn list_application_revisions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
980        let b = body(req);
981        let name = req_application_name(&b)?;
982        opt_enum(&b, "sortBy", validate::APPLICATION_REVISION_SORT_BY, "InvalidSortByException", "The column name to sort by is either not present or was specified in an invalid format.")?;
983        opt_enum(
984            &b,
985            "sortOrder",
986            validate::SORT_ORDER,
987            "InvalidSortOrderException",
988            "The sort order was specified in an invalid format.",
989        )?;
990        opt_enum(
991            &b,
992            "deployed",
993            validate::LIST_STATE_FILTER_ACTION,
994            "InvalidDeployedStateFilterException",
995            "The deployed state filter was specified in an invalid format.",
996        )?;
997        let (_region, account) = self.region_account(req);
998        let mut guard = self.state.write();
999        let st = guard.get_or_create(&account);
1000        if !st.applications.contains_key(&name) {
1001            return Err(e(
1002                "ApplicationDoesNotExistException",
1003                format!("No application found for name: {name}"),
1004            ));
1005        }
1006        // A revision counts as "deployed" when it has been used by at least one
1007        // deployment group (a non-empty `deploymentGroups` in its generic info).
1008        let is_deployed = |r: &Value| -> bool {
1009            r.get("genericRevisionInfo")
1010                .and_then(|g| g.get("deploymentGroups"))
1011                .and_then(Value::as_array)
1012                .map(|a| !a.is_empty())
1013                .unwrap_or(false)
1014        };
1015        let deployed_filter = str_field(&b, "deployed").unwrap_or_else(|| "ignore".into());
1016        let mut records: Vec<Value> = st
1017            .app_revisions
1018            .get(&name)
1019            .map(|revs| {
1020                revs.iter()
1021                    .filter(|r| match deployed_filter.as_str() {
1022                        "include" => is_deployed(r),
1023                        "exclude" => !is_deployed(r),
1024                        _ => true,
1025                    })
1026                    .cloned()
1027                    .collect()
1028            })
1029            .unwrap_or_default();
1030        // Order by the requested `sortBy` timestamp (default: registration
1031        // order) and `sortOrder` (default: ascending).
1032        if let Some(sort_by) = str_field(&b, "sortBy") {
1033            let key = |r: &Value| -> f64 {
1034                r.get("genericRevisionInfo")
1035                    .and_then(|g| g.get(sort_by.as_str()))
1036                    .and_then(Value::as_f64)
1037                    .unwrap_or(0.0)
1038            };
1039            records.sort_by(|x, y| {
1040                key(x)
1041                    .partial_cmp(&key(y))
1042                    .unwrap_or(std::cmp::Ordering::Equal)
1043            });
1044            if str_field(&b, "sortOrder").as_deref() == Some("descending") {
1045                records.reverse();
1046            }
1047        }
1048        let revisions: Vec<Value> = records
1049            .iter()
1050            .filter_map(|r| r.get("revisionLocation").cloned())
1051            .collect();
1052        ok(paginate_body(revisions, "revisions", &b)?)
1053    }
1054
1055    fn batch_get_application_revisions(
1056        &self,
1057        req: &AwsRequest,
1058    ) -> Result<AwsResponse, AwsServiceError> {
1059        let b = body(req);
1060        let name = req_application_name(&b)?;
1061        let requested = b
1062            .get("revisions")
1063            .and_then(Value::as_array)
1064            .cloned()
1065            .unwrap_or_default();
1066        let (_region, account) = self.region_account(req);
1067        let mut guard = self.state.write();
1068        let st = guard.get_or_create(&account);
1069        if !st.applications.contains_key(&name) {
1070            return Err(e(
1071                "ApplicationDoesNotExistException",
1072                format!("No application found for name: {name}"),
1073            ));
1074        }
1075        let stored = st.app_revisions.get(&name);
1076        let infos: Vec<Value> = requested
1077            .into_iter()
1078            .map(|loc| {
1079                let generic = stored
1080                    .and_then(|revs| {
1081                        revs.iter()
1082                            .find(|r| r.get("revisionLocation") == Some(&loc))
1083                    })
1084                    .and_then(|r| r.get("genericRevisionInfo").cloned())
1085                    .unwrap_or_else(|| json!({}));
1086                json!({ "revisionLocation": loc, "genericRevisionInfo": generic })
1087            })
1088            .collect();
1089        ok(json!({ "applicationName": name, "revisions": infos }))
1090    }
1091}
1092
1093// ---------------------------------------------------------------------------
1094// Deployment groups
1095// ---------------------------------------------------------------------------
1096
1097/// Resolved identifiers for assembling a `DeploymentGroupInfo`.
1098struct DeploymentGroupIdent<'a> {
1099    app: &'a str,
1100    dg_name: &'a str,
1101    dg_id: &'a str,
1102    service_role: &'a str,
1103    config_name: &'a str,
1104    compute: &'a str,
1105}
1106
1107impl CodeDeployService {
1108    /// Resolve the compute platform of an application (default `Server`).
1109    fn app_compute_platform(st: &CodeDeployState, app: &str) -> String {
1110        st.applications
1111            .get(app)
1112            .and_then(|a| a.get("computePlatform").and_then(Value::as_str))
1113            .unwrap_or("Server")
1114            .to_string()
1115    }
1116
1117    /// Assemble a `DeploymentGroupInfo` from a create input body plus the
1118    /// resolved identifiers.
1119    fn assemble_deployment_group(&self, b: &Value, ident: &DeploymentGroupIdent) -> Value {
1120        let mut g = Map::new();
1121        g.insert("applicationName".into(), json!(ident.app));
1122        g.insert("deploymentGroupId".into(), json!(ident.dg_id));
1123        g.insert("deploymentGroupName".into(), json!(ident.dg_name));
1124        g.insert("deploymentConfigName".into(), json!(ident.config_name));
1125        g.insert("computePlatform".into(), json!(ident.compute));
1126        g.insert("serviceRoleArn".into(), json!(ident.service_role));
1127        for k in [
1128            "ec2TagFilters",
1129            "onPremisesInstanceTagFilters",
1130            "autoScalingGroups",
1131            "triggerConfigurations",
1132            "alarmConfiguration",
1133            "autoRollbackConfiguration",
1134            "deploymentStyle",
1135            "outdatedInstancesStrategy",
1136            "blueGreenDeploymentConfiguration",
1137            "loadBalancerInfo",
1138            "ec2TagSet",
1139            "onPremisesTagSet",
1140            "ecsServices",
1141            "terminationHookEnabled",
1142        ] {
1143            if let Some(v) = b.get(k).filter(|v| !v.is_null()) {
1144                g.insert(k.into(), v.clone());
1145            }
1146        }
1147        Value::Object(g)
1148    }
1149
1150    fn create_deployment_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1151        let b = body(req);
1152        let app = req_application_name(&b)?;
1153        let dg_name = req_deployment_group_name(&b, "deploymentGroupName")?;
1154        if let Some(v) = str_field(&b, "deploymentConfigName") {
1155            check_deployment_config_name(&v)?;
1156        }
1157        opt_enum(
1158            &b,
1159            "outdatedInstancesStrategy",
1160            validate::OUTDATED_INSTANCES_STRATEGY,
1161            "InvalidInputException",
1162            "The outdated instances strategy is invalid.",
1163        )?;
1164        validate_deployment_group_config(&b)?;
1165        let Some(service_role) = str_field(&b, "serviceRoleArn") else {
1166            return Err(e("RoleRequiredException", "The role ID was not specified."));
1167        };
1168        let (region, account) = self.region_account(req);
1169        let config_name = str_field(&b, "deploymentConfigName")
1170            .unwrap_or_else(|| "CodeDeployDefault.OneAtATime".into());
1171        let dg_id = gen_uuid();
1172        let mut guard = self.state.write();
1173        let st = guard.get_or_create(&account);
1174        if !st.applications.contains_key(&app) {
1175            return Err(e(
1176                "ApplicationDoesNotExistException",
1177                format!("No application found for name: {app}"),
1178            ));
1179        }
1180        if predefined_config(&config_name).is_none()
1181            && !st.deployment_configs.contains_key(&config_name)
1182        {
1183            return Err(e(
1184                "DeploymentConfigDoesNotExistException",
1185                format!("No deployment configuration found for name: {config_name}"),
1186            ));
1187        }
1188        let groups = st.deployment_groups.entry(app.clone()).or_default();
1189        if groups.contains_key(&dg_name) {
1190            return Err(e(
1191                "DeploymentGroupAlreadyExistsException",
1192                format!("A deployment group with the name {dg_name} already exists."),
1193            ));
1194        }
1195        let compute = Self::app_compute_platform(st, &app);
1196        let group = self.assemble_deployment_group(
1197            &b,
1198            &DeploymentGroupIdent {
1199                app: &app,
1200                dg_name: &dg_name,
1201                dg_id: &dg_id,
1202                service_role: &service_role,
1203                config_name: &config_name,
1204                compute: &compute,
1205            },
1206        );
1207        st.deployment_groups
1208            .entry(app.clone())
1209            .or_default()
1210            .insert(dg_name.clone(), group);
1211        // Persist create-time `tags` under the deployment group ARN so
1212        // ListTagsForResource echoes them.
1213        if let Some(tags) = b.get("tags").and_then(Value::as_array) {
1214            if !tags.is_empty() {
1215                let arn = deployment_group_arn(&region, &account, &app, &dg_name);
1216                merge_tags(st.tags.entry(arn).or_default(), tags);
1217            }
1218        }
1219        ok(json!({ "deploymentGroupId": dg_id }))
1220    }
1221
1222    fn get_deployment_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1223        let b = body(req);
1224        let app = req_application_name(&b)?;
1225        let dg_name = req_deployment_group_name(&b, "deploymentGroupName")?;
1226        let (_region, account) = self.region_account(req);
1227        let mut guard = self.state.write();
1228        let st = guard.get_or_create(&account);
1229        if !st.applications.contains_key(&app) {
1230            return Err(e(
1231                "ApplicationDoesNotExistException",
1232                format!("No application found for name: {app}"),
1233            ));
1234        }
1235        let Some(group) = st.deployment_groups.get(&app).and_then(|g| g.get(&dg_name)) else {
1236            return Err(e(
1237                "DeploymentGroupDoesNotExistException",
1238                format!("No deployment group found for name: {dg_name}"),
1239            ));
1240        };
1241        ok(json!({ "deploymentGroupInfo": group.clone() }))
1242    }
1243
1244    fn update_deployment_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1245        let b = body(req);
1246        let app = req_application_name(&b)?;
1247        let current = req_deployment_group_name(&b, "currentDeploymentGroupName")?;
1248        if let Some(v) = str_field(&b, "newDeploymentGroupName") {
1249            check_deployment_group_name(&v)?;
1250        }
1251        if let Some(v) = str_field(&b, "deploymentConfigName") {
1252            check_deployment_config_name(&v)?;
1253        }
1254        opt_enum(
1255            &b,
1256            "outdatedInstancesStrategy",
1257            validate::OUTDATED_INSTANCES_STRATEGY,
1258            "InvalidInputException",
1259            "The outdated instances strategy is invalid.",
1260        )?;
1261        validate_deployment_group_config(&b)?;
1262        let (_region, account) = self.region_account(req);
1263        let mut guard = self.state.write();
1264        let st = guard.get_or_create(&account);
1265        if !st.applications.contains_key(&app) {
1266            return Err(e(
1267                "ApplicationDoesNotExistException",
1268                format!("No application found for name: {app}"),
1269            ));
1270        }
1271        let new_name = str_field(&b, "newDeploymentGroupName");
1272        if let Some(new) = &new_name {
1273            if new != &current
1274                && st
1275                    .deployment_groups
1276                    .get(&app)
1277                    .map(|g| g.contains_key(new))
1278                    .unwrap_or(false)
1279            {
1280                return Err(e(
1281                    "DeploymentGroupAlreadyExistsException",
1282                    format!("A deployment group with the name {new} already exists."),
1283                ));
1284            }
1285        }
1286        let Some(existing) = st
1287            .deployment_groups
1288            .get(&app)
1289            .and_then(|g| g.get(&current))
1290            .cloned()
1291        else {
1292            return Err(e(
1293                "DeploymentGroupDoesNotExistException",
1294                format!("No deployment group found for name: {current}"),
1295            ));
1296        };
1297        let mut merged = existing;
1298        if let Some(obj) = merged.as_object_mut() {
1299            for k in [
1300                "deploymentConfigName",
1301                "ec2TagFilters",
1302                "onPremisesInstanceTagFilters",
1303                "autoScalingGroups",
1304                "serviceRoleArn",
1305                "triggerConfigurations",
1306                "alarmConfiguration",
1307                "autoRollbackConfiguration",
1308                "deploymentStyle",
1309                "outdatedInstancesStrategy",
1310                "blueGreenDeploymentConfiguration",
1311                "loadBalancerInfo",
1312                "ec2TagSet",
1313                "onPremisesTagSet",
1314                "ecsServices",
1315                "terminationHookEnabled",
1316            ] {
1317                if let Some(v) = b.get(k).filter(|v| !v.is_null()) {
1318                    obj.insert(k.into(), v.clone());
1319                }
1320            }
1321            if let Some(new) = &new_name {
1322                obj.insert("deploymentGroupName".into(), json!(new));
1323            }
1324        }
1325        let groups = st.deployment_groups.entry(app).or_default();
1326        groups.remove(&current);
1327        let final_name = new_name.unwrap_or(current);
1328        groups.insert(final_name, merged);
1329        ok(json!({ "hooksNotCleanedUp": [] }))
1330    }
1331
1332    fn delete_deployment_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1333        let b = body(req);
1334        let app = req_application_name(&b)?;
1335        let dg_name = req_deployment_group_name(&b, "deploymentGroupName")?;
1336        let (region, account) = self.region_account(req);
1337        let mut guard = self.state.write();
1338        let st = guard.get_or_create(&account);
1339        // Idempotent: the Smithy contract declares no "does not exist" error.
1340        if let Some(groups) = st.deployment_groups.get_mut(&app) {
1341            groups.remove(&dg_name);
1342        }
1343        // Purge tags so a later same-named group does not resurrect stale tags.
1344        st.tags
1345            .remove(&deployment_group_arn(&region, &account, &app, &dg_name));
1346        ok(json!({ "hooksNotCleanedUp": [] }))
1347    }
1348
1349    fn list_deployment_groups(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1350        let b = body(req);
1351        let app = req_application_name(&b)?;
1352        let (_region, account) = self.region_account(req);
1353        let mut guard = self.state.write();
1354        let st = guard.get_or_create(&account);
1355        if !st.applications.contains_key(&app) {
1356            return Err(e(
1357                "ApplicationDoesNotExistException",
1358                format!("No application found for name: {app}"),
1359            ));
1360        }
1361        let names: Vec<Value> = st
1362            .deployment_groups
1363            .get(&app)
1364            .map(|g| g.keys().map(|k| json!(k)).collect())
1365            .unwrap_or_default();
1366        let out = paginate_body(names, "deploymentGroups", &b)?;
1367        let mut out = out.as_object().cloned().unwrap_or_default();
1368        out.insert("applicationName".into(), json!(app));
1369        ok(Value::Object(out))
1370    }
1371
1372    fn batch_get_deployment_groups(
1373        &self,
1374        req: &AwsRequest,
1375    ) -> Result<AwsResponse, AwsServiceError> {
1376        let b = body(req);
1377        let app = req_application_name(&b)?;
1378        let names = str_list(&b, "deploymentGroupNames");
1379        let (_region, account) = self.region_account(req);
1380        let mut guard = self.state.write();
1381        let st = guard.get_or_create(&account);
1382        if !st.applications.contains_key(&app) {
1383            return Err(e(
1384                "ApplicationDoesNotExistException",
1385                format!("No application found for name: {app}"),
1386            ));
1387        }
1388        let groups = st.deployment_groups.get(&app);
1389        let infos: Vec<Value> = names
1390            .iter()
1391            .filter_map(|n| groups.and_then(|g| g.get(n)).cloned())
1392            .collect();
1393        ok(json!({ "deploymentGroupsInfo": infos }))
1394    }
1395}
1396
1397// ---------------------------------------------------------------------------
1398// Deployment configurations
1399// ---------------------------------------------------------------------------
1400
1401impl CodeDeployService {
1402    fn create_deployment_config(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1403        let b = body(req);
1404        let name = req_deployment_config_name(&b)?;
1405        opt_enum(
1406            &b,
1407            "computePlatform",
1408            validate::COMPUTE_PLATFORM,
1409            "InvalidComputePlatformException",
1410            "The computePlatform is invalid.",
1411        )?;
1412        validate_minimum_healthy_hosts(&b)?;
1413        let (_region, account) = self.region_account(req);
1414        let compute = str_field(&b, "computePlatform").unwrap_or_else(|| "Server".into());
1415        let config_id = gen_uuid();
1416        let now = Utc::now();
1417        let mut guard = self.state.write();
1418        let st = guard.get_or_create(&account);
1419        if predefined_config(&name).is_some() || st.deployment_configs.contains_key(&name) {
1420            return Err(e(
1421                "DeploymentConfigAlreadyExistsException",
1422                format!("A deployment configuration already exists with the name {name}."),
1423            ));
1424        }
1425        let mut info = Map::new();
1426        info.insert("deploymentConfigId".into(), json!(config_id));
1427        info.insert("deploymentConfigName".into(), json!(name));
1428        info.insert("computePlatform".into(), json!(compute));
1429        info.insert("createTime".into(), ts(now));
1430        for k in ["minimumHealthyHosts", "trafficRoutingConfig", "zonalConfig"] {
1431            if let Some(v) = b.get(k).filter(|v| !v.is_null()) {
1432                info.insert(k.into(), v.clone());
1433            }
1434        }
1435        st.deployment_configs
1436            .insert(name.clone(), Value::Object(info));
1437        st.deployment_config_order.push(name);
1438        ok(json!({ "deploymentConfigId": config_id }))
1439    }
1440
1441    fn get_deployment_config(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1442        let b = body(req);
1443        let name = req_deployment_config_name(&b)?;
1444        if let Some(info) = predefined_config(&name) {
1445            return ok(json!({ "deploymentConfigInfo": info }));
1446        }
1447        let (_region, account) = self.region_account(req);
1448        let mut guard = self.state.write();
1449        let st = guard.get_or_create(&account);
1450        let Some(info) = st.deployment_configs.get(&name) else {
1451            return Err(e(
1452                "DeploymentConfigDoesNotExistException",
1453                format!("No deployment configuration found for name: {name}"),
1454            ));
1455        };
1456        ok(json!({ "deploymentConfigInfo": info.clone() }))
1457    }
1458
1459    fn delete_deployment_config(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1460        let b = body(req);
1461        let name = req_deployment_config_name(&b)?;
1462        if predefined_config(&name).is_some() {
1463            return Err(e(
1464                "InvalidOperationException",
1465                "A predefined deployment configuration cannot be deleted.",
1466            ));
1467        }
1468        let (region, account) = self.region_account(req);
1469        let mut guard = self.state.write();
1470        let st = guard.get_or_create(&account);
1471        if st.deployment_configs.remove(&name).is_some() {
1472            st.deployment_config_order.retain(|n| n != &name);
1473        }
1474        // Purge tags so a later same-named config does not resurrect stale tags.
1475        st.tags
1476            .remove(&deployment_config_arn(&region, &account, &name));
1477        ok(json!({}))
1478    }
1479
1480    fn list_deployment_configs(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1481        let b = body(req);
1482        let (_region, account) = self.region_account(req);
1483        let mut guard = self.state.write();
1484        let st = guard.get_or_create(&account);
1485        let mut names: Vec<Value> = PREDEFINED_CONFIG_NAMES.iter().map(|n| json!(n)).collect();
1486        names.extend(st.deployment_config_order.iter().map(|n| json!(n)));
1487        ok(paginate_body(names, "deploymentConfigsList", &b)?)
1488    }
1489}
1490
1491// ---------------------------------------------------------------------------
1492// Deployments
1493// ---------------------------------------------------------------------------
1494
1495/// Advance an in-progress deployment one lifecycle step on read. The first
1496/// read moves `Created` -> `InProgress` (stamping `startTime`); the next moves
1497/// it to a terminal `Succeeded` (stamping `completeTime` and a fully-succeeded
1498/// `deploymentOverview`). Terminal deployments are left untouched.
1499fn settle_deployment(info: &mut Value, step: &mut u8) {
1500    let status = info
1501        .get("status")
1502        .and_then(Value::as_str)
1503        .unwrap_or("Created");
1504    if matches!(status, "Succeeded" | "Failed" | "Stopped") {
1505        return;
1506    }
1507    let now = Utc::now();
1508    *step = step.saturating_add(1);
1509    if let Some(obj) = info.as_object_mut() {
1510        if *step == 1 {
1511            obj.insert("status".into(), json!("InProgress"));
1512            obj.entry("startTime").or_insert_with(|| ts(now));
1513            obj.insert(
1514                "deploymentOverview".into(),
1515                json!({ "Pending": 0, "InProgress": 1, "Succeeded": 0, "Failed": 0, "Skipped": 0, "Ready": 0 }),
1516            );
1517        } else {
1518            obj.insert("status".into(), json!("Succeeded"));
1519            obj.entry("startTime").or_insert_with(|| ts(now));
1520            obj.insert("completeTime".into(), ts(now));
1521            obj.insert(
1522                "deploymentOverview".into(),
1523                json!({ "Pending": 0, "InProgress": 0, "Succeeded": 1, "Failed": 0, "Skipped": 0, "Ready": 0 }),
1524            );
1525        }
1526    }
1527}
1528
1529impl CodeDeployService {
1530    fn create_deployment(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1531        let b = body(req);
1532        let app = req_application_name(&b)?;
1533        // A deployment must target a deployment group. The model declares
1534        // DeploymentGroupNameRequiredException for its absence and
1535        // InvalidDeploymentGroupNameException for a malformed value.
1536        let dg_name = req_deployment_group_name(&b, "deploymentGroupName")?;
1537        if let Some(v) = str_field(&b, "deploymentConfigName") {
1538            check_deployment_config_name(&v)?;
1539        }
1540        opt_enum(
1541            &b,
1542            "fileExistsBehavior",
1543            validate::FILE_EXISTS_BEHAVIOR,
1544            "InvalidFileExistsBehaviorException",
1545            "The specified fileExistsBehavior value is invalid.",
1546        )?;
1547        if let Some(rev) = b.get("revision").filter(|v| !v.is_null()) {
1548            validate_revision(rev)?;
1549        }
1550        let (_region, account) = self.region_account(req);
1551        let now = Utc::now();
1552        let mut guard = self.state.write();
1553        let st = guard.get_or_create(&account);
1554        if !st.applications.contains_key(&app) {
1555            return Err(e(
1556                "ApplicationDoesNotExistException",
1557                format!("No application found for name: {app}"),
1558            ));
1559        }
1560        if !st
1561            .deployment_groups
1562            .get(&app)
1563            .map(|g| g.contains_key(&dg_name))
1564            .unwrap_or(false)
1565        {
1566            return Err(e(
1567                "DeploymentGroupDoesNotExistException",
1568                format!("No deployment group found for name: {dg_name}"),
1569            ));
1570        }
1571        let config_name = str_field(&b, "deploymentConfigName").unwrap_or_else(|| {
1572            st.deployment_groups
1573                .get(&app)
1574                .and_then(|g| g.get(&dg_name))
1575                .and_then(|g| g.get("deploymentConfigName").and_then(Value::as_str))
1576                .unwrap_or("CodeDeployDefault.OneAtATime")
1577                .to_string()
1578        });
1579        if predefined_config(&config_name).is_none()
1580            && !st.deployment_configs.contains_key(&config_name)
1581        {
1582            return Err(e(
1583                "DeploymentConfigDoesNotExistException",
1584                format!("No deployment configuration found for name: {config_name}"),
1585            ));
1586        }
1587        let compute = Self::app_compute_platform(st, &app);
1588        // Inherit the target group's stored deploymentStyle; fall back to the
1589        // AWS default (in-place, no traffic control) only when the group has none.
1590        let deployment_style = st
1591            .deployment_groups
1592            .get(&app)
1593            .and_then(|g| g.get(&dg_name))
1594            .and_then(|g| g.get("deploymentStyle").cloned())
1595            .unwrap_or_else(|| {
1596                json!({ "deploymentType": "IN_PLACE", "deploymentOption": "WITHOUT_TRAFFIC_CONTROL" })
1597            });
1598        let deployment_id = gen_deployment_id();
1599        let mut info = Map::new();
1600        info.insert("deploymentId".into(), json!(deployment_id));
1601        info.insert("applicationName".into(), json!(app));
1602        info.insert("deploymentGroupName".into(), json!(dg_name));
1603        info.insert("deploymentConfigName".into(), json!(config_name));
1604        info.insert("computePlatform".into(), json!(compute));
1605        info.insert("status".into(), json!("Created"));
1606        info.insert("creator".into(), json!("user"));
1607        info.insert("createTime".into(), ts(now));
1608        info.insert("deploymentStyle".into(), deployment_style);
1609        info.insert(
1610            "ignoreApplicationStopFailures".into(),
1611            json!(b
1612                .get("ignoreApplicationStopFailures")
1613                .and_then(Value::as_bool)
1614                .unwrap_or(false)),
1615        );
1616        info.insert(
1617            "updateOutdatedInstancesOnly".into(),
1618            json!(b
1619                .get("updateOutdatedInstancesOnly")
1620                .and_then(Value::as_bool)
1621                .unwrap_or(false)),
1622        );
1623        for (in_key, out_key) in [
1624            ("revision", "revision"),
1625            ("description", "description"),
1626            ("autoRollbackConfiguration", "autoRollbackConfiguration"),
1627            ("fileExistsBehavior", "fileExistsBehavior"),
1628            ("targetInstances", "targetInstances"),
1629            ("overrideAlarmConfiguration", "overrideAlarmConfiguration"),
1630        ] {
1631            if let Some(v) = b.get(in_key).filter(|v| !v.is_null()) {
1632                info.insert(out_key.into(), v.clone());
1633            }
1634        }
1635        let info = Value::Object(info);
1636        st.deployments.insert(deployment_id.clone(), info);
1637        st.deployment_order.push(deployment_id.clone());
1638        st.deployment_settle.insert(deployment_id.clone(), 0);
1639        ok(json!({ "deploymentId": deployment_id }))
1640    }
1641
1642    fn get_deployment(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1643        let b = body(req);
1644        let id = req_deployment_id(&b)?;
1645        let (_region, account) = self.region_account(req);
1646        let mut guard = self.state.write();
1647        let st = guard.get_or_create(&account);
1648        let mut step = st.deployment_settle.get(&id).copied().unwrap_or(0);
1649        let Some(info) = st.deployments.get_mut(&id) else {
1650            return Err(e(
1651                "DeploymentDoesNotExistException",
1652                format!("The deployment {id} could not be found."),
1653            ));
1654        };
1655        settle_deployment(info, &mut step);
1656        let info = info.clone();
1657        st.deployment_settle.insert(id, step);
1658        ok(json!({ "deploymentInfo": info }))
1659    }
1660
1661    fn batch_get_deployments(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1662        let b = body(req);
1663        let ids = str_list(&b, "deploymentIds");
1664        let (_region, account) = self.region_account(req);
1665        let mut guard = self.state.write();
1666        let st = guard.get_or_create(&account);
1667        let mut infos = Vec::new();
1668        for id in ids {
1669            let mut step = st.deployment_settle.get(&id).copied().unwrap_or(0);
1670            if let Some(info) = st.deployments.get_mut(&id) {
1671                settle_deployment(info, &mut step);
1672                infos.push(info.clone());
1673                st.deployment_settle.insert(id, step);
1674            }
1675        }
1676        ok(json!({ "deploymentsInfo": infos }))
1677    }
1678
1679    fn list_deployments(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1680        let b = body(req);
1681        if let Some(v) = str_field(&b, "applicationName") {
1682            check_application_name(&v)?;
1683        }
1684        if let Some(v) = str_field(&b, "deploymentGroupName") {
1685            check_deployment_group_name(&v)?;
1686        }
1687        // A deploymentGroupName without an applicationName is invalid.
1688        if str_field(&b, "deploymentGroupName").is_some()
1689            && str_field(&b, "applicationName").is_none()
1690        {
1691            return Err(e(
1692                "ApplicationNameRequiredException",
1693                "The minimum number of required application inputs was not satisfied.",
1694            ));
1695        }
1696        // Validate the `includeOnlyStatuses` enum members up front.
1697        let status_filter: Vec<String> = str_list(&b, "includeOnlyStatuses");
1698        for s in &status_filter {
1699            if !validate::is_enum(validate::DEPLOYMENT_STATUS, s) {
1700                return Err(e(
1701                    "InvalidDeploymentStatusException",
1702                    format!("The deployment status is invalid: {s}"),
1703                ));
1704            }
1705        }
1706        // Validate the `createTimeRange` bounds (start/end epoch seconds).
1707        let time_range = b.get("createTimeRange").filter(|v| !v.is_null());
1708        let range_start = time_range
1709            .and_then(|r| r.get("start"))
1710            .and_then(Value::as_f64);
1711        let range_end = time_range
1712            .and_then(|r| r.get("end"))
1713            .and_then(Value::as_f64);
1714        if let (Some(start), Some(end)) = (range_start, range_end) {
1715            if start > end {
1716                return Err(e(
1717                    "InvalidTimeRangeException",
1718                    "The specified time range is invalid: the start is after the end.",
1719                ));
1720            }
1721        }
1722        let (_region, account) = self.region_account(req);
1723        let mut guard = self.state.write();
1724        let st = guard.get_or_create(&account);
1725        let app_filter = str_field(&b, "applicationName");
1726        if let Some(app) = &app_filter {
1727            if !st.applications.contains_key(app) {
1728                return Err(e(
1729                    "ApplicationDoesNotExistException",
1730                    format!("No application found for name: {app}"),
1731                ));
1732            }
1733        }
1734        let dg_filter = str_field(&b, "deploymentGroupName");
1735        let ids: Vec<Value> = st
1736            .deployment_order
1737            .iter()
1738            .rev()
1739            .filter(|id| {
1740                let info = match st.deployments.get(*id) {
1741                    Some(i) => i,
1742                    None => return false,
1743                };
1744                if let Some(app) = &app_filter {
1745                    if info.get("applicationName").and_then(Value::as_str) != Some(app.as_str()) {
1746                        return false;
1747                    }
1748                }
1749                if let Some(dg) = &dg_filter {
1750                    if info.get("deploymentGroupName").and_then(Value::as_str) != Some(dg.as_str())
1751                    {
1752                        return false;
1753                    }
1754                }
1755                if !status_filter.is_empty() {
1756                    let status = info
1757                        .get("status")
1758                        .and_then(Value::as_str)
1759                        .unwrap_or("Created");
1760                    if !status_filter.iter().any(|s| s == status) {
1761                        return false;
1762                    }
1763                }
1764                if range_start.is_some() || range_end.is_some() {
1765                    let created = info
1766                        .get("createTime")
1767                        .and_then(Value::as_f64)
1768                        .unwrap_or(0.0);
1769                    if let Some(start) = range_start {
1770                        if created < start {
1771                            return false;
1772                        }
1773                    }
1774                    if let Some(end) = range_end {
1775                        if created > end {
1776                            return false;
1777                        }
1778                    }
1779                }
1780                true
1781            })
1782            .map(|id| json!(id))
1783            .collect();
1784        ok(paginate_body(ids, "deployments", &b)?)
1785    }
1786
1787    fn stop_deployment(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1788        let b = body(req);
1789        let id = req_deployment_id(&b)?;
1790        let (_region, account) = self.region_account(req);
1791        let mut guard = self.state.write();
1792        let st = guard.get_or_create(&account);
1793        let Some(info) = st.deployments.get_mut(&id) else {
1794            return Err(e(
1795                "DeploymentDoesNotExistException",
1796                format!("The deployment {id} could not be found."),
1797            ));
1798        };
1799        let status = info
1800            .get("status")
1801            .and_then(Value::as_str)
1802            .unwrap_or("Created");
1803        if matches!(status, "Succeeded" | "Failed" | "Stopped") {
1804            return Err(e(
1805                "DeploymentAlreadyCompletedException",
1806                "The specified deployment has already completed.",
1807            ));
1808        }
1809        if let Some(obj) = info.as_object_mut() {
1810            obj.insert("status".into(), json!("Stopped"));
1811            obj.insert("completeTime".into(), ts(Utc::now()));
1812        }
1813        ok(json!({ "status": "Succeeded", "statusMessage": "Deployment was stopped." }))
1814    }
1815
1816    fn continue_deployment(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1817        let b = body(req);
1818        opt_enum(
1819            &b,
1820            "deploymentWaitType",
1821            validate::DEPLOYMENT_WAIT_TYPE,
1822            "InvalidDeploymentWaitTypeException",
1823            "The wait type is invalid.",
1824        )?;
1825        if let Some(id) = str_field(&b, "deploymentId") {
1826            let (_region, account) = self.region_account(req);
1827            let mut guard = self.state.write();
1828            let st = guard.get_or_create(&account);
1829            if !st.deployments.contains_key(&id) {
1830                return Err(e(
1831                    "DeploymentDoesNotExistException",
1832                    format!("The deployment {id} could not be found."),
1833                ));
1834            }
1835        }
1836        ok(json!({}))
1837    }
1838
1839    fn skip_wait_time(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1840        let b = body(req);
1841        if let Some(id) = str_field(&b, "deploymentId") {
1842            let (_region, account) = self.region_account(req);
1843            let mut guard = self.state.write();
1844            let st = guard.get_or_create(&account);
1845            if !st.deployments.contains_key(&id) {
1846                return Err(e(
1847                    "DeploymentDoesNotExistException",
1848                    format!("The deployment {id} could not be found."),
1849                ));
1850            }
1851        }
1852        ok(json!({}))
1853    }
1854}
1855
1856// ---------------------------------------------------------------------------
1857// Deployment targets + instances (no real deploy engine -> empty target sets)
1858// ---------------------------------------------------------------------------
1859
1860impl CodeDeployService {
1861    fn require_deployment(st: &CodeDeployState, id: &str) -> Result<(), AwsServiceError> {
1862        if st.deployments.contains_key(id) {
1863            Ok(())
1864        } else {
1865            Err(e(
1866                "DeploymentDoesNotExistException",
1867                format!("The deployment {id} could not be found."),
1868            ))
1869        }
1870    }
1871
1872    fn get_deployment_target(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1873        let b = body(req);
1874        let id = req_deployment_id(&b)?;
1875        let Some(target_id) = str_field(&b, "targetId").filter(|s| !s.is_empty()) else {
1876            return Err(e(
1877                "DeploymentTargetIdRequiredException",
1878                "A deployment target ID must be specified.",
1879            ));
1880        };
1881        let (_region, account) = self.region_account(req);
1882        let mut guard = self.state.write();
1883        let st = guard.get_or_create(&account);
1884        Self::require_deployment(st, &id)?;
1885        // No real instances participate in fakecloud deployments, so no target
1886        // resolves.
1887        Err(e(
1888            "DeploymentTargetDoesNotExistException",
1889            format!("The deployment target {target_id} could not be found."),
1890        ))
1891    }
1892
1893    fn list_deployment_targets(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1894        let b = body(req);
1895        let id = req_deployment_id(&b)?;
1896        let (_region, account) = self.region_account(req);
1897        let mut guard = self.state.write();
1898        let st = guard.get_or_create(&account);
1899        Self::require_deployment(st, &id)?;
1900        ok(paginate_body(vec![], "targetIds", &b)?)
1901    }
1902
1903    fn batch_get_deployment_targets(
1904        &self,
1905        req: &AwsRequest,
1906    ) -> Result<AwsResponse, AwsServiceError> {
1907        let b = body(req);
1908        let id = req_deployment_id(&b)?;
1909        let (_region, account) = self.region_account(req);
1910        let mut guard = self.state.write();
1911        let st = guard.get_or_create(&account);
1912        Self::require_deployment(st, &id)?;
1913        ok(json!({ "deploymentTargets": [] }))
1914    }
1915
1916    fn get_deployment_instance(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1917        let b = body(req);
1918        let id = req_deployment_id(&b)?;
1919        let Some(instance_id) = str_field(&b, "instanceId").filter(|s| !s.is_empty()) else {
1920            return Err(e(
1921                "InstanceIdRequiredException",
1922                "The instance ID was not specified.",
1923            ));
1924        };
1925        let (_region, account) = self.region_account(req);
1926        let mut guard = self.state.write();
1927        let st = guard.get_or_create(&account);
1928        Self::require_deployment(st, &id)?;
1929        Err(e(
1930            "InstanceDoesNotExistException",
1931            format!("The instance {instance_id} could not be found."),
1932        ))
1933    }
1934
1935    fn list_deployment_instances(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1936        let b = body(req);
1937        let id = req_deployment_id(&b)?;
1938        let (_region, account) = self.region_account(req);
1939        let mut guard = self.state.write();
1940        let st = guard.get_or_create(&account);
1941        Self::require_deployment(st, &id)?;
1942        ok(paginate_body(vec![], "instancesList", &b)?)
1943    }
1944
1945    fn batch_get_deployment_instances(
1946        &self,
1947        req: &AwsRequest,
1948    ) -> Result<AwsResponse, AwsServiceError> {
1949        let b = body(req);
1950        let id = req_deployment_id(&b)?;
1951        let (_region, account) = self.region_account(req);
1952        let mut guard = self.state.write();
1953        let st = guard.get_or_create(&account);
1954        Self::require_deployment(st, &id)?;
1955        ok(json!({ "instancesSummary": [] }))
1956    }
1957}
1958
1959// ---------------------------------------------------------------------------
1960// On-premises instances
1961// ---------------------------------------------------------------------------
1962
1963impl CodeDeployService {
1964    fn register_on_premises_instance(
1965        &self,
1966        req: &AwsRequest,
1967    ) -> Result<AwsResponse, AwsServiceError> {
1968        let b = body(req);
1969        let name = req_instance_name(&b)?;
1970        let iam_user = str_field(&b, "iamUserArn");
1971        let iam_session = str_field(&b, "iamSessionArn");
1972        match (&iam_user, &iam_session) {
1973            (None, None) => return Err(e("IamArnRequiredException", "No IAM ARN was specified.")),
1974            (Some(_), Some(_)) => {
1975                return Err(e(
1976                    "MultipleIamArnsProvidedException",
1977                    "Both an IAM user ARN and an IAM session ARN were specified.",
1978                ))
1979            }
1980            _ => {}
1981        }
1982        let (region, account) = self.region_account(req);
1983        let now = Utc::now();
1984        let mut guard = self.state.write();
1985        let st = guard.get_or_create(&account);
1986        // Only a currently-registered instance (one without a `deregisterTime`)
1987        // blocks re-registration; a previously deregistered name may be
1988        // re-registered, matching AWS.
1989        let already_registered = st
1990            .on_premises_instances
1991            .get(&name)
1992            .map(|i| i.get("deregisterTime").is_none())
1993            .unwrap_or(false);
1994        if already_registered {
1995            return Err(e(
1996                "InstanceNameAlreadyRegisteredException",
1997                format!("The specified on-premises instance name is already registered: {name}"),
1998            ));
1999        }
2000        let instance_arn = format!("arn:aws:codedeploy:{region}:{account}:instance/{name}");
2001        let mut info = Map::new();
2002        info.insert("instanceName".into(), json!(name));
2003        info.insert("instanceArn".into(), json!(instance_arn));
2004        info.insert("registerTime".into(), ts(now));
2005        info.insert("tags".into(), json!([]));
2006        if let Some(u) = iam_user {
2007            info.insert("iamUserArn".into(), json!(u));
2008        }
2009        if let Some(s) = iam_session {
2010            info.insert("iamSessionArn".into(), json!(s));
2011        }
2012        let is_new = !st.on_premises_instances.contains_key(&name);
2013        st.on_premises_instances
2014            .insert(name.clone(), Value::Object(info));
2015        if is_new {
2016            st.on_premises_order.push(name);
2017        }
2018        ok(json!({}))
2019    }
2020
2021    fn deregister_on_premises_instance(
2022        &self,
2023        req: &AwsRequest,
2024    ) -> Result<AwsResponse, AwsServiceError> {
2025        let b = body(req);
2026        let name = req_instance_name(&b)?;
2027        let (_region, account) = self.region_account(req);
2028        let now = Utc::now();
2029        let mut guard = self.state.write();
2030        let st = guard.get_or_create(&account);
2031        // Deregistration is idempotent (the Smithy contract declares no
2032        // "not registered" error). A registered instance is retained with a
2033        // `deregisterTime` stamp so it can still be listed by registration
2034        // status and re-registered later, matching AWS.
2035        if let Some(info) = st.on_premises_instances.get_mut(&name) {
2036            if let Some(obj) = info.as_object_mut() {
2037                obj.insert("deregisterTime".into(), ts(now));
2038            }
2039        }
2040        ok(json!({}))
2041    }
2042
2043    fn get_on_premises_instance(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2044        let b = body(req);
2045        let name = req_instance_name(&b)?;
2046        let (_region, account) = self.region_account(req);
2047        let mut guard = self.state.write();
2048        let st = guard.get_or_create(&account);
2049        let Some(info) = st.on_premises_instances.get(&name) else {
2050            return Err(e(
2051                "InstanceNotRegisteredException",
2052                format!("The on-premises instance is not registered: {name}"),
2053            ));
2054        };
2055        ok(json!({ "instanceInfo": info.clone() }))
2056    }
2057
2058    fn list_on_premises_instances(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2059        let b = body(req);
2060        opt_enum(
2061            &b,
2062            "registrationStatus",
2063            validate::REGISTRATION_STATUS,
2064            "InvalidRegistrationStatusException",
2065            "The registration status was specified in an invalid format.",
2066        )?;
2067        let (_region, account) = self.region_account(req);
2068        let mut guard = self.state.write();
2069        let st = guard.get_or_create(&account);
2070        // A `deregisterTime` marks a deregistered instance; filter on it when a
2071        // `registrationStatus` is supplied.
2072        let status_filter = str_field(&b, "registrationStatus");
2073        let names: Vec<Value> = st
2074            .on_premises_order
2075            .iter()
2076            .filter(|n| {
2077                let deregistered = st
2078                    .on_premises_instances
2079                    .get(*n)
2080                    .map(|i| i.get("deregisterTime").is_some())
2081                    .unwrap_or(false);
2082                match status_filter.as_deref() {
2083                    Some("Registered") => !deregistered,
2084                    Some("Deregistered") => deregistered,
2085                    _ => true,
2086                }
2087            })
2088            .map(|n| json!(n))
2089            .collect();
2090        ok(paginate_body(names, "instanceNames", &b)?)
2091    }
2092
2093    fn batch_get_on_premises_instances(
2094        &self,
2095        req: &AwsRequest,
2096    ) -> Result<AwsResponse, AwsServiceError> {
2097        let b = body(req);
2098        let names = str_list(&b, "instanceNames");
2099        let (_region, account) = self.region_account(req);
2100        let mut guard = self.state.write();
2101        let st = guard.get_or_create(&account);
2102        let infos: Vec<Value> = names
2103            .iter()
2104            .filter_map(|n| st.on_premises_instances.get(n).cloned())
2105            .collect();
2106        ok(json!({ "instanceInfos": infos }))
2107    }
2108
2109    fn add_tags_to_on_premises(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2110        let b = body(req);
2111        let tags = b
2112            .get("tags")
2113            .and_then(Value::as_array)
2114            .cloned()
2115            .unwrap_or_default();
2116        if tags.is_empty() {
2117            return Err(e("TagRequiredException", "A tag was not specified."));
2118        }
2119        let names = str_list(&b, "instanceNames");
2120        if names.is_empty() {
2121            return Err(e(
2122                "InstanceNameRequiredException",
2123                "An on-premises instance name was not specified.",
2124            ));
2125        }
2126        let (_region, account) = self.region_account(req);
2127        let mut guard = self.state.write();
2128        let st = guard.get_or_create(&account);
2129        // Validate every instance is registered *before* mutating any, so a
2130        // later unregistered name cannot leave earlier instances partially
2131        // tagged (handle() only snapshots on Ok).
2132        Self::require_all_registered(st, &names)?;
2133        for name in &names {
2134            if let Some(existing) = st
2135                .on_premises_instances
2136                .get_mut(name)
2137                .and_then(|i| i.get_mut("tags"))
2138                .and_then(Value::as_array_mut)
2139            {
2140                for t in &tags {
2141                    if !existing.contains(t) {
2142                        existing.push(t.clone());
2143                    }
2144                }
2145            }
2146        }
2147        ok(json!({}))
2148    }
2149
2150    fn remove_tags_from_on_premises(
2151        &self,
2152        req: &AwsRequest,
2153    ) -> Result<AwsResponse, AwsServiceError> {
2154        let b = body(req);
2155        let tags = b
2156            .get("tags")
2157            .and_then(Value::as_array)
2158            .cloned()
2159            .unwrap_or_default();
2160        if tags.is_empty() {
2161            return Err(e("TagRequiredException", "A tag was not specified."));
2162        }
2163        let names = str_list(&b, "instanceNames");
2164        if names.is_empty() {
2165            return Err(e(
2166                "InstanceNameRequiredException",
2167                "An on-premises instance name was not specified.",
2168            ));
2169        }
2170        let (_region, account) = self.region_account(req);
2171        let mut guard = self.state.write();
2172        let st = guard.get_or_create(&account);
2173        // Validate-all-then-apply, so a later unregistered name cannot leave
2174        // earlier instances partially untagged.
2175        Self::require_all_registered(st, &names)?;
2176        for name in &names {
2177            if let Some(existing) = st
2178                .on_premises_instances
2179                .get_mut(name)
2180                .and_then(|i| i.get_mut("tags"))
2181                .and_then(Value::as_array_mut)
2182            {
2183                existing.retain(|t| !tags.contains(t));
2184            }
2185        }
2186        ok(json!({}))
2187    }
2188
2189    /// Ensure every named on-premises instance is currently registered (exists
2190    /// and has no `deregisterTime`); otherwise return `InstanceNotRegisteredException`
2191    /// without having mutated any state.
2192    fn require_all_registered(
2193        st: &CodeDeployState,
2194        names: &[String],
2195    ) -> Result<(), AwsServiceError> {
2196        for name in names {
2197            let registered = st
2198                .on_premises_instances
2199                .get(name)
2200                .map(|i| i.get("deregisterTime").is_none())
2201                .unwrap_or(false);
2202            if !registered {
2203                return Err(e(
2204                    "InstanceNotRegisteredException",
2205                    format!("The on-premises instance is not registered: {name}"),
2206                ));
2207            }
2208        }
2209        Ok(())
2210    }
2211}
2212
2213// ---------------------------------------------------------------------------
2214// GitHub account tokens + lifecycle hook status + external-id cleanup
2215// ---------------------------------------------------------------------------
2216
2217impl CodeDeployService {
2218    fn list_github_account_token_names(
2219        &self,
2220        req: &AwsRequest,
2221    ) -> Result<AwsResponse, AwsServiceError> {
2222        let b = body(req);
2223        // No GitHub account tokens are provisioned in fakecloud.
2224        ok(paginate_body(vec![], "tokenNameList", &b)?)
2225    }
2226
2227    fn delete_github_account_token(
2228        &self,
2229        req: &AwsRequest,
2230    ) -> Result<AwsResponse, AwsServiceError> {
2231        let b = body(req);
2232        let Some(token_name) = str_field(&b, "tokenName") else {
2233            return Err(e(
2234                "GitHubAccountTokenNameRequiredException",
2235                "The GitHub account token name was not specified.",
2236            ));
2237        };
2238        // No tokens exist, so any named token is unknown.
2239        Err(e(
2240            "GitHubAccountTokenDoesNotExistException",
2241            format!("No GitHub account connection exists for token name: {token_name}"),
2242        ))
2243    }
2244
2245    fn put_lifecycle_status(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2246        let b = body(req);
2247        opt_enum(
2248            &b,
2249            "status",
2250            validate::LIFECYCLE_EVENT_STATUS,
2251            "InvalidLifecycleEventHookExecutionStatusException",
2252            "The lifecycle event status was specified in an invalid format.",
2253        )?;
2254        if let Some(id) = str_field(&b, "deploymentId") {
2255            let (_region, account) = self.region_account(req);
2256            let mut guard = self.state.write();
2257            let st = guard.get_or_create(&account);
2258            if !st.deployments.contains_key(&id) {
2259                return Err(e(
2260                    "DeploymentDoesNotExistException",
2261                    format!("The deployment {id} could not be found."),
2262                ));
2263            }
2264        }
2265        let exec_id = str_field(&b, "lifecycleEventHookExecutionId")
2266            .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
2267        ok(json!({ "lifecycleEventHookExecutionId": exec_id }))
2268    }
2269
2270    fn delete_resources_by_external_id(
2271        &self,
2272        req: &AwsRequest,
2273    ) -> Result<AwsResponse, AwsServiceError> {
2274        let b = body(req);
2275        let Some(external_id) = str_field(&b, "externalId") else {
2276            return ok(json!({}));
2277        };
2278        let (_region, account) = self.region_account(req);
2279        let mut guard = self.state.write();
2280        let st = guard.get_or_create(&account);
2281        // CloudFormation cleanup hook: remove every deployment tagged with this
2282        // external id (and its settle bookkeeping). A deployment carries an
2283        // `externalId` only when CloudFormation created it, so this typically
2284        // matches nothing, but the deletion is real when it does.
2285        let to_remove: Vec<String> = st
2286            .deployments
2287            .iter()
2288            .filter(|(_, info)| {
2289                info.get("externalId").and_then(Value::as_str) == Some(external_id.as_str())
2290            })
2291            .map(|(id, _)| id.clone())
2292            .collect();
2293        for id in to_remove {
2294            st.deployments.remove(&id);
2295            st.deployment_settle.remove(&id);
2296            st.deployment_order.retain(|x| x != &id);
2297        }
2298        ok(json!({}))
2299    }
2300}
2301
2302// ---------------------------------------------------------------------------
2303// Resource tagging
2304// ---------------------------------------------------------------------------
2305
2306impl CodeDeployService {
2307    /// Verify the resource referenced by a (syntactically valid) CodeDeploy ARN
2308    /// exists, returning the declared per-resource-type not-found error when it
2309    /// does not. The ARN resource segment is `application:<name>`,
2310    /// `deploymentgroup:<app>/<group>`, or `deploymentconfig:<name>`.
2311    fn require_resource_exists(st: &CodeDeployState, arn: &str) -> Result<(), AwsServiceError> {
2312        let parts: Vec<&str> = arn.splitn(7, ':').collect();
2313        let (kind, rest) = (parts[5], parts.get(6).copied().unwrap_or(""));
2314        let group_exists = |rest: &str| {
2315            let (app, group) = rest.split_once('/').unwrap_or((rest, ""));
2316            st.deployment_groups
2317                .get(app)
2318                .map(|g| g.contains_key(group))
2319                .unwrap_or(false)
2320        };
2321        match kind {
2322            "application" if !st.applications.contains_key(rest) => Err(e(
2323                "ApplicationDoesNotExistException",
2324                format!("No application found for name: {rest}"),
2325            )),
2326            "deploymentgroup" if !group_exists(rest) => Err(e(
2327                "DeploymentGroupDoesNotExistException",
2328                format!("No deployment group found for name: {rest}"),
2329            )),
2330            "deploymentconfig"
2331                if predefined_config(rest).is_none()
2332                    && !st.deployment_configs.contains_key(rest) =>
2333            {
2334                Err(e(
2335                    "DeploymentConfigDoesNotExistException",
2336                    format!("No deployment configuration found for name: {rest}"),
2337                ))
2338            }
2339            _ => Ok(()),
2340        }
2341    }
2342
2343    fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2344        let b = body(req);
2345        let arn = req_resource_arn(&b)?;
2346        let tags = b
2347            .get("Tags")
2348            .and_then(Value::as_array)
2349            .cloned()
2350            .unwrap_or_default();
2351        if tags.is_empty() {
2352            return Err(e("TagRequiredException", "A tag was not specified."));
2353        }
2354        let (_region, account) = self.region_account(req);
2355        let mut guard = self.state.write();
2356        let st = guard.get_or_create(&account);
2357        Self::require_resource_exists(st, &arn)?;
2358        let stored = st.tags.entry(arn).or_default();
2359        for t in tags {
2360            let key = t.get("Key").and_then(Value::as_str).map(str::to_string);
2361            if let Some(k) = key {
2362                stored.retain(|x| x.get("Key").and_then(Value::as_str) != Some(k.as_str()));
2363            }
2364            stored.push(t);
2365        }
2366        ok(json!({}))
2367    }
2368
2369    fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2370        let b = body(req);
2371        let arn = req_resource_arn(&b)?;
2372        let keys = str_list(&b, "TagKeys");
2373        let (_region, account) = self.region_account(req);
2374        let mut guard = self.state.write();
2375        let st = guard.get_or_create(&account);
2376        Self::require_resource_exists(st, &arn)?;
2377        if let Some(stored) = st.tags.get_mut(&arn) {
2378            stored.retain(|t| {
2379                t.get("Key")
2380                    .and_then(Value::as_str)
2381                    .map(|k| !keys.iter().any(|rk| rk == k))
2382                    .unwrap_or(true)
2383            });
2384        }
2385        ok(json!({}))
2386    }
2387
2388    fn list_tags_for_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2389        let b = body(req);
2390        let arn = req_resource_arn(&b)?;
2391        let (_region, account) = self.region_account(req);
2392        let mut guard = self.state.write();
2393        let st = guard.get_or_create(&account);
2394        let tags = st.tags.get(&arn).cloned().unwrap_or_default();
2395        let out = paginate_body(tags, "Tags", &b)?;
2396        ok(out)
2397    }
2398}
2399
2400#[cfg(test)]
2401mod tests {
2402    use super::*;
2403    use fakecloud_core::multi_account::MultiAccountState;
2404    use parking_lot::RwLock;
2405
2406    fn svc() -> CodeDeployService {
2407        let state = Arc::new(RwLock::new(MultiAccountState::new(
2408            "000000000000",
2409            "us-east-1",
2410            "",
2411        )));
2412        CodeDeployService::new(state)
2413    }
2414
2415    fn req(action: &str, body: Value) -> AwsRequest {
2416        AwsRequest {
2417            service: "codedeploy".to_string(),
2418            action: action.to_string(),
2419            region: "us-east-1".to_string(),
2420            account_id: "000000000000".to_string(),
2421            request_id: "rid".to_string(),
2422            headers: http::HeaderMap::new(),
2423            query_params: std::collections::HashMap::new(),
2424            body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
2425            body_stream: parking_lot::Mutex::new(None),
2426            path_segments: vec![],
2427            raw_path: "/".to_string(),
2428            raw_query: String::new(),
2429            method: http::Method::POST,
2430            is_query_protocol: false,
2431            access_key_id: None,
2432            principal: None,
2433        }
2434    }
2435
2436    fn body_of(resp: AwsResponse) -> Value {
2437        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
2438    }
2439
2440    fn err_code(r: Result<AwsResponse, AwsServiceError>) -> String {
2441        match r {
2442            Ok(_) => panic!("expected an error"),
2443            Err(e) => e.code().to_string(),
2444        }
2445    }
2446
2447    #[test]
2448    fn create_get_application_round_trip() {
2449        let s = svc();
2450        let created = body_of(
2451            s.create_application(&req(
2452                "CreateApplication",
2453                json!({ "applicationName": "app1", "computePlatform": "Lambda" }),
2454            ))
2455            .unwrap(),
2456        );
2457        assert!(created["applicationId"].is_string());
2458        let got = body_of(
2459            s.get_application(&req("GetApplication", json!({ "applicationName": "app1" })))
2460                .unwrap(),
2461        );
2462        assert_eq!(got["application"]["applicationName"], "app1");
2463        assert_eq!(got["application"]["computePlatform"], "Lambda");
2464    }
2465
2466    #[test]
2467    fn create_application_rejects_duplicate_and_bad_platform() {
2468        let s = svc();
2469        s.create_application(&req(
2470            "CreateApplication",
2471            json!({ "applicationName": "dup" }),
2472        ))
2473        .unwrap();
2474        assert_eq!(
2475            err_code(s.create_application(&req(
2476                "CreateApplication",
2477                json!({ "applicationName": "dup" })
2478            ))),
2479            "ApplicationAlreadyExistsException"
2480        );
2481        assert_eq!(
2482            err_code(s.create_application(&req(
2483                "CreateApplication",
2484                json!({ "applicationName": "x", "computePlatform": "NOPE" })
2485            ))),
2486            "InvalidComputePlatformException"
2487        );
2488    }
2489
2490    #[test]
2491    fn application_name_required_and_invalid() {
2492        let s = svc();
2493        assert_eq!(
2494            err_code(s.get_application(&req("GetApplication", json!({})))),
2495            "ApplicationNameRequiredException"
2496        );
2497        let long = "a".repeat(101);
2498        assert_eq!(
2499            err_code(s.get_application(&req("GetApplication", json!({ "applicationName": long })))),
2500            "InvalidApplicationNameException"
2501        );
2502        assert_eq!(
2503            err_code(s.get_application(&req(
2504                "GetApplication",
2505                json!({ "applicationName": "ghost" })
2506            ))),
2507            "ApplicationDoesNotExistException"
2508        );
2509    }
2510
2511    #[test]
2512    fn predefined_deployment_config_always_present() {
2513        let s = svc();
2514        let got = body_of(
2515            s.get_deployment_config(&req(
2516                "GetDeploymentConfig",
2517                json!({ "deploymentConfigName": "CodeDeployDefault.OneAtATime" }),
2518            ))
2519            .unwrap(),
2520        );
2521        assert_eq!(got["deploymentConfigInfo"]["computePlatform"], "Server");
2522        assert_eq!(
2523            got["deploymentConfigInfo"]["minimumHealthyHosts"]["type"],
2524            "HOST_COUNT"
2525        );
2526        let canary = body_of(s.get_deployment_config(&req("GetDeploymentConfig", json!({ "deploymentConfigName": "CodeDeployDefault.LambdaCanary10Percent5Minutes" }))).unwrap());
2527        assert_eq!(
2528            canary["deploymentConfigInfo"]["trafficRoutingConfig"]["type"],
2529            "TimeBasedCanary"
2530        );
2531    }
2532
2533    #[test]
2534    fn deployment_settles_through_lifecycle() {
2535        let s = svc();
2536        s.create_application(&req(
2537            "CreateApplication",
2538            json!({ "applicationName": "da" }),
2539        ))
2540        .unwrap();
2541        s.create_deployment_group(&req(
2542            "CreateDeploymentGroup",
2543            json!({ "applicationName": "da", "deploymentGroupName": "dg", "serviceRoleArn": "arn:aws:iam::000000000000:role/cd" }),
2544        ))
2545        .unwrap();
2546        let id = body_of(
2547            s.create_deployment(&req(
2548                "CreateDeployment",
2549                json!({ "applicationName": "da", "deploymentGroupName": "dg" }),
2550            ))
2551            .unwrap(),
2552        )["deploymentId"]
2553            .as_str()
2554            .unwrap()
2555            .to_string();
2556        assert!(id.starts_with("d-"));
2557        let first = body_of(
2558            s.get_deployment(&req("GetDeployment", json!({ "deploymentId": id.clone() })))
2559                .unwrap(),
2560        );
2561        assert_eq!(first["deploymentInfo"]["status"], "InProgress");
2562        let second = body_of(
2563            s.get_deployment(&req("GetDeployment", json!({ "deploymentId": id })))
2564                .unwrap(),
2565        );
2566        assert_eq!(second["deploymentInfo"]["status"], "Succeeded");
2567    }
2568
2569    #[test]
2570    fn create_deployment_group_requires_app_and_role() {
2571        let s = svc();
2572        assert_eq!(
2573            err_code(s.create_deployment_group(&req(
2574                "CreateDeploymentGroup",
2575                json!({ "applicationName": "ga", "deploymentGroupName": "g1" })
2576            ))),
2577            "RoleRequiredException"
2578        );
2579        assert_eq!(
2580            err_code(s.create_deployment_group(&req("CreateDeploymentGroup", json!({ "applicationName": "ga", "deploymentGroupName": "g1", "serviceRoleArn": "arn:aws:iam::000000000000:role/cd" })))),
2581            "ApplicationDoesNotExistException"
2582        );
2583        s.create_application(&req(
2584            "CreateApplication",
2585            json!({ "applicationName": "ga" }),
2586        ))
2587        .unwrap();
2588        let created = body_of(
2589            s.create_deployment_group(&req("CreateDeploymentGroup", json!({ "applicationName": "ga", "deploymentGroupName": "g1", "serviceRoleArn": "arn:aws:iam::000000000000:role/cd" })))
2590                .unwrap(),
2591        );
2592        assert!(created["deploymentGroupId"].is_string());
2593        let got = body_of(
2594            s.get_deployment_group(&req(
2595                "GetDeploymentGroup",
2596                json!({ "applicationName": "ga", "deploymentGroupName": "g1" }),
2597            ))
2598            .unwrap(),
2599        );
2600        assert_eq!(
2601            got["deploymentGroupInfo"]["deploymentConfigName"],
2602            "CodeDeployDefault.OneAtATime"
2603        );
2604    }
2605
2606    #[test]
2607    fn on_premises_register_requires_iam_arn() {
2608        let s = svc();
2609        assert_eq!(
2610            err_code(s.register_on_premises_instance(&req(
2611                "RegisterOnPremisesInstance",
2612                json!({ "instanceName": "i1" })
2613            ))),
2614            "IamArnRequiredException"
2615        );
2616        s.register_on_premises_instance(&req(
2617            "RegisterOnPremisesInstance",
2618            json!({ "instanceName": "i1", "iamUserArn": "arn:aws:iam::000000000000:user/u" }),
2619        ))
2620        .unwrap();
2621        let got = body_of(
2622            s.get_on_premises_instance(&req(
2623                "GetOnPremisesInstance",
2624                json!({ "instanceName": "i1" }),
2625            ))
2626            .unwrap(),
2627        );
2628        assert_eq!(got["instanceInfo"]["instanceName"], "i1");
2629    }
2630
2631    #[test]
2632    fn tag_resource_rejects_non_arn() {
2633        let s = svc();
2634        assert_eq!(
2635            err_code(s.list_tags_for_resource(&req(
2636                "ListTagsForResource",
2637                json!({ "ResourceArn": "not-an-arn" })
2638            ))),
2639            "InvalidArnException"
2640        );
2641        s.create_application(&req(
2642            "CreateApplication",
2643            json!({ "applicationName": "app1" }),
2644        ))
2645        .unwrap();
2646        let arn = "arn:aws:codedeploy:us-east-1:000000000000:application:app1";
2647        s.tag_resource(&req(
2648            "TagResource",
2649            json!({ "ResourceArn": arn, "Tags": [{ "Key": "env", "Value": "prod" }] }),
2650        ))
2651        .unwrap();
2652        let got = body_of(
2653            s.list_tags_for_resource(&req("ListTagsForResource", json!({ "ResourceArn": arn })))
2654                .unwrap(),
2655        );
2656        assert_eq!(got["Tags"][0]["Key"], "env");
2657    }
2658
2659    // Finding 7: TagResource / UntagResource reject a syntactically valid ARN
2660    // whose resource does not exist (ListTagsForResource stays lenient: its
2661    // Smithy contract declares no not-found error).
2662    #[test]
2663    fn tag_resource_rejects_nonexistent_resource() {
2664        let s = svc();
2665        let ghost = "arn:aws:codedeploy:us-east-1:000000000000:application:ghost";
2666        assert_eq!(
2667            err_code(s.tag_resource(&req(
2668                "TagResource",
2669                json!({ "ResourceArn": ghost, "Tags": [{ "Key": "k", "Value": "v" }] })
2670            ))),
2671            "ApplicationDoesNotExistException"
2672        );
2673        let ghost_cfg = "arn:aws:codedeploy:us-east-1:000000000000:deploymentconfig:ghostcfg";
2674        assert_eq!(
2675            err_code(s.untag_resource(&req(
2676                "UntagResource",
2677                json!({ "ResourceArn": ghost_cfg, "TagKeys": ["k"] })
2678            ))),
2679            "DeploymentConfigDoesNotExistException"
2680        );
2681        // A predefined deployment config ARN is a real resource.
2682        let predef =
2683            "arn:aws:codedeploy:us-east-1:000000000000:deploymentconfig:CodeDeployDefault.OneAtATime";
2684        s.tag_resource(&req(
2685            "TagResource",
2686            json!({ "ResourceArn": predef, "Tags": [{ "Key": "k", "Value": "v" }] }),
2687        ))
2688        .unwrap();
2689    }
2690
2691    // Finding 1: renaming an application rewrites the embedded applicationName
2692    // in every stored DeploymentGroupInfo.
2693    #[test]
2694    fn rename_application_rewrites_group_application_name() {
2695        let s = svc();
2696        s.create_application(&req("CreateApplication", json!({ "applicationName": "a" })))
2697            .unwrap();
2698        s.create_deployment_group(&req(
2699            "CreateDeploymentGroup",
2700            json!({
2701                "applicationName": "a",
2702                "deploymentGroupName": "g",
2703                "serviceRoleArn": "arn:aws:iam::000000000000:role/cd"
2704            }),
2705        ))
2706        .unwrap();
2707        s.update_application(&req(
2708            "UpdateApplication",
2709            json!({ "applicationName": "a", "newApplicationName": "b" }),
2710        ))
2711        .unwrap();
2712        let got = body_of(
2713            s.get_deployment_group(&req(
2714                "GetDeploymentGroup",
2715                json!({ "applicationName": "b", "deploymentGroupName": "g" }),
2716            ))
2717            .unwrap(),
2718        );
2719        assert_eq!(got["deploymentGroupInfo"]["applicationName"], "b");
2720    }
2721
2722    // Completeness A: renaming an application also rewrites the applicationName
2723    // on every stored deployment, so ListDeployments(new) finds pre-rename
2724    // deployments and GetDeployment reports the new name.
2725    #[test]
2726    fn rename_application_rewrites_deployment_application_name() {
2727        let s = svc();
2728        s.create_application(&req("CreateApplication", json!({ "applicationName": "a" })))
2729            .unwrap();
2730        s.create_deployment_group(&req(
2731            "CreateDeploymentGroup",
2732            json!({ "applicationName": "a", "deploymentGroupName": "dg", "serviceRoleArn": "arn:aws:iam::000000000000:role/cd" }),
2733        ))
2734        .unwrap();
2735        let id = body_of(
2736            s.create_deployment(&req(
2737                "CreateDeployment",
2738                json!({ "applicationName": "a", "deploymentGroupName": "dg" }),
2739            ))
2740            .unwrap(),
2741        )["deploymentId"]
2742            .as_str()
2743            .unwrap()
2744            .to_string();
2745        s.update_application(&req(
2746            "UpdateApplication",
2747            json!({ "applicationName": "a", "newApplicationName": "b" }),
2748        ))
2749        .unwrap();
2750        // ListDeployments filtered by the new name finds the pre-rename deployment.
2751        let listed = body_of(
2752            s.list_deployments(&req("ListDeployments", json!({ "applicationName": "b" })))
2753                .unwrap(),
2754        );
2755        let ids: Vec<&str> = listed["deployments"]
2756            .as_array()
2757            .unwrap()
2758            .iter()
2759            .map(|v| v.as_str().unwrap())
2760            .collect();
2761        assert_eq!(ids, vec![id.as_str()]);
2762        // GetDeployment reports the renamed application.
2763        let got = body_of(
2764            s.get_deployment(&req("GetDeployment", json!({ "deploymentId": id })))
2765                .unwrap(),
2766        );
2767        assert_eq!(got["deploymentInfo"]["applicationName"], "b");
2768    }
2769
2770    // Completeness B: ListApplicationRevisions rejects bogus deployed / sortBy /
2771    // sortOrder enum values with the declared errors.
2772    #[test]
2773    fn list_application_revisions_validates_enums() {
2774        let s = svc();
2775        s.create_application(&req(
2776            "CreateApplication",
2777            json!({ "applicationName": "ra" }),
2778        ))
2779        .unwrap();
2780        let cases = [
2781            ("deployed", "bogus", "InvalidDeployedStateFilterException"),
2782            ("sortBy", "bogus", "InvalidSortByException"),
2783            ("sortOrder", "bogus", "InvalidSortOrderException"),
2784        ];
2785        for (field, value, code) in cases {
2786            assert_eq!(
2787                err_code(s.list_application_revisions(&req(
2788                    "ListApplicationRevisions",
2789                    json!({ "applicationName": "ra", field: value }),
2790                ))),
2791                code,
2792                "field {field}",
2793            );
2794        }
2795    }
2796
2797    // Finding 5: BatchGetApplications raises the declared not-found for a
2798    // missing name rather than silently omitting it.
2799    #[test]
2800    fn batch_get_applications_raises_not_found() {
2801        let s = svc();
2802        s.create_application(&req(
2803            "CreateApplication",
2804            json!({ "applicationName": "real" }),
2805        ))
2806        .unwrap();
2807        assert_eq!(
2808            err_code(s.batch_get_applications(&req(
2809                "BatchGetApplications",
2810                json!({ "applicationNames": ["real", "ghost"] })
2811            ))),
2812            "ApplicationDoesNotExistException"
2813        );
2814        let got = body_of(
2815            s.batch_get_applications(&req(
2816                "BatchGetApplications",
2817                json!({ "applicationNames": ["real"] }),
2818            ))
2819            .unwrap(),
2820        );
2821        assert_eq!(got["applicationsInfo"][0]["applicationName"], "real");
2822    }
2823
2824    // Finding 2: ListDeployments honours includeOnlyStatuses + createTimeRange
2825    // and validates the status enum and time range.
2826    #[test]
2827    fn list_deployments_filters_by_status_and_time() {
2828        let s = svc();
2829        s.create_application(&req(
2830            "CreateApplication",
2831            json!({ "applicationName": "app" }),
2832        ))
2833        .unwrap();
2834        s.create_deployment_group(&req(
2835            "CreateDeploymentGroup",
2836            json!({ "applicationName": "app", "deploymentGroupName": "dg", "serviceRoleArn": "arn:aws:iam::000000000000:role/cd" }),
2837        ))
2838        .unwrap();
2839        let mk = || {
2840            body_of(
2841                s.create_deployment(&req(
2842                    "CreateDeployment",
2843                    json!({ "applicationName": "app", "deploymentGroupName": "dg" }),
2844                ))
2845                .unwrap(),
2846            )["deploymentId"]
2847                .as_str()
2848                .unwrap()
2849                .to_string()
2850        };
2851        let created = mk(); // stays "Created"
2852        let done = mk();
2853        // Settle `done` to Succeeded via two reads.
2854        s.get_deployment(&req(
2855            "GetDeployment",
2856            json!({ "deploymentId": done.clone() }),
2857        ))
2858        .unwrap();
2859        s.get_deployment(&req(
2860            "GetDeployment",
2861            json!({ "deploymentId": done.clone() }),
2862        ))
2863        .unwrap();
2864        let succeeded = body_of(
2865            s.list_deployments(&req(
2866                "ListDeployments",
2867                json!({ "includeOnlyStatuses": ["Succeeded"] }),
2868            ))
2869            .unwrap(),
2870        );
2871        let ids: Vec<&str> = succeeded["deployments"]
2872            .as_array()
2873            .unwrap()
2874            .iter()
2875            .map(|v| v.as_str().unwrap())
2876            .collect();
2877        assert_eq!(ids, vec![done.as_str()]);
2878        assert!(!ids.contains(&created.as_str()));
2879        // Bad status enum + inverted time range are rejected.
2880        assert_eq!(
2881            err_code(s.list_deployments(&req(
2882                "ListDeployments",
2883                json!({ "includeOnlyStatuses": ["Bogus"] })
2884            ))),
2885            "InvalidDeploymentStatusException"
2886        );
2887        assert_eq!(
2888            err_code(s.list_deployments(&req(
2889                "ListDeployments",
2890                json!({ "createTimeRange": { "start": 100.0, "end": 1.0 } })
2891            ))),
2892            "InvalidTimeRangeException"
2893        );
2894        // A time window that excludes everything (far future) returns nothing.
2895        let none = body_of(
2896            s.list_deployments(&req(
2897                "ListDeployments",
2898                json!({ "createTimeRange": { "start": 9_000_000_000.0_f64 } }),
2899            ))
2900            .unwrap(),
2901        );
2902        assert_eq!(none["deployments"].as_array().unwrap().len(), 0);
2903    }
2904
2905    // Finding 3: ListApplicationRevisions applies the `deployed` filter and
2906    // sort order.
2907    #[test]
2908    fn list_application_revisions_filters_and_sorts() {
2909        let s = svc();
2910        s.create_application(&req(
2911            "CreateApplication",
2912            json!({ "applicationName": "ra" }),
2913        ))
2914        .unwrap();
2915        let reg = |bucket: &str| {
2916            s.register_application_revision(&req(
2917                "RegisterApplicationRevision",
2918                json!({
2919                    "applicationName": "ra",
2920                    "revision": { "revisionType": "S3", "s3Location": { "bucket": bucket, "key": "k", "bundleType": "zip" } }
2921                }),
2922            ))
2923            .unwrap();
2924        };
2925        reg("bucket-a");
2926        reg("bucket-b");
2927        // No revision is deployed, so `include` -> empty, `exclude` -> all.
2928        let inc = body_of(
2929            s.list_application_revisions(&req(
2930                "ListApplicationRevisions",
2931                json!({ "applicationName": "ra", "deployed": "include" }),
2932            ))
2933            .unwrap(),
2934        );
2935        assert_eq!(inc["revisions"].as_array().unwrap().len(), 0);
2936        let exc = body_of(
2937            s.list_application_revisions(&req(
2938                "ListApplicationRevisions",
2939                json!({ "applicationName": "ra", "deployed": "exclude" }),
2940            ))
2941            .unwrap(),
2942        );
2943        assert_eq!(exc["revisions"].as_array().unwrap().len(), 2);
2944        // Descending register-time order reverses insertion order.
2945        let desc = body_of(
2946            s.list_application_revisions(&req(
2947                "ListApplicationRevisions",
2948                json!({ "applicationName": "ra", "sortBy": "registerTime", "sortOrder": "descending" }),
2949            ))
2950            .unwrap(),
2951        );
2952        assert_eq!(
2953            desc["revisions"][0]["s3Location"]["bucket"], "bucket-b",
2954            "descending should list the most-recently-registered first"
2955        );
2956    }
2957
2958    // Finding 4: ListOnPremisesInstances honours registrationStatus, and a
2959    // deregistered instance is retained as Deregistered.
2960    #[test]
2961    fn list_on_premises_filters_by_registration_status() {
2962        let s = svc();
2963        for n in ["i1", "i2"] {
2964            s.register_on_premises_instance(&req(
2965                "RegisterOnPremisesInstance",
2966                json!({ "instanceName": n, "iamUserArn": "arn:aws:iam::000000000000:user/u" }),
2967            ))
2968            .unwrap();
2969        }
2970        s.deregister_on_premises_instance(&req(
2971            "DeregisterOnPremisesInstance",
2972            json!({ "instanceName": "i2" }),
2973        ))
2974        .unwrap();
2975        let names = |status: &str| -> Vec<String> {
2976            body_of(
2977                s.list_on_premises_instances(&req(
2978                    "ListOnPremisesInstances",
2979                    json!({ "registrationStatus": status }),
2980                ))
2981                .unwrap(),
2982            )["instanceNames"]
2983                .as_array()
2984                .unwrap()
2985                .iter()
2986                .map(|v| v.as_str().unwrap().to_string())
2987                .collect()
2988        };
2989        assert_eq!(names("Registered"), vec!["i1".to_string()]);
2990        assert_eq!(names("Deregistered"), vec!["i2".to_string()]);
2991    }
2992
2993    // Finding 6: AddTagsToOnPremisesInstances validates all names before
2994    // mutating any (no partial tagging on error).
2995    #[test]
2996    fn add_tags_on_premises_is_all_or_nothing() {
2997        let s = svc();
2998        s.register_on_premises_instance(&req(
2999            "RegisterOnPremisesInstance",
3000            json!({ "instanceName": "ok1", "iamUserArn": "arn:aws:iam::000000000000:user/u" }),
3001        ))
3002        .unwrap();
3003        assert_eq!(
3004            err_code(s.add_tags_to_on_premises(&req(
3005                "AddTagsToOnPremisesInstances",
3006                json!({ "tags": [{ "Key": "k", "Value": "v" }], "instanceNames": ["ok1", "missing"] })
3007            ))),
3008            "InstanceNotRegisteredException"
3009        );
3010        // ok1 must not have been tagged.
3011        let got = body_of(
3012            s.get_on_premises_instance(&req(
3013                "GetOnPremisesInstance",
3014                json!({ "instanceName": "ok1" }),
3015            ))
3016            .unwrap(),
3017        );
3018        assert_eq!(got["instanceInfo"]["tags"].as_array().unwrap().len(), 0);
3019    }
3020
3021    // Finding 8: the true no-op actions are not in the mutating set, while
3022    // DeleteResourcesByExternalId performs (and persists) a real deletion.
3023    #[test]
3024    fn is_mutating_excludes_true_no_ops() {
3025        assert!(!is_mutating("ContinueDeployment"));
3026        assert!(!is_mutating("SkipWaitTimeForInstanceTermination"));
3027        assert!(!is_mutating("PutLifecycleEventHookExecutionStatus"));
3028        assert!(is_mutating("DeleteResourcesByExternalId"));
3029    }
3030
3031    #[test]
3032    fn delete_resources_by_external_id_removes_matching_deployments() {
3033        let s = svc();
3034        {
3035            let mut g = s.state.write();
3036            let st = g.get_or_create("000000000000");
3037            st.deployments.insert(
3038                "d-EXT000001".into(),
3039                json!({ "deploymentId": "d-EXT000001", "externalId": "ext-1" }),
3040            );
3041            st.deployment_order.push("d-EXT000001".into());
3042            st.deployment_settle.insert("d-EXT000001".into(), 0);
3043        }
3044        s.delete_resources_by_external_id(&req(
3045            "DeleteResourcesByExternalId",
3046            json!({ "externalId": "ext-1" }),
3047        ))
3048        .unwrap();
3049        let mut g = s.state.write();
3050        let st = g.get_or_create("000000000000");
3051        assert!(!st.deployments.contains_key("d-EXT000001"));
3052        assert!(st.deployment_order.is_empty());
3053    }
3054
3055    #[test]
3056    fn delete_application_is_idempotent() {
3057        let s = svc();
3058        s.delete_application(&req(
3059            "DeleteApplication",
3060            json!({ "applicationName": "ghost" }),
3061        ))
3062        .unwrap();
3063    }
3064
3065    // Helper: create an application plus a deployment group under it.
3066    fn make_app_and_group(s: &CodeDeployService, app: &str, group: &str) {
3067        s.create_application(&req("CreateApplication", json!({ "applicationName": app })))
3068            .unwrap();
3069        s.create_deployment_group(&req(
3070            "CreateDeploymentGroup",
3071            json!({ "applicationName": app, "deploymentGroupName": group, "serviceRoleArn": "arn:aws:iam::000000000000:role/cd" }),
3072        ))
3073        .unwrap();
3074    }
3075
3076    fn tags_for(s: &CodeDeployService, arn: &str) -> Vec<Value> {
3077        body_of(
3078            s.list_tags_for_resource(&req("ListTagsForResource", json!({ "ResourceArn": arn })))
3079                .unwrap(),
3080        )["Tags"]
3081            .as_array()
3082            .cloned()
3083            .unwrap_or_default()
3084    }
3085
3086    // T1.1: CreateApplication persists its create-time `tags` so
3087    // ListTagsForResource echoes them.
3088    #[test]
3089    fn create_application_persists_tags() {
3090        let s = svc();
3091        s.create_application(&req(
3092            "CreateApplication",
3093            json!({ "applicationName": "tagged", "tags": [{ "Key": "env", "Value": "prod" }] }),
3094        ))
3095        .unwrap();
3096        let arn = "arn:aws:codedeploy:us-east-1:000000000000:application:tagged";
3097        let tags = tags_for(&s, arn);
3098        assert_eq!(tags.len(), 1);
3099        assert_eq!(tags[0]["Key"], "env");
3100        assert_eq!(tags[0]["Value"], "prod");
3101    }
3102
3103    // T1.2: CreateDeploymentGroup persists its create-time `tags`.
3104    #[test]
3105    fn create_deployment_group_persists_tags() {
3106        let s = svc();
3107        s.create_application(&req(
3108            "CreateApplication",
3109            json!({ "applicationName": "app" }),
3110        ))
3111        .unwrap();
3112        s.create_deployment_group(&req(
3113            "CreateDeploymentGroup",
3114            json!({
3115                "applicationName": "app",
3116                "deploymentGroupName": "dg",
3117                "serviceRoleArn": "arn:aws:iam::000000000000:role/cd",
3118                "tags": [{ "Key": "team", "Value": "core" }]
3119            }),
3120        ))
3121        .unwrap();
3122        let arn = "arn:aws:codedeploy:us-east-1:000000000000:deploymentgroup:app/dg";
3123        let tags = tags_for(&s, arn);
3124        assert_eq!(tags.len(), 1);
3125        assert_eq!(tags[0]["Key"], "team");
3126    }
3127
3128    // T1.3: CreateDeploymentConfig validates the minimumHealthyHosts type and
3129    // value range with the declared error.
3130    #[test]
3131    fn create_deployment_config_validates_minimum_healthy_hosts() {
3132        let s = svc();
3133        // Bad type.
3134        assert_eq!(
3135            err_code(s.create_deployment_config(&req(
3136                "CreateDeploymentConfig",
3137                json!({ "deploymentConfigName": "c1", "minimumHealthyHosts": { "type": "BOGUS", "value": 1 } })
3138            ))),
3139            "InvalidMinimumHealthyHostValueException"
3140        );
3141        // FLEET_PERCENT out of range.
3142        assert_eq!(
3143            err_code(s.create_deployment_config(&req(
3144                "CreateDeploymentConfig",
3145                json!({ "deploymentConfigName": "c2", "minimumHealthyHosts": { "type": "FLEET_PERCENT", "value": 150 } })
3146            ))),
3147            "InvalidMinimumHealthyHostValueException"
3148        );
3149        // HOST_COUNT negative.
3150        assert_eq!(
3151            err_code(s.create_deployment_config(&req(
3152                "CreateDeploymentConfig",
3153                json!({ "deploymentConfigName": "c3", "minimumHealthyHosts": { "type": "HOST_COUNT", "value": -1 } })
3154            ))),
3155            "InvalidMinimumHealthyHostValueException"
3156        );
3157        // A valid combination is accepted.
3158        s.create_deployment_config(&req(
3159            "CreateDeploymentConfig",
3160            json!({ "deploymentConfigName": "ok", "minimumHealthyHosts": { "type": "FLEET_PERCENT", "value": 50 } }),
3161        ))
3162        .unwrap();
3163    }
3164
3165    // T1.4: CreateDeploymentGroup and UpdateDeploymentGroup reject malformed
3166    // nested enums with the declared errors.
3167    #[test]
3168    fn deployment_group_validates_nested_enums() {
3169        let s = svc();
3170        s.create_application(&req(
3171            "CreateApplication",
3172            json!({ "applicationName": "app" }),
3173        ))
3174        .unwrap();
3175        let role = "arn:aws:iam::000000000000:role/cd";
3176        let base = |extra: Value| -> Value {
3177            let mut m = json!({
3178                "applicationName": "app",
3179                "deploymentGroupName": "dg",
3180                "serviceRoleArn": role,
3181            });
3182            for (k, v) in extra.as_object().unwrap() {
3183                m.as_object_mut().unwrap().insert(k.clone(), v.clone());
3184            }
3185            m
3186        };
3187        let cases = [
3188            (
3189                json!({ "ec2TagFilters": [{ "Key": "k", "Value": "v", "Type": "BOGUS" }] }),
3190                "InvalidEC2TagException",
3191            ),
3192            (
3193                json!({ "onPremisesInstanceTagFilters": [{ "Key": "k", "Value": "v", "Type": "BOGUS" }] }),
3194                "InvalidOnPremisesTagCombinationException",
3195            ),
3196            (
3197                json!({ "triggerConfigurations": [{ "triggerName": "t", "triggerTargetArn": "arn:aws:sns:us-east-1:000000000000:x", "triggerEvents": ["BogusEvent"] }] }),
3198                "InvalidTriggerConfigException",
3199            ),
3200            (
3201                json!({ "autoRollbackConfiguration": { "enabled": true, "events": ["NOT_AN_EVENT"] } }),
3202                "InvalidAutoRollbackConfigException",
3203            ),
3204        ];
3205        for (extra, code) in &cases {
3206            assert_eq!(
3207                err_code(
3208                    s.create_deployment_group(&req("CreateDeploymentGroup", base(extra.clone())))
3209                ),
3210                *code,
3211                "create: {extra}"
3212            );
3213        }
3214        // The same validation runs on update. Create a valid group first.
3215        s.create_deployment_group(&req("CreateDeploymentGroup", base(json!({}))))
3216            .unwrap();
3217        assert_eq!(
3218            err_code(s.update_deployment_group(&req(
3219                "UpdateDeploymentGroup",
3220                json!({
3221                    "applicationName": "app",
3222                    "currentDeploymentGroupName": "dg",
3223                    "ec2TagFilters": [{ "Key": "k", "Value": "v", "Type": "BOGUS" }]
3224                })
3225            ))),
3226            "InvalidEC2TagException"
3227        );
3228        // Valid nested enums are accepted.
3229        s.update_deployment_group(&req(
3230            "UpdateDeploymentGroup",
3231            json!({
3232                "applicationName": "app",
3233                "currentDeploymentGroupName": "dg",
3234                "autoRollbackConfiguration": { "enabled": true, "events": ["DEPLOYMENT_FAILURE"] },
3235                "triggerConfigurations": [{ "triggerName": "t", "triggerTargetArn": "arn:aws:sns:us-east-1:000000000000:x", "triggerEvents": ["DeploymentSuccess"] }]
3236            }),
3237        ))
3238        .unwrap();
3239    }
3240
3241    // Suspicious: CreateDeployment requires a deployment group name, and the
3242    // named group must exist.
3243    #[test]
3244    fn create_deployment_requires_and_checks_group() {
3245        let s = svc();
3246        s.create_application(&req(
3247            "CreateApplication",
3248            json!({ "applicationName": "app" }),
3249        ))
3250        .unwrap();
3251        assert_eq!(
3252            err_code(s.create_deployment(&req(
3253                "CreateDeployment",
3254                json!({ "applicationName": "app" })
3255            ))),
3256            "DeploymentGroupNameRequiredException"
3257        );
3258        assert_eq!(
3259            err_code(s.create_deployment(&req(
3260                "CreateDeployment",
3261                json!({ "applicationName": "app", "deploymentGroupName": "ghost" })
3262            ))),
3263            "DeploymentGroupDoesNotExistException"
3264        );
3265    }
3266
3267    // T2.5: CreateDeployment inherits the target group's deploymentStyle.
3268    #[test]
3269    fn create_deployment_inherits_deployment_style() {
3270        let s = svc();
3271        s.create_application(&req(
3272            "CreateApplication",
3273            json!({ "applicationName": "app" }),
3274        ))
3275        .unwrap();
3276        s.create_deployment_group(&req(
3277            "CreateDeploymentGroup",
3278            json!({
3279                "applicationName": "app",
3280                "deploymentGroupName": "dg",
3281                "serviceRoleArn": "arn:aws:iam::000000000000:role/cd",
3282                "deploymentStyle": { "deploymentType": "BLUE_GREEN", "deploymentOption": "WITH_TRAFFIC_CONTROL" }
3283            }),
3284        ))
3285        .unwrap();
3286        let id = body_of(
3287            s.create_deployment(&req(
3288                "CreateDeployment",
3289                json!({ "applicationName": "app", "deploymentGroupName": "dg" }),
3290            ))
3291            .unwrap(),
3292        )["deploymentId"]
3293            .as_str()
3294            .unwrap()
3295            .to_string();
3296        let got = body_of(
3297            s.get_deployment(&req("GetDeployment", json!({ "deploymentId": id })))
3298                .unwrap(),
3299        );
3300        assert_eq!(
3301            got["deploymentInfo"]["deploymentStyle"]["deploymentType"],
3302            "BLUE_GREEN"
3303        );
3304        assert_eq!(
3305            got["deploymentInfo"]["deploymentStyle"]["deploymentOption"],
3306            "WITH_TRAFFIC_CONTROL"
3307        );
3308    }
3309
3310    // T2.6: CreateDeployment carries overrideAlarmConfiguration into the stored
3311    // deployment so GetDeployment echoes it.
3312    #[test]
3313    fn create_deployment_carries_override_alarm_configuration() {
3314        let s = svc();
3315        make_app_and_group(&s, "app", "dg");
3316        let id = body_of(
3317            s.create_deployment(&req(
3318                "CreateDeployment",
3319                json!({
3320                    "applicationName": "app",
3321                    "deploymentGroupName": "dg",
3322                    "overrideAlarmConfiguration": { "enabled": true, "alarms": [{ "name": "cpu-high" }] }
3323                }),
3324            ))
3325            .unwrap(),
3326        )["deploymentId"]
3327            .as_str()
3328            .unwrap()
3329            .to_string();
3330        let got = body_of(
3331            s.get_deployment(&req("GetDeployment", json!({ "deploymentId": id })))
3332                .unwrap(),
3333        );
3334        assert_eq!(
3335            got["deploymentInfo"]["overrideAlarmConfiguration"]["enabled"],
3336            true
3337        );
3338        assert_eq!(
3339            got["deploymentInfo"]["overrideAlarmConfiguration"]["alarms"][0]["name"],
3340            "cpu-high"
3341        );
3342    }
3343
3344    // T2.7: deleting a resource purges its tag entry, so a same-named
3345    // recreation does not resurrect stale tags.
3346    #[test]
3347    fn delete_purges_resource_tags() {
3348        let s = svc();
3349        // Application.
3350        s.create_application(&req(
3351            "CreateApplication",
3352            json!({ "applicationName": "app", "tags": [{ "Key": "k", "Value": "v" }] }),
3353        ))
3354        .unwrap();
3355        let app_arn = "arn:aws:codedeploy:us-east-1:000000000000:application:app";
3356        assert_eq!(tags_for(&s, app_arn).len(), 1);
3357        // Deployment group with tags, under the same app.
3358        s.create_deployment_group(&req(
3359            "CreateDeploymentGroup",
3360            json!({
3361                "applicationName": "app",
3362                "deploymentGroupName": "dg",
3363                "serviceRoleArn": "arn:aws:iam::000000000000:role/cd",
3364                "tags": [{ "Key": "t", "Value": "core" }]
3365            }),
3366        ))
3367        .unwrap();
3368        let dg_arn = "arn:aws:codedeploy:us-east-1:000000000000:deploymentgroup:app/dg";
3369        assert_eq!(tags_for(&s, dg_arn).len(), 1);
3370        // Deleting the application purges both the app and group tag entries.
3371        s.delete_application(&req(
3372            "DeleteApplication",
3373            json!({ "applicationName": "app" }),
3374        ))
3375        .unwrap();
3376        assert_eq!(tags_for(&s, app_arn).len(), 0);
3377        assert_eq!(tags_for(&s, dg_arn).len(), 0);
3378
3379        // Deployment config.
3380        s.create_deployment_config(&req(
3381            "CreateDeploymentConfig",
3382            json!({ "deploymentConfigName": "cfg", "minimumHealthyHosts": { "type": "HOST_COUNT", "value": 1 } }),
3383        ))
3384        .unwrap();
3385        let cfg_arn = "arn:aws:codedeploy:us-east-1:000000000000:deploymentconfig:cfg";
3386        s.tag_resource(&req(
3387            "TagResource",
3388            json!({ "ResourceArn": cfg_arn, "Tags": [{ "Key": "k", "Value": "v" }] }),
3389        ))
3390        .unwrap();
3391        assert_eq!(tags_for(&s, cfg_arn).len(), 1);
3392        s.delete_deployment_config(&req(
3393            "DeleteDeploymentConfig",
3394            json!({ "deploymentConfigName": "cfg" }),
3395        ))
3396        .unwrap();
3397        assert_eq!(tags_for(&s, cfg_arn).len(), 0);
3398
3399        // Deleting a deployment group directly also purges its tags.
3400        make_app_and_group(&s, "app2", "dg2");
3401        let dg2_arn = "arn:aws:codedeploy:us-east-1:000000000000:deploymentgroup:app2/dg2";
3402        s.tag_resource(&req(
3403            "TagResource",
3404            json!({ "ResourceArn": dg2_arn, "Tags": [{ "Key": "k", "Value": "v" }] }),
3405        ))
3406        .unwrap();
3407        assert_eq!(tags_for(&s, dg2_arn).len(), 1);
3408        s.delete_deployment_group(&req(
3409            "DeleteDeploymentGroup",
3410            json!({ "applicationName": "app2", "deploymentGroupName": "dg2" }),
3411        ))
3412        .unwrap();
3413        assert_eq!(tags_for(&s, dg2_arn).len(), 0);
3414    }
3415}