Skip to main content

fakecloud_eks/
service.rs

1//! AWS EKS REST-JSON service handler.
2//!
3//! Implements the cluster control plane (Create/Describe/List/DeleteCluster,
4//! UpdateClusterConfig, UpdateClusterVersion, Describe/ListUpdates,
5//! Tag/Untag/ListTagsForResource) plus managed node groups
6//! (Create/Describe/List/DeleteNodegroup, UpdateNodegroupConfig/Version) and
7//! Fargate profiles (Create/Describe/List/DeleteFargateProfile), add-ons,
8//! access entries and their access-policy associations, OIDC identity-provider
9//! configs, and Pod Identity associations, all modeled as cluster
10//! sub-resources.
11
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use http::{Method, StatusCode};
15use serde_json::{json, Value};
16use std::sync::Arc;
17use tokio::sync::Mutex as AsyncMutex;
18
19use fakecloud_core::pagination::paginate_checked;
20use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
21use fakecloud_persistence::SnapshotStore;
22
23use crate::state::{
24    access_entry_arn, addon_arn, capability_arn, cluster_arn, eks_anywhere_subscription_arn,
25    fargate_profile_arn, identity_provider_config_arn, nodegroup_arn, pod_identity_association_arn,
26    AccessEntry, Addon, AssociatedPolicy, Capability, Cluster, EksAnywhereSubscription,
27    EksSnapshot, FargateProfile, IdentityProviderConfig, Insight, InsightsRefresh, Nodegroup,
28    PodIdentityAssociation, SharedEksState, Update, DEFAULT_K8S_VERSION,
29    EKS_SNAPSHOT_SCHEMA_VERSION,
30};
31
32/// The set of control-plane log types EKS reports on every cluster.
33const LOG_TYPES: &[&str] = &[
34    "api",
35    "audit",
36    "authenticator",
37    "controllerManager",
38    "scheduler",
39];
40
41pub const EKS_ACTIONS: &[&str] = &[
42    "CreateCluster",
43    "DescribeCluster",
44    "ListClusters",
45    "DeleteCluster",
46    "UpdateClusterConfig",
47    "UpdateClusterVersion",
48    "DescribeUpdate",
49    "ListUpdates",
50    "TagResource",
51    "UntagResource",
52    "ListTagsForResource",
53    "CreateNodegroup",
54    "DescribeNodegroup",
55    "ListNodegroups",
56    "DeleteNodegroup",
57    "UpdateNodegroupConfig",
58    "UpdateNodegroupVersion",
59    "CreateFargateProfile",
60    "DescribeFargateProfile",
61    "ListFargateProfiles",
62    "DeleteFargateProfile",
63    "CreateAddon",
64    "DescribeAddon",
65    "ListAddons",
66    "DeleteAddon",
67    "UpdateAddon",
68    "DescribeAddonVersions",
69    "DescribeAddonConfiguration",
70    "CreateAccessEntry",
71    "DescribeAccessEntry",
72    "ListAccessEntries",
73    "DeleteAccessEntry",
74    "UpdateAccessEntry",
75    "AssociateAccessPolicy",
76    "DisassociateAccessPolicy",
77    "ListAssociatedAccessPolicies",
78    "ListAccessPolicies",
79    "AssociateIdentityProviderConfig",
80    "DisassociateIdentityProviderConfig",
81    "DescribeIdentityProviderConfig",
82    "ListIdentityProviderConfigs",
83    "CreatePodIdentityAssociation",
84    "DeletePodIdentityAssociation",
85    "DescribePodIdentityAssociation",
86    "ListPodIdentityAssociations",
87    "UpdatePodIdentityAssociation",
88    "DescribeInsight",
89    "ListInsights",
90    "DescribeInsightsRefresh",
91    "StartInsightsRefresh",
92    "AssociateEncryptionConfig",
93    "CancelUpdate",
94    "DeregisterCluster",
95    "RegisterCluster",
96    "DescribeClusterVersions",
97    "CreateCapability",
98    "DeleteCapability",
99    "DescribeCapability",
100    "ListCapabilities",
101    "UpdateCapability",
102    "CreateEksAnywhereSubscription",
103    "DeleteEksAnywhereSubscription",
104    "DescribeEksAnywhereSubscription",
105    "ListEksAnywhereSubscriptions",
106    "UpdateEksAnywhereSubscription",
107];
108
109pub struct EksService {
110    state: SharedEksState,
111    snapshot_store: Option<Arc<dyn SnapshotStore>>,
112    snapshot_lock: Arc<AsyncMutex<()>>,
113}
114
115enum PathArgs {
116    None,
117    Name(String),
118    Update {
119        name: String,
120        update_id: String,
121    },
122    Arn(String),
123    /// A sub-resource collection scoped to a cluster (create/list).
124    Cluster(String),
125    /// A named sub-resource of a cluster (describe/delete/update).
126    ClusterChild {
127        cluster: String,
128        name: String,
129    },
130    /// A named access policy association on an access entry
131    /// (`DisassociateAccessPolicy`).
132    AccessPolicyChild {
133        cluster: String,
134        principal: String,
135        policy_arn: String,
136    },
137}
138
139impl EksService {
140    pub fn new(state: SharedEksState) -> Self {
141        Self {
142            state,
143            snapshot_store: None,
144            snapshot_lock: Arc::new(AsyncMutex::new(())),
145        }
146    }
147
148    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
149        self.snapshot_store = Some(store);
150        self
151    }
152
153    async fn save_snapshot(&self) {
154        save_eks_snapshot(
155            &self.state,
156            self.snapshot_store.clone(),
157            &self.snapshot_lock,
158        )
159        .await;
160    }
161
162    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
163    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
164        let store = self.snapshot_store.clone()?;
165        let state = self.state.clone();
166        let lock = self.snapshot_lock.clone();
167        Some(Arc::new(move || {
168            let state = state.clone();
169            let store = store.clone();
170            let lock = lock.clone();
171            Box::pin(async move {
172                save_eks_snapshot(&state, Some(store), &lock).await;
173            })
174        }))
175    }
176
177    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, PathArgs)> {
178        // Derive segments from the raw path, preserving INTERNAL empty segments
179        // (only the leading empty from the leading `/` and a single trailing
180        // empty are dropped). `req.path_segments` strips every empty part, which
181        // would collapse a real 3-segment path with an empty middle label
182        // (e.g. `/clusters//addons`, ListAddons with an empty clusterName) down
183        // to `["clusters","addons"]` and mis-route it to DescribeCluster. Keeping
184        // internal empties makes the exact-slice matches below precise, so a
185        // not-yet-implemented sub-resource path falls through to None.
186        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
187        let trimmed = raw.strip_prefix('/').unwrap_or(raw);
188        let trimmed = trimmed.strip_suffix('/').unwrap_or(trimmed);
189        let segs: Vec<&str> = if trimmed.is_empty() {
190            Vec::new()
191        } else {
192            trimmed.split('/').collect()
193        };
194        match (&req.method, segs.as_slice()) {
195            (&Method::POST, ["clusters"]) => Some(("CreateCluster", PathArgs::None)),
196            (&Method::GET, ["clusters"]) => Some(("ListClusters", PathArgs::None)),
197            (&Method::GET, ["clusters", name]) => {
198                Some(("DescribeCluster", PathArgs::Name(decode(name))))
199            }
200            (&Method::DELETE, ["clusters", name]) => {
201                Some(("DeleteCluster", PathArgs::Name(decode(name))))
202            }
203            (&Method::POST, ["clusters", name, "update-config"]) => {
204                Some(("UpdateClusterConfig", PathArgs::Name(decode(name))))
205            }
206            (&Method::POST, ["clusters", name, "updates"]) => {
207                Some(("UpdateClusterVersion", PathArgs::Name(decode(name))))
208            }
209            (&Method::GET, ["clusters", name, "updates"]) => {
210                Some(("ListUpdates", PathArgs::Name(decode(name))))
211            }
212            (&Method::GET, ["clusters", name, "updates", update_id]) => Some((
213                "DescribeUpdate",
214                PathArgs::Update {
215                    name: decode(name),
216                    update_id: decode(update_id),
217                },
218            )),
219            // Node groups (sub-resources of a cluster).
220            (&Method::POST, ["clusters", c, "node-groups"]) => {
221                Some(("CreateNodegroup", PathArgs::Cluster(decode(c))))
222            }
223            (&Method::GET, ["clusters", c, "node-groups"]) => {
224                Some(("ListNodegroups", PathArgs::Cluster(decode(c))))
225            }
226            (&Method::GET, ["clusters", c, "node-groups", n]) => Some((
227                "DescribeNodegroup",
228                PathArgs::ClusterChild {
229                    cluster: decode(c),
230                    name: decode(n),
231                },
232            )),
233            (&Method::DELETE, ["clusters", c, "node-groups", n]) => Some((
234                "DeleteNodegroup",
235                PathArgs::ClusterChild {
236                    cluster: decode(c),
237                    name: decode(n),
238                },
239            )),
240            (&Method::POST, ["clusters", c, "node-groups", n, "update-config"]) => Some((
241                "UpdateNodegroupConfig",
242                PathArgs::ClusterChild {
243                    cluster: decode(c),
244                    name: decode(n),
245                },
246            )),
247            (&Method::POST, ["clusters", c, "node-groups", n, "update-version"]) => Some((
248                "UpdateNodegroupVersion",
249                PathArgs::ClusterChild {
250                    cluster: decode(c),
251                    name: decode(n),
252                },
253            )),
254            // Fargate profiles (sub-resources of a cluster).
255            (&Method::POST, ["clusters", c, "fargate-profiles"]) => {
256                Some(("CreateFargateProfile", PathArgs::Cluster(decode(c))))
257            }
258            (&Method::GET, ["clusters", c, "fargate-profiles"]) => {
259                Some(("ListFargateProfiles", PathArgs::Cluster(decode(c))))
260            }
261            (&Method::GET, ["clusters", c, "fargate-profiles", n]) => Some((
262                "DescribeFargateProfile",
263                PathArgs::ClusterChild {
264                    cluster: decode(c),
265                    name: decode(n),
266                },
267            )),
268            (&Method::DELETE, ["clusters", c, "fargate-profiles", n]) => Some((
269                "DeleteFargateProfile",
270                PathArgs::ClusterChild {
271                    cluster: decode(c),
272                    name: decode(n),
273                },
274            )),
275            // Add-ons (sub-resources of a cluster).
276            (&Method::POST, ["clusters", c, "addons"]) => {
277                Some(("CreateAddon", PathArgs::Cluster(decode(c))))
278            }
279            (&Method::GET, ["clusters", c, "addons"]) => {
280                Some(("ListAddons", PathArgs::Cluster(decode(c))))
281            }
282            (&Method::GET, ["clusters", c, "addons", n]) => Some((
283                "DescribeAddon",
284                PathArgs::ClusterChild {
285                    cluster: decode(c),
286                    name: decode(n),
287                },
288            )),
289            (&Method::DELETE, ["clusters", c, "addons", n]) => Some((
290                "DeleteAddon",
291                PathArgs::ClusterChild {
292                    cluster: decode(c),
293                    name: decode(n),
294                },
295            )),
296            (&Method::POST, ["clusters", c, "addons", n, "update"]) => Some((
297                "UpdateAddon",
298                PathArgs::ClusterChild {
299                    cluster: decode(c),
300                    name: decode(n),
301                },
302            )),
303            // Add-on catalogue ops (not scoped to a cluster).
304            (&Method::GET, ["addons", "supported-versions"]) => {
305                Some(("DescribeAddonVersions", PathArgs::None))
306            }
307            (&Method::GET, ["addons", "configuration-schemas"]) => {
308                Some(("DescribeAddonConfiguration", PathArgs::None))
309            }
310            // Access entries (sub-resources of a cluster, keyed by principalArn).
311            (&Method::POST, ["clusters", c, "access-entries"]) => {
312                Some(("CreateAccessEntry", PathArgs::Cluster(decode(c))))
313            }
314            (&Method::GET, ["clusters", c, "access-entries"]) => {
315                Some(("ListAccessEntries", PathArgs::Cluster(decode(c))))
316            }
317            (&Method::GET, ["clusters", c, "access-entries", p]) => Some((
318                "DescribeAccessEntry",
319                PathArgs::ClusterChild {
320                    cluster: decode(c),
321                    name: decode(p),
322                },
323            )),
324            (&Method::DELETE, ["clusters", c, "access-entries", p]) => Some((
325                "DeleteAccessEntry",
326                PathArgs::ClusterChild {
327                    cluster: decode(c),
328                    name: decode(p),
329                },
330            )),
331            (&Method::POST, ["clusters", c, "access-entries", p]) => Some((
332                "UpdateAccessEntry",
333                PathArgs::ClusterChild {
334                    cluster: decode(c),
335                    name: decode(p),
336                },
337            )),
338            // Access-policy associations on an access entry.
339            (&Method::POST, ["clusters", c, "access-entries", p, "access-policies"]) => Some((
340                "AssociateAccessPolicy",
341                PathArgs::ClusterChild {
342                    cluster: decode(c),
343                    name: decode(p),
344                },
345            )),
346            (&Method::GET, ["clusters", c, "access-entries", p, "access-policies"]) => Some((
347                "ListAssociatedAccessPolicies",
348                PathArgs::ClusterChild {
349                    cluster: decode(c),
350                    name: decode(p),
351                },
352            )),
353            (&Method::DELETE, ["clusters", c, "access-entries", p, "access-policies", policy]) => {
354                Some((
355                    "DisassociateAccessPolicy",
356                    PathArgs::AccessPolicyChild {
357                        cluster: decode(c),
358                        principal: decode(p),
359                        policy_arn: decode(policy),
360                    },
361                ))
362            }
363            // Access-policy catalogue (not scoped to a cluster).
364            (&Method::GET, ["access-policies"]) => Some(("ListAccessPolicies", PathArgs::None)),
365            // Identity-provider configs (sub-resources of a cluster). The
366            // associate/disassociate/describe forms are POSTs to fixed action
367            // sub-paths, carrying the config identity in the body.
368            (&Method::POST, ["clusters", c, "identity-provider-configs", "associate"]) => Some((
369                "AssociateIdentityProviderConfig",
370                PathArgs::Cluster(decode(c)),
371            )),
372            (&Method::POST, ["clusters", c, "identity-provider-configs", "disassociate"]) => {
373                Some((
374                    "DisassociateIdentityProviderConfig",
375                    PathArgs::Cluster(decode(c)),
376                ))
377            }
378            (&Method::POST, ["clusters", c, "identity-provider-configs", "describe"]) => Some((
379                "DescribeIdentityProviderConfig",
380                PathArgs::Cluster(decode(c)),
381            )),
382            (&Method::GET, ["clusters", c, "identity-provider-configs"]) => {
383                Some(("ListIdentityProviderConfigs", PathArgs::Cluster(decode(c))))
384            }
385            // Pod identity associations (sub-resources of a cluster, keyed by id).
386            (&Method::POST, ["clusters", c, "pod-identity-associations"]) => {
387                Some(("CreatePodIdentityAssociation", PathArgs::Cluster(decode(c))))
388            }
389            (&Method::GET, ["clusters", c, "pod-identity-associations"]) => {
390                Some(("ListPodIdentityAssociations", PathArgs::Cluster(decode(c))))
391            }
392            (&Method::GET, ["clusters", c, "pod-identity-associations", id]) => Some((
393                "DescribePodIdentityAssociation",
394                PathArgs::ClusterChild {
395                    cluster: decode(c),
396                    name: decode(id),
397                },
398            )),
399            (&Method::DELETE, ["clusters", c, "pod-identity-associations", id]) => Some((
400                "DeletePodIdentityAssociation",
401                PathArgs::ClusterChild {
402                    cluster: decode(c),
403                    name: decode(id),
404                },
405            )),
406            (&Method::POST, ["clusters", c, "pod-identity-associations", id]) => Some((
407                "UpdatePodIdentityAssociation",
408                PathArgs::ClusterChild {
409                    cluster: decode(c),
410                    name: decode(id),
411                },
412            )),
413            // Insights (sub-resources of a cluster). List is a POST (it carries
414            // a filter body); Describe is a GET keyed by insight id.
415            (&Method::POST, ["clusters", c, "insights"]) => {
416                Some(("ListInsights", PathArgs::Cluster(decode(c))))
417            }
418            (&Method::GET, ["clusters", c, "insights", id]) => Some((
419                "DescribeInsight",
420                PathArgs::ClusterChild {
421                    cluster: decode(c),
422                    name: decode(id),
423                },
424            )),
425            (&Method::GET, ["clusters", c, "insights-refresh"]) => {
426                Some(("DescribeInsightsRefresh", PathArgs::Cluster(decode(c))))
427            }
428            (&Method::POST, ["clusters", c, "insights-refresh"]) => {
429                Some(("StartInsightsRefresh", PathArgs::Cluster(decode(c))))
430            }
431            // Encryption config association (mints a tracked cluster Update).
432            (&Method::POST, ["clusters", c, "encryption-config", "associate"]) => {
433                Some(("AssociateEncryptionConfig", PathArgs::Cluster(decode(c))))
434            }
435            // Cancel an in-progress update.
436            (&Method::POST, ["clusters", name, "updates", update_id, "cancel-update"]) => Some((
437                "CancelUpdate",
438                PathArgs::Update {
439                    name: decode(name),
440                    update_id: decode(update_id),
441                },
442            )),
443            // Connected (registered) clusters.
444            (&Method::POST, ["cluster-registrations"]) => Some(("RegisterCluster", PathArgs::None)),
445            (&Method::DELETE, ["cluster-registrations", name]) => {
446                Some(("DeregisterCluster", PathArgs::Name(decode(name))))
447            }
448            // Kubernetes version catalogue (account/region-scoped).
449            (&Method::GET, ["cluster-versions"]) => {
450                Some(("DescribeClusterVersions", PathArgs::None))
451            }
452            // Capabilities (sub-resources of a cluster).
453            (&Method::POST, ["clusters", c, "capabilities"]) => {
454                Some(("CreateCapability", PathArgs::Cluster(decode(c))))
455            }
456            (&Method::GET, ["clusters", c, "capabilities"]) => {
457                Some(("ListCapabilities", PathArgs::Cluster(decode(c))))
458            }
459            (&Method::GET, ["clusters", c, "capabilities", n]) => Some((
460                "DescribeCapability",
461                PathArgs::ClusterChild {
462                    cluster: decode(c),
463                    name: decode(n),
464                },
465            )),
466            (&Method::DELETE, ["clusters", c, "capabilities", n]) => Some((
467                "DeleteCapability",
468                PathArgs::ClusterChild {
469                    cluster: decode(c),
470                    name: decode(n),
471                },
472            )),
473            (&Method::POST, ["clusters", c, "capabilities", n]) => Some((
474                "UpdateCapability",
475                PathArgs::ClusterChild {
476                    cluster: decode(c),
477                    name: decode(n),
478                },
479            )),
480            // EKS Anywhere subscriptions (account-scoped).
481            (&Method::POST, ["eks-anywhere-subscriptions"]) => {
482                Some(("CreateEksAnywhereSubscription", PathArgs::None))
483            }
484            (&Method::GET, ["eks-anywhere-subscriptions"]) => {
485                Some(("ListEksAnywhereSubscriptions", PathArgs::None))
486            }
487            (&Method::GET, ["eks-anywhere-subscriptions", id]) => Some((
488                "DescribeEksAnywhereSubscription",
489                PathArgs::Name(decode(id)),
490            )),
491            (&Method::DELETE, ["eks-anywhere-subscriptions", id]) => {
492                Some(("DeleteEksAnywhereSubscription", PathArgs::Name(decode(id))))
493            }
494            (&Method::POST, ["eks-anywhere-subscriptions", id]) => {
495                Some(("UpdateEksAnywhereSubscription", PathArgs::Name(decode(id))))
496            }
497            (&Method::POST, ["tags", arn]) => Some(("TagResource", PathArgs::Arn(decode(arn)))),
498            (&Method::DELETE, ["tags", arn]) => Some(("UntagResource", PathArgs::Arn(decode(arn)))),
499            (&Method::GET, ["tags", arn]) => {
500                Some(("ListTagsForResource", PathArgs::Arn(decode(arn))))
501            }
502            _ => None,
503        }
504    }
505
506    fn create_cluster(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
507        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
508
509        let name = body
510            .get("name")
511            .and_then(|v| v.as_str())
512            .ok_or_else(|| invalid_parameter("name is required"))?
513            .to_string();
514        validate_cluster_name(&name)?;
515
516        let role_arn = body
517            .get("roleArn")
518            .and_then(|v| v.as_str())
519            .ok_or_else(|| invalid_parameter("roleArn is required"))?
520            .to_string();
521
522        let vpc_req = body
523            .get("resourcesVpcConfig")
524            .filter(|v| v.is_object())
525            .ok_or_else(|| invalid_parameter("resourcesVpcConfig is required"))?;
526
527        let version = body
528            .get("version")
529            .and_then(|v| v.as_str())
530            .unwrap_or(DEFAULT_K8S_VERSION)
531            .to_string();
532
533        let mut accounts = self.state.write();
534        let state = accounts.get_or_create(&req.account_id);
535        if state.clusters.contains_key(&name) {
536            return Err(AwsServiceError::aws_error(
537                StatusCode::CONFLICT,
538                "ResourceInUseException",
539                format!("Cluster already exists with name: {name}"),
540            ));
541        }
542
543        let region = req.region.clone();
544        let account_id = req.account_id.clone();
545        let arn = cluster_arn(&region, &account_id, &name);
546        let id = uuid::Uuid::new_v4().to_string();
547
548        let tags = parse_tag_map(body.get("tags"));
549
550        let cluster = Cluster {
551            name: name.clone(),
552            arn: arn.clone(),
553            version,
554            role_arn,
555            status: "CREATING".to_string(),
556            created_at: Utc::now(),
557            endpoint: format!(
558                "https://{}.gr7.{region}.eks.amazonaws.com",
559                id.replace('-', "").to_uppercase()
560            ),
561            platform_version: "eks.1".to_string(),
562            certificate_authority_data: default_ca_data(),
563            resources_vpc_config: build_vpc_config_response(vpc_req, &id),
564            kubernetes_network_config: build_k8s_network_config(
565                body.get("kubernetesNetworkConfig"),
566            ),
567            logging: build_logging(body.get("logging")),
568            tags,
569            updates: Default::default(),
570            connector_config: None,
571            encryption_config: None,
572            access_config: build_access_config(body.get("accessConfig")),
573            upgrade_policy: build_upgrade_policy(body.get("upgradePolicy")),
574        };
575
576        let out = cluster_json(&cluster, &id);
577        state.clusters.insert(name, cluster);
578        Ok(AwsResponse::json(
579            StatusCode::OK,
580            json!({ "cluster": out }).to_string(),
581        ))
582    }
583
584    fn describe_cluster(
585        &self,
586        req: &AwsRequest,
587        name: &str,
588    ) -> Result<AwsResponse, AwsServiceError> {
589        let mut accounts = self.state.write();
590        let state = accounts.get_or_create(&req.account_id);
591        let cluster = state
592            .clusters
593            .get_mut(name)
594            .ok_or_else(not_found_cluster(name))?;
595        // Lazily settle CREATING -> ACTIVE on first describe, mirroring the
596        // eventual-consistency of the real control plane.
597        if cluster.status == "CREATING" {
598            cluster.status = "ACTIVE".to_string();
599        }
600        let id = arn_cluster_id(&cluster.endpoint);
601        Ok(AwsResponse::json(
602            StatusCode::OK,
603            json!({ "cluster": cluster_json(cluster, &id) }).to_string(),
604        ))
605    }
606
607    fn list_clusters(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
608        let max_results = validate_max_results(req)?;
609        let next_token = req.query_params.get("nextToken").cloned();
610
611        let accounts = self.state.read();
612        let Some(state) = accounts.get(&req.account_id) else {
613            return Ok(AwsResponse::json(
614                StatusCode::OK,
615                json!({ "clusters": [] }).to_string(),
616            ));
617        };
618        let names: Vec<String> = state.clusters.keys().cloned().collect();
619        let (page, token) = paginate_checked(&names, next_token.as_deref(), max_results)
620            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
621        let mut out = json!({ "clusters": page });
622        if let Some(t) = token {
623            out["nextToken"] = Value::String(t);
624        }
625        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
626    }
627
628    fn delete_cluster(&self, req: &AwsRequest, name: &str) -> Result<AwsResponse, AwsServiceError> {
629        let mut accounts = self.state.write();
630        let state = accounts.get_or_create(&req.account_id);
631        let mut cluster = state
632            .clusters
633            .remove(name)
634            .ok_or_else(not_found_cluster(name))?;
635        cluster.status = "DELETING".to_string();
636        let id = arn_cluster_id(&cluster.endpoint);
637        Ok(AwsResponse::json(
638            StatusCode::OK,
639            json!({ "cluster": cluster_json(&cluster, &id) }).to_string(),
640        ))
641    }
642
643    fn update_cluster_config(
644        &self,
645        req: &AwsRequest,
646        name: &str,
647    ) -> Result<AwsResponse, AwsServiceError> {
648        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
649        let mut accounts = self.state.write();
650        let state = accounts.get_or_create(&req.account_id);
651        let cluster = state
652            .clusters
653            .get_mut(name)
654            .ok_or_else(not_found_cluster(name))?;
655
656        let (update_type, params) = if let Some(logging) = body.get("logging") {
657            cluster.logging = build_logging(Some(logging));
658            (
659                "LoggingUpdate",
660                vec![("ClusterLogging".to_string(), logging.to_string())],
661            )
662        } else if let Some(vpc) = body.get("resourcesVpcConfig") {
663            let id = arn_cluster_id(&cluster.endpoint);
664            cluster.resources_vpc_config = build_vpc_config_response(vpc, &id);
665            (
666                "EndpointAccessUpdate",
667                vec![("EndpointPublicAccess".to_string(), vpc.to_string())],
668            )
669        } else {
670            ("ConfigUpdate", Vec::new())
671        };
672
673        let update = new_update(update_type, params);
674        let out = update_json(&update);
675        cluster.updates.insert(update.id.clone(), update);
676        Ok(AwsResponse::json(
677            StatusCode::OK,
678            json!({ "update": out }).to_string(),
679        ))
680    }
681
682    fn update_cluster_version(
683        &self,
684        req: &AwsRequest,
685        name: &str,
686    ) -> Result<AwsResponse, AwsServiceError> {
687        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
688        let version = body
689            .get("version")
690            .and_then(|v| v.as_str())
691            .ok_or_else(|| invalid_parameter("version is required"))?
692            .to_string();
693
694        let mut accounts = self.state.write();
695        let state = accounts.get_or_create(&req.account_id);
696        let cluster = state
697            .clusters
698            .get_mut(name)
699            .ok_or_else(not_found_cluster(name))?;
700        cluster.version = version.clone();
701
702        let update = new_update(
703            "VersionUpdate",
704            vec![
705                ("Version".to_string(), version),
706                (
707                    "PlatformVersion".to_string(),
708                    cluster.platform_version.clone(),
709                ),
710            ],
711        );
712        let out = update_json(&update);
713        cluster.updates.insert(update.id.clone(), update);
714        Ok(AwsResponse::json(
715            StatusCode::OK,
716            json!({ "update": out }).to_string(),
717        ))
718    }
719
720    fn describe_update(
721        &self,
722        req: &AwsRequest,
723        name: &str,
724        update_id: &str,
725    ) -> Result<AwsResponse, AwsServiceError> {
726        let nodegroup_name = req.query_params.get("nodegroupName").cloned();
727        let addon_name = req.query_params.get("addonName").cloned();
728        let mut accounts = self.state.write();
729        let state = accounts.get_or_create(&req.account_id);
730        if !state.clusters.contains_key(name) {
731            return Err(not_found_cluster(name)());
732        }
733        // `DescribeUpdate` disambiguates cluster updates from node-group and
734        // add-on updates via the optional `nodegroupName` / `addonName` query
735        // params, mirroring the real API. When present, the update lives on
736        // that sub-resource's own update history.
737        let update = if let Some(ng_name) = nodegroup_name.as_deref() {
738            let ng = state
739                .nodegroups
740                .get_mut(name)
741                .and_then(|m| m.get_mut(ng_name))
742                .ok_or_else(not_found_nodegroup(ng_name))?;
743            ng.updates
744                .get_mut(update_id)
745                .ok_or_else(not_found_update(update_id))?
746        } else if let Some(a_name) = addon_name.as_deref() {
747            let addon = state
748                .addons
749                .get_mut(name)
750                .and_then(|m| m.get_mut(a_name))
751                .ok_or_else(not_found_addon(a_name))?;
752            addon
753                .updates
754                .get_mut(update_id)
755                .ok_or_else(not_found_update(update_id))?
756        } else {
757            let cluster = state
758                .clusters
759                .get_mut(name)
760                .ok_or_else(not_found_cluster(name))?;
761            cluster
762                .updates
763                .get_mut(update_id)
764                .ok_or_else(not_found_update(update_id))?
765        };
766        // Updates settle to Successful on first describe.
767        if update.status == "InProgress" {
768            update.status = "Successful".to_string();
769        }
770        Ok(AwsResponse::json(
771            StatusCode::OK,
772            json!({ "update": update_json(update) }).to_string(),
773        ))
774    }
775
776    fn list_updates(&self, req: &AwsRequest, name: &str) -> Result<AwsResponse, AwsServiceError> {
777        let max_results = validate_max_results(req)?;
778        let next_token = req.query_params.get("nextToken").cloned();
779
780        let nodegroup_name = req.query_params.get("nodegroupName").cloned();
781        let addon_name = req.query_params.get("addonName").cloned();
782        let accounts = self.state.read();
783        let state = accounts
784            .get(&req.account_id)
785            .ok_or_else(not_found_cluster(name))?;
786        if !state.clusters.contains_key(name) {
787            return Err(not_found_cluster(name)());
788        }
789        let ids: Vec<String> = if let Some(ng_name) = nodegroup_name.as_deref() {
790            let ng = state
791                .nodegroups
792                .get(name)
793                .and_then(|m| m.get(ng_name))
794                .ok_or_else(not_found_nodegroup(ng_name))?;
795            ng.updates.keys().cloned().collect()
796        } else if let Some(a_name) = addon_name.as_deref() {
797            let addon = state
798                .addons
799                .get(name)
800                .and_then(|m| m.get(a_name))
801                .ok_or_else(not_found_addon(a_name))?;
802            addon.updates.keys().cloned().collect()
803        } else {
804            let cluster = state
805                .clusters
806                .get(name)
807                .ok_or_else(not_found_cluster(name))?;
808            cluster.updates.keys().cloned().collect()
809        };
810        let (page, token) = paginate_checked(&ids, next_token.as_deref(), max_results)
811            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
812        let mut out = json!({ "updateIds": page });
813        if let Some(t) = token {
814            out["nextToken"] = Value::String(t);
815        }
816        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
817    }
818
819    fn tag_resource(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
820        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
821        let tags = body
822            .get("tags")
823            .filter(|v| v.is_object())
824            .ok_or_else(|| bad_request("tags is required"))?;
825        let name = cluster_name_from_arn(arn)?;
826        let mut accounts = self.state.write();
827        let state = accounts.get_or_create(&req.account_id);
828        let cluster = state
829            .clusters
830            .get_mut(&name)
831            .ok_or_else(not_found_arn(arn))?;
832        for (k, v) in tags.as_object().unwrap() {
833            if let Some(v) = v.as_str() {
834                cluster.tags.insert(k.clone(), v.to_string());
835            }
836        }
837        Ok(AwsResponse::json(StatusCode::OK, "{}"))
838    }
839
840    fn untag_resource(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
841        let keys = parse_multi_query(&req.raw_query, "tagKeys");
842        let name = cluster_name_from_arn(arn)?;
843        let mut accounts = self.state.write();
844        let state = accounts.get_or_create(&req.account_id);
845        let cluster = state
846            .clusters
847            .get_mut(&name)
848            .ok_or_else(not_found_arn(arn))?;
849        for k in keys {
850            cluster.tags.remove(&k);
851        }
852        Ok(AwsResponse::json(StatusCode::OK, "{}"))
853    }
854
855    fn list_tags_for_resource(
856        &self,
857        req: &AwsRequest,
858        arn: &str,
859    ) -> Result<AwsResponse, AwsServiceError> {
860        let name = cluster_name_from_arn(arn)?;
861        let accounts = self.state.read();
862        let state = accounts
863            .get(&req.account_id)
864            .ok_or_else(not_found_arn(arn))?;
865        let cluster = state.clusters.get(&name).ok_or_else(not_found_arn(arn))?;
866        let tags: serde_json::Map<String, Value> = cluster
867            .tags
868            .iter()
869            .map(|(k, v)| (k.clone(), Value::String(v.clone())))
870            .collect();
871        Ok(AwsResponse::json(
872            StatusCode::OK,
873            json!({ "tags": tags }).to_string(),
874        ))
875    }
876
877    // -----------------------------------------------------------------------
878    // Node groups
879    // -----------------------------------------------------------------------
880
881    fn create_nodegroup(
882        &self,
883        req: &AwsRequest,
884        cluster_name: &str,
885    ) -> Result<AwsResponse, AwsServiceError> {
886        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
887        let name = body
888            .get("nodegroupName")
889            .and_then(|v| v.as_str())
890            .ok_or_else(|| invalid_parameter("nodegroupName is required"))?
891            .to_string();
892        let node_role = body
893            .get("nodeRole")
894            .and_then(|v| v.as_str())
895            .ok_or_else(|| invalid_parameter("nodeRole is required"))?
896            .to_string();
897        let subnets = body
898            .get("subnets")
899            .filter(|v| v.is_array())
900            .cloned()
901            .ok_or_else(|| invalid_parameter("subnets is required"))?;
902
903        let region = req.region.clone();
904        let account_id = req.account_id.clone();
905
906        let mut accounts = self.state.write();
907        let state = accounts.get_or_create(&req.account_id);
908        // The parent cluster must exist. AWS returns ResourceNotFoundException
909        // here even though the Smithy model omits it from CreateNodegroup's
910        // declared errors.
911        let cluster_version = state
912            .clusters
913            .get(cluster_name)
914            .ok_or_else(not_found_cluster(cluster_name))?
915            .version
916            .clone();
917        if state
918            .nodegroups
919            .get(cluster_name)
920            .is_some_and(|m| m.contains_key(&name))
921        {
922            return Err(AwsServiceError::aws_error(
923                StatusCode::CONFLICT,
924                "ResourceInUseException",
925                format!(
926                    "NodeGroup already exists with name {name} and cluster name {cluster_name}"
927                ),
928            ));
929        }
930
931        let id = uuid::Uuid::new_v4().to_string();
932        let arn = nodegroup_arn(&region, &account_id, cluster_name, &name, &id);
933        let now = Utc::now();
934        let version = body
935            .get("version")
936            .and_then(|v| v.as_str())
937            .map(|s| s.to_string())
938            .unwrap_or(cluster_version);
939        let release_version = body
940            .get("releaseVersion")
941            .and_then(|v| v.as_str())
942            .map(|s| s.to_string())
943            .unwrap_or_else(|| format!("{version}-20240000"));
944        let capacity_type = body
945            .get("capacityType")
946            .and_then(|v| v.as_str())
947            .unwrap_or("ON_DEMAND")
948            .to_string();
949        let ami_type = body
950            .get("amiType")
951            .and_then(|v| v.as_str())
952            .unwrap_or("AL2023_x86_64_STANDARD")
953            .to_string();
954        let disk_size = body.get("diskSize").and_then(|v| v.as_i64()).unwrap_or(20);
955
956        let ng = Nodegroup {
957            name: name.clone(),
958            arn,
959            cluster_name: cluster_name.to_string(),
960            version,
961            release_version,
962            status: "CREATING".to_string(),
963            capacity_type,
964            ami_type,
965            node_role,
966            created_at: now,
967            modified_at: now,
968            disk_size,
969            scaling_config: build_scaling_config(body.get("scalingConfig")),
970            update_config: build_nodegroup_update_config(body.get("updateConfig")),
971            instance_types: body
972                .get("instanceTypes")
973                .cloned()
974                .unwrap_or_else(|| json!(["t3.medium"])),
975            subnets,
976            labels: body.get("labels").cloned().unwrap_or_else(|| json!({})),
977            taints: body.get("taints").cloned().unwrap_or_else(|| json!([])),
978            remote_access: body.get("remoteAccess").cloned(),
979            launch_template: body.get("launchTemplate").cloned(),
980            asg_name: format!("eks-{name}-{}", &id[..8]),
981            tags: parse_tag_map(body.get("tags")),
982            updates: Default::default(),
983        };
984
985        let out = nodegroup_json(&ng);
986        state
987            .nodegroups
988            .entry(cluster_name.to_string())
989            .or_default()
990            .insert(name, ng);
991        Ok(AwsResponse::json(
992            StatusCode::OK,
993            json!({ "nodegroup": out }).to_string(),
994        ))
995    }
996
997    fn describe_nodegroup(
998        &self,
999        req: &AwsRequest,
1000        cluster_name: &str,
1001        name: &str,
1002    ) -> Result<AwsResponse, AwsServiceError> {
1003        let mut accounts = self.state.write();
1004        let state = accounts.get_or_create(&req.account_id);
1005        if !state.clusters.contains_key(cluster_name) {
1006            return Err(not_found_cluster(cluster_name)());
1007        }
1008        let ng = state
1009            .nodegroups
1010            .get_mut(cluster_name)
1011            .and_then(|m| m.get_mut(name))
1012            .ok_or_else(not_found_nodegroup(name))?;
1013        if ng.status == "CREATING" {
1014            ng.status = "ACTIVE".to_string();
1015        }
1016        Ok(AwsResponse::json(
1017            StatusCode::OK,
1018            json!({ "nodegroup": nodegroup_json(ng) }).to_string(),
1019        ))
1020    }
1021
1022    fn list_nodegroups(
1023        &self,
1024        req: &AwsRequest,
1025        cluster_name: &str,
1026    ) -> Result<AwsResponse, AwsServiceError> {
1027        let max_results = validate_max_results(req)?;
1028        let next_token = req.query_params.get("nextToken").cloned();
1029        let accounts = self.state.read();
1030        let state = accounts
1031            .get(&req.account_id)
1032            .ok_or_else(not_found_cluster(cluster_name))?;
1033        if !state.clusters.contains_key(cluster_name) {
1034            return Err(not_found_cluster(cluster_name)());
1035        }
1036        let names: Vec<String> = state
1037            .nodegroups
1038            .get(cluster_name)
1039            .map(|m| m.keys().cloned().collect())
1040            .unwrap_or_default();
1041        let (page, token) = paginate_checked(&names, next_token.as_deref(), max_results)
1042            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
1043        let mut out = json!({ "nodegroups": page });
1044        if let Some(t) = token {
1045            out["nextToken"] = Value::String(t);
1046        }
1047        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
1048    }
1049
1050    fn delete_nodegroup(
1051        &self,
1052        req: &AwsRequest,
1053        cluster_name: &str,
1054        name: &str,
1055    ) -> Result<AwsResponse, AwsServiceError> {
1056        let mut accounts = self.state.write();
1057        let state = accounts.get_or_create(&req.account_id);
1058        if !state.clusters.contains_key(cluster_name) {
1059            return Err(not_found_cluster(cluster_name)());
1060        }
1061        let mut ng = state
1062            .nodegroups
1063            .get_mut(cluster_name)
1064            .and_then(|m| m.remove(name))
1065            .ok_or_else(not_found_nodegroup(name))?;
1066        ng.status = "DELETING".to_string();
1067        Ok(AwsResponse::json(
1068            StatusCode::OK,
1069            json!({ "nodegroup": nodegroup_json(&ng) }).to_string(),
1070        ))
1071    }
1072
1073    fn update_nodegroup_config(
1074        &self,
1075        req: &AwsRequest,
1076        cluster_name: &str,
1077        name: &str,
1078    ) -> Result<AwsResponse, AwsServiceError> {
1079        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1080        let mut accounts = self.state.write();
1081        let state = accounts.get_or_create(&req.account_id);
1082        if !state.clusters.contains_key(cluster_name) {
1083            return Err(not_found_cluster(cluster_name)());
1084        }
1085        let ng = state
1086            .nodegroups
1087            .get_mut(cluster_name)
1088            .and_then(|m| m.get_mut(name))
1089            .ok_or_else(not_found_nodegroup(name))?;
1090
1091        let mut params = Vec::new();
1092        if let Some(scaling) = body.get("scalingConfig") {
1093            ng.scaling_config = build_scaling_config(Some(scaling));
1094            params.push(("ScalingConfig".to_string(), scaling.to_string()));
1095        }
1096        if let Some(labels) = body.get("labels") {
1097            if let Some(add) = labels.get("addOrUpdateLabels").and_then(|v| v.as_object()) {
1098                let map = ng.labels.as_object_mut();
1099                if let Some(map) = map {
1100                    for (k, v) in add {
1101                        map.insert(k.clone(), v.clone());
1102                    }
1103                }
1104            }
1105            params.push(("LabelsToAdd".to_string(), labels.to_string()));
1106        }
1107        if let Some(update_config) = body.get("updateConfig") {
1108            ng.update_config = build_nodegroup_update_config(Some(update_config));
1109            params.push(("MaxUnavailable".to_string(), update_config.to_string()));
1110        }
1111        ng.modified_at = Utc::now();
1112
1113        let update = new_update("ConfigUpdate", params);
1114        let out = update_json(&update);
1115        ng.updates.insert(update.id.clone(), update);
1116        Ok(AwsResponse::json(
1117            StatusCode::OK,
1118            json!({ "update": out }).to_string(),
1119        ))
1120    }
1121
1122    fn update_nodegroup_version(
1123        &self,
1124        req: &AwsRequest,
1125        cluster_name: &str,
1126        name: &str,
1127    ) -> Result<AwsResponse, AwsServiceError> {
1128        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1129        let mut accounts = self.state.write();
1130        let state = accounts.get_or_create(&req.account_id);
1131        if !state.clusters.contains_key(cluster_name) {
1132            return Err(not_found_cluster(cluster_name)());
1133        }
1134        let ng = state
1135            .nodegroups
1136            .get_mut(cluster_name)
1137            .and_then(|m| m.get_mut(name))
1138            .ok_or_else(not_found_nodegroup(name))?;
1139
1140        let mut params = Vec::new();
1141        if let Some(version) = body.get("version").and_then(|v| v.as_str()) {
1142            ng.version = version.to_string();
1143            params.push(("Version".to_string(), version.to_string()));
1144        }
1145        if let Some(release) = body.get("releaseVersion").and_then(|v| v.as_str()) {
1146            ng.release_version = release.to_string();
1147            params.push(("ReleaseVersion".to_string(), release.to_string()));
1148        }
1149        ng.modified_at = Utc::now();
1150
1151        let update = new_update("VersionUpdate", params);
1152        let out = update_json(&update);
1153        ng.updates.insert(update.id.clone(), update);
1154        Ok(AwsResponse::json(
1155            StatusCode::OK,
1156            json!({ "update": out }).to_string(),
1157        ))
1158    }
1159
1160    // -----------------------------------------------------------------------
1161    // Fargate profiles
1162    // -----------------------------------------------------------------------
1163
1164    fn create_fargate_profile(
1165        &self,
1166        req: &AwsRequest,
1167        cluster_name: &str,
1168    ) -> Result<AwsResponse, AwsServiceError> {
1169        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1170        let name = body
1171            .get("fargateProfileName")
1172            .and_then(|v| v.as_str())
1173            .ok_or_else(|| invalid_parameter("fargateProfileName is required"))?
1174            .to_string();
1175        let pod_execution_role_arn = body
1176            .get("podExecutionRoleArn")
1177            .and_then(|v| v.as_str())
1178            .ok_or_else(|| invalid_parameter("podExecutionRoleArn is required"))?
1179            .to_string();
1180
1181        let region = req.region.clone();
1182        let account_id = req.account_id.clone();
1183
1184        let mut accounts = self.state.write();
1185        let state = accounts.get_or_create(&req.account_id);
1186        if !state.clusters.contains_key(cluster_name) {
1187            return Err(not_found_cluster(cluster_name)());
1188        }
1189        if state
1190            .fargate_profiles
1191            .get(cluster_name)
1192            .is_some_and(|m| m.contains_key(&name))
1193        {
1194            return Err(AwsServiceError::aws_error(
1195                StatusCode::CONFLICT,
1196                "ResourceInUseException",
1197                format!(
1198                    "FargateProfile already exists with name {name} and cluster name {cluster_name}"
1199                ),
1200            ));
1201        }
1202
1203        let id = uuid::Uuid::new_v4().to_string();
1204        let arn = fargate_profile_arn(&region, &account_id, cluster_name, &name, &id);
1205        let profile = FargateProfile {
1206            name: name.clone(),
1207            arn,
1208            cluster_name: cluster_name.to_string(),
1209            pod_execution_role_arn,
1210            status: "CREATING".to_string(),
1211            created_at: Utc::now(),
1212            subnets: body.get("subnets").cloned().unwrap_or_else(|| json!([])),
1213            selectors: body.get("selectors").cloned().unwrap_or_else(|| json!([])),
1214            tags: parse_tag_map(body.get("tags")),
1215        };
1216
1217        let out = fargate_profile_json(&profile);
1218        state
1219            .fargate_profiles
1220            .entry(cluster_name.to_string())
1221            .or_default()
1222            .insert(name, profile);
1223        Ok(AwsResponse::json(
1224            StatusCode::OK,
1225            json!({ "fargateProfile": out }).to_string(),
1226        ))
1227    }
1228
1229    fn describe_fargate_profile(
1230        &self,
1231        req: &AwsRequest,
1232        cluster_name: &str,
1233        name: &str,
1234    ) -> Result<AwsResponse, AwsServiceError> {
1235        let mut accounts = self.state.write();
1236        let state = accounts.get_or_create(&req.account_id);
1237        if !state.clusters.contains_key(cluster_name) {
1238            return Err(not_found_cluster(cluster_name)());
1239        }
1240        let profile = state
1241            .fargate_profiles
1242            .get_mut(cluster_name)
1243            .and_then(|m| m.get_mut(name))
1244            .ok_or_else(not_found_fargate_profile(name))?;
1245        if profile.status == "CREATING" {
1246            profile.status = "ACTIVE".to_string();
1247        }
1248        Ok(AwsResponse::json(
1249            StatusCode::OK,
1250            json!({ "fargateProfile": fargate_profile_json(profile) }).to_string(),
1251        ))
1252    }
1253
1254    fn list_fargate_profiles(
1255        &self,
1256        req: &AwsRequest,
1257        cluster_name: &str,
1258    ) -> Result<AwsResponse, AwsServiceError> {
1259        let max_results = validate_max_results(req)?;
1260        let next_token = req.query_params.get("nextToken").cloned();
1261        let accounts = self.state.read();
1262        let state = accounts
1263            .get(&req.account_id)
1264            .ok_or_else(not_found_cluster(cluster_name))?;
1265        if !state.clusters.contains_key(cluster_name) {
1266            return Err(not_found_cluster(cluster_name)());
1267        }
1268        let names: Vec<String> = state
1269            .fargate_profiles
1270            .get(cluster_name)
1271            .map(|m| m.keys().cloned().collect())
1272            .unwrap_or_default();
1273        let (page, token) = paginate_checked(&names, next_token.as_deref(), max_results)
1274            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
1275        let mut out = json!({ "fargateProfileNames": page });
1276        if let Some(t) = token {
1277            out["nextToken"] = Value::String(t);
1278        }
1279        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
1280    }
1281
1282    fn delete_fargate_profile(
1283        &self,
1284        req: &AwsRequest,
1285        cluster_name: &str,
1286        name: &str,
1287    ) -> Result<AwsResponse, AwsServiceError> {
1288        let mut accounts = self.state.write();
1289        let state = accounts.get_or_create(&req.account_id);
1290        if !state.clusters.contains_key(cluster_name) {
1291            return Err(not_found_cluster(cluster_name)());
1292        }
1293        let mut profile = state
1294            .fargate_profiles
1295            .get_mut(cluster_name)
1296            .and_then(|m| m.remove(name))
1297            .ok_or_else(not_found_fargate_profile(name))?;
1298        profile.status = "DELETING".to_string();
1299        Ok(AwsResponse::json(
1300            StatusCode::OK,
1301            json!({ "fargateProfile": fargate_profile_json(&profile) }).to_string(),
1302        ))
1303    }
1304
1305    // -----------------------------------------------------------------------
1306    // Add-ons
1307    // -----------------------------------------------------------------------
1308
1309    fn create_addon(
1310        &self,
1311        req: &AwsRequest,
1312        cluster_name: &str,
1313    ) -> Result<AwsResponse, AwsServiceError> {
1314        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1315        let name = body
1316            .get("addonName")
1317            .and_then(|v| v.as_str())
1318            .ok_or_else(|| invalid_parameter("addonName is required"))?
1319            .to_string();
1320
1321        let region = req.region.clone();
1322        let account_id = req.account_id.clone();
1323
1324        let mut accounts = self.state.write();
1325        let state = accounts.get_or_create(&req.account_id);
1326        // The parent cluster must exist; AWS returns ResourceNotFoundException.
1327        let cluster_version = state
1328            .clusters
1329            .get(cluster_name)
1330            .ok_or_else(not_found_cluster(cluster_name))?
1331            .version
1332            .clone();
1333        if state
1334            .addons
1335            .get(cluster_name)
1336            .is_some_and(|m| m.contains_key(&name))
1337        {
1338            return Err(AwsServiceError::aws_error(
1339                StatusCode::CONFLICT,
1340                "ResourceInUseException",
1341                format!("Addon already exists with name {name} and cluster name {cluster_name}"),
1342            ));
1343        }
1344
1345        let id = uuid::Uuid::new_v4().to_string();
1346        let arn = addon_arn(&region, &account_id, cluster_name, &name, &id);
1347        let now = Utc::now();
1348        let addon_version = body
1349            .get("addonVersion")
1350            .and_then(|v| v.as_str())
1351            .map(|s| s.to_string())
1352            .unwrap_or_else(|| default_addon_version(&name, &cluster_version));
1353        let namespace = body
1354            .get("namespaceConfig")
1355            .and_then(|v| v.get("namespace"))
1356            .and_then(|v| v.as_str())
1357            .map(|s| s.to_string());
1358        let pod_identity_associations = build_pod_identity_association_arns(
1359            &region,
1360            &account_id,
1361            cluster_name,
1362            body.get("podIdentityAssociations"),
1363        );
1364
1365        let addon = Addon {
1366            name: name.clone(),
1367            arn,
1368            cluster_name: cluster_name.to_string(),
1369            addon_version,
1370            status: "CREATING".to_string(),
1371            created_at: now,
1372            modified_at: now,
1373            service_account_role_arn: body
1374                .get("serviceAccountRoleArn")
1375                .and_then(|v| v.as_str())
1376                .map(|s| s.to_string()),
1377            configuration_values: body
1378                .get("configurationValues")
1379                .and_then(|v| v.as_str())
1380                .map(|s| s.to_string()),
1381            namespace,
1382            pod_identity_associations,
1383            tags: parse_tag_map(body.get("tags")),
1384            updates: Default::default(),
1385        };
1386
1387        let out = addon_json(&addon);
1388        state
1389            .addons
1390            .entry(cluster_name.to_string())
1391            .or_default()
1392            .insert(name, addon);
1393        Ok(AwsResponse::json(
1394            StatusCode::OK,
1395            json!({ "addon": out }).to_string(),
1396        ))
1397    }
1398
1399    fn describe_addon(
1400        &self,
1401        req: &AwsRequest,
1402        cluster_name: &str,
1403        name: &str,
1404    ) -> Result<AwsResponse, AwsServiceError> {
1405        let mut accounts = self.state.write();
1406        let state = accounts.get_or_create(&req.account_id);
1407        if !state.clusters.contains_key(cluster_name) {
1408            return Err(not_found_cluster(cluster_name)());
1409        }
1410        let addon = state
1411            .addons
1412            .get_mut(cluster_name)
1413            .and_then(|m| m.get_mut(name))
1414            .ok_or_else(not_found_addon(name))?;
1415        // Settle CREATING -> ACTIVE on first describe.
1416        if addon.status == "CREATING" {
1417            addon.status = "ACTIVE".to_string();
1418        }
1419        Ok(AwsResponse::json(
1420            StatusCode::OK,
1421            json!({ "addon": addon_json(addon) }).to_string(),
1422        ))
1423    }
1424
1425    fn list_addons(
1426        &self,
1427        req: &AwsRequest,
1428        cluster_name: &str,
1429    ) -> Result<AwsResponse, AwsServiceError> {
1430        let max_results = validate_max_results(req)?;
1431        let next_token = req.query_params.get("nextToken").cloned();
1432        let accounts = self.state.read();
1433        let state = accounts
1434            .get(&req.account_id)
1435            .ok_or_else(not_found_cluster(cluster_name))?;
1436        if !state.clusters.contains_key(cluster_name) {
1437            return Err(not_found_cluster(cluster_name)());
1438        }
1439        let names: Vec<String> = state
1440            .addons
1441            .get(cluster_name)
1442            .map(|m| m.keys().cloned().collect())
1443            .unwrap_or_default();
1444        let (page, token) = paginate_checked(&names, next_token.as_deref(), max_results)
1445            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
1446        let mut out = json!({ "addons": page });
1447        if let Some(t) = token {
1448            out["nextToken"] = Value::String(t);
1449        }
1450        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
1451    }
1452
1453    fn delete_addon(
1454        &self,
1455        req: &AwsRequest,
1456        cluster_name: &str,
1457        name: &str,
1458    ) -> Result<AwsResponse, AwsServiceError> {
1459        let mut accounts = self.state.write();
1460        let state = accounts.get_or_create(&req.account_id);
1461        if !state.clusters.contains_key(cluster_name) {
1462            return Err(not_found_cluster(cluster_name)());
1463        }
1464        let mut addon = state
1465            .addons
1466            .get_mut(cluster_name)
1467            .and_then(|m| m.remove(name))
1468            .ok_or_else(not_found_addon(name))?;
1469        addon.status = "DELETING".to_string();
1470        Ok(AwsResponse::json(
1471            StatusCode::OK,
1472            json!({ "addon": addon_json(&addon) }).to_string(),
1473        ))
1474    }
1475
1476    fn update_addon(
1477        &self,
1478        req: &AwsRequest,
1479        cluster_name: &str,
1480        name: &str,
1481    ) -> Result<AwsResponse, AwsServiceError> {
1482        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1483        let region = req.region.clone();
1484        let account_id = req.account_id.clone();
1485        let mut accounts = self.state.write();
1486        let state = accounts.get_or_create(&req.account_id);
1487        if !state.clusters.contains_key(cluster_name) {
1488            return Err(not_found_cluster(cluster_name)());
1489        }
1490        let addon = state
1491            .addons
1492            .get_mut(cluster_name)
1493            .and_then(|m| m.get_mut(name))
1494            .ok_or_else(not_found_addon(name))?;
1495
1496        let mut params = Vec::new();
1497        if let Some(version) = body.get("addonVersion").and_then(|v| v.as_str()) {
1498            addon.addon_version = version.to_string();
1499            params.push(("AddonVersion".to_string(), version.to_string()));
1500        }
1501        if let Some(role) = body.get("serviceAccountRoleArn").and_then(|v| v.as_str()) {
1502            addon.service_account_role_arn = Some(role.to_string());
1503            params.push(("ServiceAccountRoleArn".to_string(), role.to_string()));
1504        }
1505        if let Some(cfg) = body.get("configurationValues").and_then(|v| v.as_str()) {
1506            addon.configuration_values = Some(cfg.to_string());
1507            params.push(("ConfigurationValues".to_string(), cfg.to_string()));
1508        }
1509        if let Some(resolve) = body.get("resolveConflicts").and_then(|v| v.as_str()) {
1510            params.push(("ResolveConflicts".to_string(), resolve.to_string()));
1511        }
1512        if let Some(assocs) = body.get("podIdentityAssociations") {
1513            addon.pod_identity_associations = build_pod_identity_association_arns(
1514                &region,
1515                &account_id,
1516                cluster_name,
1517                Some(assocs),
1518            );
1519        }
1520        addon.modified_at = Utc::now();
1521
1522        let update = new_update("AddonUpdate", params);
1523        let out = update_json(&update);
1524        addon.updates.insert(update.id.clone(), update);
1525        Ok(AwsResponse::json(
1526            StatusCode::OK,
1527            json!({ "update": out }).to_string(),
1528        ))
1529    }
1530
1531    fn describe_addon_versions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1532        let max_results = validate_max_results(req)?;
1533        let next_token = req.query_params.get("nextToken").cloned();
1534        let addon_filter = req.query_params.get("addonName").cloned();
1535        let k8s_version = req
1536            .query_params
1537            .get("kubernetesVersion")
1538            .cloned()
1539            .unwrap_or_else(|| DEFAULT_K8S_VERSION.to_string());
1540
1541        let catalog = addon_catalog(&k8s_version);
1542        let filtered: Vec<Value> = catalog
1543            .into_iter()
1544            .filter(|a| addon_filter.as_deref().is_none_or(|f| a["addonName"] == f))
1545            .collect();
1546
1547        let (page, token) = paginate_checked(&filtered, next_token.as_deref(), max_results)
1548            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
1549        let mut out = json!({ "addons": page });
1550        if let Some(t) = token {
1551            out["nextToken"] = Value::String(t);
1552        }
1553        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
1554    }
1555
1556    fn describe_addon_configuration(
1557        &self,
1558        req: &AwsRequest,
1559    ) -> Result<AwsResponse, AwsServiceError> {
1560        let addon_name = req
1561            .query_params
1562            .get("addonName")
1563            .cloned()
1564            .ok_or_else(|| invalid_parameter("addonName is required"))?;
1565        let addon_version = req
1566            .query_params
1567            .get("addonVersion")
1568            .cloned()
1569            .ok_or_else(|| invalid_parameter("addonVersion is required"))?;
1570
1571        Ok(AwsResponse::json(
1572            StatusCode::OK,
1573            json!({
1574                "addonName": addon_name,
1575                "addonVersion": addon_version,
1576                "configurationSchema": addon_configuration_schema(&addon_name),
1577                "podIdentityConfiguration": pod_identity_configuration(&addon_name),
1578            })
1579            .to_string(),
1580        ))
1581    }
1582
1583    // -----------------------------------------------------------------------
1584    // Access entries / access policies
1585    // -----------------------------------------------------------------------
1586
1587    fn create_access_entry(
1588        &self,
1589        req: &AwsRequest,
1590        cluster_name: &str,
1591    ) -> Result<AwsResponse, AwsServiceError> {
1592        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1593        let principal_arn = body
1594            .get("principalArn")
1595            .and_then(|v| v.as_str())
1596            .ok_or_else(|| invalid_parameter("principalArn is required"))?
1597            .to_string();
1598
1599        let region = req.region.clone();
1600        let account_id = req.account_id.clone();
1601
1602        let mut accounts = self.state.write();
1603        let state = accounts.get_or_create(&req.account_id);
1604        // The parent cluster must exist; AWS returns ResourceNotFoundException.
1605        if !state.clusters.contains_key(cluster_name) {
1606            return Err(not_found_cluster(cluster_name)());
1607        }
1608        if state
1609            .access_entries
1610            .get(cluster_name)
1611            .is_some_and(|m| m.contains_key(&principal_arn))
1612        {
1613            return Err(AwsServiceError::aws_error(
1614                StatusCode::CONFLICT,
1615                "ResourceInUseException",
1616                format!(
1617                    "The specified access entry resource is already in use on this cluster: {principal_arn}"
1618                ),
1619            ));
1620        }
1621
1622        let (principal_type, principal_name) = principal_parts(&principal_arn);
1623        let id = uuid::Uuid::new_v4().to_string();
1624        let arn = access_entry_arn(
1625            &region,
1626            &account_id,
1627            cluster_name,
1628            &principal_type,
1629            &principal_name,
1630            &id,
1631        );
1632        let now = Utc::now();
1633        let username = body
1634            .get("username")
1635            .and_then(|v| v.as_str())
1636            .map(|s| s.to_string())
1637            .unwrap_or_else(|| default_username(&principal_arn));
1638        let entry_type = body
1639            .get("type")
1640            .and_then(|v| v.as_str())
1641            .unwrap_or("STANDARD")
1642            .to_string();
1643        let kubernetes_groups = string_list(body.get("kubernetesGroups"));
1644
1645        let entry = AccessEntry {
1646            principal_arn: principal_arn.clone(),
1647            cluster_name: cluster_name.to_string(),
1648            arn,
1649            kubernetes_groups,
1650            username,
1651            type_: entry_type,
1652            created_at: now,
1653            modified_at: now,
1654            tags: parse_tag_map(body.get("tags")),
1655            associated_policies: Vec::new(),
1656        };
1657
1658        let out = access_entry_json(&entry);
1659        state
1660            .access_entries
1661            .entry(cluster_name.to_string())
1662            .or_default()
1663            .insert(principal_arn, entry);
1664        Ok(AwsResponse::json(
1665            StatusCode::OK,
1666            json!({ "accessEntry": out }).to_string(),
1667        ))
1668    }
1669
1670    fn describe_access_entry(
1671        &self,
1672        req: &AwsRequest,
1673        cluster_name: &str,
1674        principal_arn: &str,
1675    ) -> Result<AwsResponse, AwsServiceError> {
1676        let accounts = self.state.read();
1677        let state = accounts
1678            .get(&req.account_id)
1679            .ok_or_else(not_found_cluster(cluster_name))?;
1680        if !state.clusters.contains_key(cluster_name) {
1681            return Err(not_found_cluster(cluster_name)());
1682        }
1683        let entry = state
1684            .access_entries
1685            .get(cluster_name)
1686            .and_then(|m| m.get(principal_arn))
1687            .ok_or_else(not_found_access_entry(principal_arn))?;
1688        Ok(AwsResponse::json(
1689            StatusCode::OK,
1690            json!({ "accessEntry": access_entry_json(entry) }).to_string(),
1691        ))
1692    }
1693
1694    fn list_access_entries(
1695        &self,
1696        req: &AwsRequest,
1697        cluster_name: &str,
1698    ) -> Result<AwsResponse, AwsServiceError> {
1699        let max_results = validate_max_results(req)?;
1700        let next_token = req.query_params.get("nextToken").cloned();
1701        let associated_policy = req.query_params.get("associatedPolicyArn").cloned();
1702        let accounts = self.state.read();
1703        let state = accounts
1704            .get(&req.account_id)
1705            .ok_or_else(not_found_cluster(cluster_name))?;
1706        if !state.clusters.contains_key(cluster_name) {
1707            return Err(not_found_cluster(cluster_name)());
1708        }
1709        // Optional `associatedPolicyArn` filter narrows the result to entries
1710        // that have that policy associated, mirroring the real API.
1711        let names: Vec<String> = state
1712            .access_entries
1713            .get(cluster_name)
1714            .map(|m| {
1715                m.values()
1716                    .filter(|e| {
1717                        associated_policy.as_deref().is_none_or(|p| {
1718                            e.associated_policies.iter().any(|ap| ap.policy_arn == p)
1719                        })
1720                    })
1721                    .map(|e| e.principal_arn.clone())
1722                    .collect()
1723            })
1724            .unwrap_or_default();
1725        let (page, token) = paginate_checked(&names, next_token.as_deref(), max_results)
1726            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
1727        let mut out = json!({ "accessEntries": page });
1728        if let Some(t) = token {
1729            out["nextToken"] = Value::String(t);
1730        }
1731        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
1732    }
1733
1734    fn delete_access_entry(
1735        &self,
1736        req: &AwsRequest,
1737        cluster_name: &str,
1738        principal_arn: &str,
1739    ) -> Result<AwsResponse, AwsServiceError> {
1740        let mut accounts = self.state.write();
1741        let state = accounts.get_or_create(&req.account_id);
1742        if !state.clusters.contains_key(cluster_name) {
1743            return Err(not_found_cluster(cluster_name)());
1744        }
1745        state
1746            .access_entries
1747            .get_mut(cluster_name)
1748            .and_then(|m| m.remove(principal_arn))
1749            .ok_or_else(not_found_access_entry(principal_arn))?;
1750        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1751    }
1752
1753    fn update_access_entry(
1754        &self,
1755        req: &AwsRequest,
1756        cluster_name: &str,
1757        principal_arn: &str,
1758    ) -> Result<AwsResponse, AwsServiceError> {
1759        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1760        let mut accounts = self.state.write();
1761        let state = accounts.get_or_create(&req.account_id);
1762        if !state.clusters.contains_key(cluster_name) {
1763            return Err(not_found_cluster(cluster_name)());
1764        }
1765        let entry = state
1766            .access_entries
1767            .get_mut(cluster_name)
1768            .and_then(|m| m.get_mut(principal_arn))
1769            .ok_or_else(not_found_access_entry(principal_arn))?;
1770
1771        if let Some(groups) = body.get("kubernetesGroups") {
1772            entry.kubernetes_groups = string_list(Some(groups));
1773        }
1774        if let Some(username) = body.get("username").and_then(|v| v.as_str()) {
1775            entry.username = username.to_string();
1776        }
1777        entry.modified_at = Utc::now();
1778        Ok(AwsResponse::json(
1779            StatusCode::OK,
1780            json!({ "accessEntry": access_entry_json(entry) }).to_string(),
1781        ))
1782    }
1783
1784    fn associate_access_policy(
1785        &self,
1786        req: &AwsRequest,
1787        cluster_name: &str,
1788        principal_arn: &str,
1789    ) -> Result<AwsResponse, AwsServiceError> {
1790        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1791        let policy_arn = body
1792            .get("policyArn")
1793            .and_then(|v| v.as_str())
1794            .ok_or_else(|| invalid_parameter("policyArn is required"))?
1795            .to_string();
1796        let access_scope = build_access_scope(body.get("accessScope"));
1797
1798        let mut accounts = self.state.write();
1799        let state = accounts.get_or_create(&req.account_id);
1800        if !state.clusters.contains_key(cluster_name) {
1801            return Err(not_found_cluster(cluster_name)());
1802        }
1803        let entry = state
1804            .access_entries
1805            .get_mut(cluster_name)
1806            .and_then(|m| m.get_mut(principal_arn))
1807            .ok_or_else(not_found_access_entry(principal_arn))?;
1808
1809        let now = Utc::now();
1810        // Re-associating the same policy updates its scope in place, matching
1811        // the real API's idempotent upsert semantics.
1812        let associated = if let Some(existing) = entry
1813            .associated_policies
1814            .iter_mut()
1815            .find(|ap| ap.policy_arn == policy_arn)
1816        {
1817            existing.access_scope = access_scope;
1818            existing.modified_at = now;
1819            existing.clone()
1820        } else {
1821            let ap = AssociatedPolicy {
1822                policy_arn: policy_arn.clone(),
1823                access_scope,
1824                associated_at: now,
1825                modified_at: now,
1826            };
1827            entry.associated_policies.push(ap.clone());
1828            ap
1829        };
1830        Ok(AwsResponse::json(
1831            StatusCode::OK,
1832            json!({
1833                "clusterName": cluster_name,
1834                "principalArn": entry.principal_arn,
1835                "associatedAccessPolicy": associated_policy_json(&associated),
1836            })
1837            .to_string(),
1838        ))
1839    }
1840
1841    fn disassociate_access_policy(
1842        &self,
1843        req: &AwsRequest,
1844        cluster_name: &str,
1845        principal_arn: &str,
1846        policy_arn: &str,
1847    ) -> Result<AwsResponse, AwsServiceError> {
1848        let mut accounts = self.state.write();
1849        let state = accounts.get_or_create(&req.account_id);
1850        if !state.clusters.contains_key(cluster_name) {
1851            return Err(not_found_cluster(cluster_name)());
1852        }
1853        let entry = state
1854            .access_entries
1855            .get_mut(cluster_name)
1856            .and_then(|m| m.get_mut(principal_arn))
1857            .ok_or_else(not_found_access_entry(principal_arn))?;
1858        entry
1859            .associated_policies
1860            .retain(|ap| ap.policy_arn != policy_arn);
1861        Ok(AwsResponse::json(StatusCode::OK, "{}"))
1862    }
1863
1864    fn list_associated_access_policies(
1865        &self,
1866        req: &AwsRequest,
1867        cluster_name: &str,
1868        principal_arn: &str,
1869    ) -> Result<AwsResponse, AwsServiceError> {
1870        let max_results = validate_max_results(req)?;
1871        let next_token = req.query_params.get("nextToken").cloned();
1872        let accounts = self.state.read();
1873        let state = accounts
1874            .get(&req.account_id)
1875            .ok_or_else(not_found_cluster(cluster_name))?;
1876        if !state.clusters.contains_key(cluster_name) {
1877            return Err(not_found_cluster(cluster_name)());
1878        }
1879        let entry = state
1880            .access_entries
1881            .get(cluster_name)
1882            .and_then(|m| m.get(principal_arn))
1883            .ok_or_else(not_found_access_entry(principal_arn))?;
1884        let policies: Vec<Value> = entry
1885            .associated_policies
1886            .iter()
1887            .map(associated_policy_json)
1888            .collect();
1889        let (page, token) = paginate_checked(&policies, next_token.as_deref(), max_results)
1890            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
1891        let mut out = json!({
1892            "clusterName": cluster_name,
1893            "principalArn": entry.principal_arn,
1894            "associatedAccessPolicies": page,
1895        });
1896        if let Some(t) = token {
1897            out["nextToken"] = Value::String(t);
1898        }
1899        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
1900    }
1901
1902    fn list_access_policies(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1903        // ListAccessPolicies is a read-only catalogue. Its `maxResults` carries
1904        // an `@range` (1-100) constraint the probe exercises, so an out-of-range
1905        // value is rejected. The nextToken, however, is tolerated (an
1906        // unparseable one restarts from the beginning) since the op's Smithy
1907        // contract declares no client error for it.
1908        let max_results = validate_max_results(req)?;
1909        let next_token = req.query_params.get("nextToken").cloned();
1910        let catalog = access_policy_catalog();
1911        let (page, token) = paginate_checked(&catalog, next_token.as_deref(), max_results)
1912            .unwrap_or_else(|_| (catalog.clone(), None));
1913        let mut out = json!({ "accessPolicies": page });
1914        if let Some(t) = token {
1915            out["nextToken"] = Value::String(t);
1916        }
1917        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
1918    }
1919
1920    // -----------------------------------------------------------------------
1921    // Identity-provider configs
1922    // -----------------------------------------------------------------------
1923
1924    fn associate_identity_provider_config(
1925        &self,
1926        req: &AwsRequest,
1927        cluster_name: &str,
1928    ) -> Result<AwsResponse, AwsServiceError> {
1929        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1930        let oidc = body
1931            .get("oidc")
1932            .filter(|v| v.is_object())
1933            .ok_or_else(|| invalid_parameter("oidc is required"))?;
1934        let name = oidc
1935            .get("identityProviderConfigName")
1936            .and_then(|v| v.as_str())
1937            .ok_or_else(|| invalid_parameter("oidc.identityProviderConfigName is required"))?
1938            .to_string();
1939        let issuer_url = oidc
1940            .get("issuerUrl")
1941            .and_then(|v| v.as_str())
1942            .ok_or_else(|| invalid_parameter("oidc.issuerUrl is required"))?
1943            .to_string();
1944        let client_id = oidc
1945            .get("clientId")
1946            .and_then(|v| v.as_str())
1947            .ok_or_else(|| invalid_parameter("oidc.clientId is required"))?
1948            .to_string();
1949
1950        let region = req.region.clone();
1951        let account_id = req.account_id.clone();
1952
1953        let mut accounts = self.state.write();
1954        let state = accounts.get_or_create(&req.account_id);
1955        // The parent cluster must exist; AWS returns ResourceNotFoundException.
1956        if !state.clusters.contains_key(cluster_name) {
1957            return Err(not_found_cluster(cluster_name)());
1958        }
1959        if state
1960            .identity_provider_configs
1961            .get(cluster_name)
1962            .is_some_and(|m| m.contains_key(&name))
1963        {
1964            return Err(AwsServiceError::aws_error(
1965                StatusCode::CONFLICT,
1966                "ResourceInUseException",
1967                format!(
1968                    "Identity provider config already exists with name {name} and cluster name {cluster_name}"
1969                ),
1970            ));
1971        }
1972
1973        let id = uuid::Uuid::new_v4().to_string();
1974        let arn = identity_provider_config_arn(&region, &account_id, cluster_name, &name, &id);
1975        let config = IdentityProviderConfig {
1976            name: name.clone(),
1977            arn,
1978            cluster_name: cluster_name.to_string(),
1979            issuer_url,
1980            client_id,
1981            username_claim: str_field(oidc, "usernameClaim"),
1982            username_prefix: str_field(oidc, "usernamePrefix"),
1983            groups_claim: str_field(oidc, "groupsClaim"),
1984            groups_prefix: str_field(oidc, "groupsPrefix"),
1985            required_claims: oidc
1986                .get("requiredClaims")
1987                .cloned()
1988                .unwrap_or_else(|| json!({})),
1989            status: "CREATING".to_string(),
1990            tags: parse_tag_map(body.get("tags")),
1991        };
1992        state
1993            .identity_provider_configs
1994            .entry(cluster_name.to_string())
1995            .or_default()
1996            .insert(name, config.clone());
1997
1998        // Associating an IdP config mints a tracked cluster-scoped Update, read
1999        // back through DescribeUpdate/ListUpdates like other config changes.
2000        let cluster = state.clusters.get_mut(cluster_name).unwrap();
2001        let update = new_update(
2002            "AssociateIdentityProviderConfig",
2003            vec![("IdentityProviderConfig".to_string(), oidc.to_string())],
2004        );
2005        let update_out = update_json(&update);
2006        cluster.updates.insert(update.id.clone(), update);
2007        Ok(AwsResponse::json(
2008            StatusCode::OK,
2009            json!({ "update": update_out, "tags": config.tags }).to_string(),
2010        ))
2011    }
2012
2013    fn disassociate_identity_provider_config(
2014        &self,
2015        req: &AwsRequest,
2016        cluster_name: &str,
2017    ) -> Result<AwsResponse, AwsServiceError> {
2018        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2019        let name = body
2020            .get("identityProviderConfig")
2021            .and_then(|v| v.get("name"))
2022            .and_then(|v| v.as_str())
2023            .ok_or_else(|| invalid_parameter("identityProviderConfig.name is required"))?
2024            .to_string();
2025
2026        let mut accounts = self.state.write();
2027        let state = accounts.get_or_create(&req.account_id);
2028        if !state.clusters.contains_key(cluster_name) {
2029            return Err(not_found_cluster(cluster_name)());
2030        }
2031        state
2032            .identity_provider_configs
2033            .get_mut(cluster_name)
2034            .and_then(|m| m.remove(&name))
2035            .ok_or_else(not_found_identity_provider_config(&name))?;
2036
2037        let cluster = state.clusters.get_mut(cluster_name).unwrap();
2038        let update = new_update(
2039            "DisassociateIdentityProviderConfig",
2040            vec![("IdentityProviderConfig".to_string(), name)],
2041        );
2042        let update_out = update_json(&update);
2043        cluster.updates.insert(update.id.clone(), update);
2044        Ok(AwsResponse::json(
2045            StatusCode::OK,
2046            json!({ "update": update_out }).to_string(),
2047        ))
2048    }
2049
2050    fn describe_identity_provider_config(
2051        &self,
2052        req: &AwsRequest,
2053        cluster_name: &str,
2054    ) -> Result<AwsResponse, AwsServiceError> {
2055        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2056        let name = body
2057            .get("identityProviderConfig")
2058            .and_then(|v| v.get("name"))
2059            .and_then(|v| v.as_str())
2060            .ok_or_else(|| invalid_parameter("identityProviderConfig.name is required"))?
2061            .to_string();
2062
2063        let mut accounts = self.state.write();
2064        let state = accounts.get_or_create(&req.account_id);
2065        if !state.clusters.contains_key(cluster_name) {
2066            return Err(not_found_cluster(cluster_name)());
2067        }
2068        let config = state
2069            .identity_provider_configs
2070            .get_mut(cluster_name)
2071            .and_then(|m| m.get_mut(&name))
2072            .ok_or_else(not_found_identity_provider_config(&name))?;
2073        // Settle CREATING -> ACTIVE on first describe.
2074        if config.status == "CREATING" {
2075            config.status = "ACTIVE".to_string();
2076        }
2077        Ok(AwsResponse::json(
2078            StatusCode::OK,
2079            json!({ "identityProviderConfig": identity_provider_config_json(config) }).to_string(),
2080        ))
2081    }
2082
2083    fn list_identity_provider_configs(
2084        &self,
2085        req: &AwsRequest,
2086        cluster_name: &str,
2087    ) -> Result<AwsResponse, AwsServiceError> {
2088        let max_results = validate_max_results(req)?;
2089        let next_token = req.query_params.get("nextToken").cloned();
2090        let accounts = self.state.read();
2091        let state = accounts
2092            .get(&req.account_id)
2093            .ok_or_else(not_found_cluster(cluster_name))?;
2094        if !state.clusters.contains_key(cluster_name) {
2095            return Err(not_found_cluster(cluster_name)());
2096        }
2097        let configs: Vec<Value> = state
2098            .identity_provider_configs
2099            .get(cluster_name)
2100            .map(|m| {
2101                m.keys()
2102                    .map(|n| json!({ "type": "oidc", "name": n }))
2103                    .collect()
2104            })
2105            .unwrap_or_default();
2106        let (page, token) = paginate_checked(&configs, next_token.as_deref(), max_results)
2107            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
2108        let mut out = json!({ "identityProviderConfigs": page });
2109        if let Some(t) = token {
2110            out["nextToken"] = Value::String(t);
2111        }
2112        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
2113    }
2114
2115    // -----------------------------------------------------------------------
2116    // Pod identity associations
2117    // -----------------------------------------------------------------------
2118
2119    fn create_pod_identity_association(
2120        &self,
2121        req: &AwsRequest,
2122        cluster_name: &str,
2123    ) -> Result<AwsResponse, AwsServiceError> {
2124        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2125        let namespace = body
2126            .get("namespace")
2127            .and_then(|v| v.as_str())
2128            .ok_or_else(|| invalid_parameter("namespace is required"))?
2129            .to_string();
2130        let service_account = body
2131            .get("serviceAccount")
2132            .and_then(|v| v.as_str())
2133            .ok_or_else(|| invalid_parameter("serviceAccount is required"))?
2134            .to_string();
2135        let role_arn = body
2136            .get("roleArn")
2137            .and_then(|v| v.as_str())
2138            .ok_or_else(|| invalid_parameter("roleArn is required"))?
2139            .to_string();
2140
2141        let region = req.region.clone();
2142        let account_id = req.account_id.clone();
2143
2144        let mut accounts = self.state.write();
2145        let state = accounts.get_or_create(&req.account_id);
2146        // The parent cluster must exist; AWS returns ResourceNotFoundException.
2147        if !state.clusters.contains_key(cluster_name) {
2148            return Err(not_found_cluster(cluster_name)());
2149        }
2150        // A given (namespace, serviceAccount) can only be associated once per
2151        // cluster; AWS returns ResourceInUseException for a duplicate.
2152        if state
2153            .pod_identity_associations
2154            .get(cluster_name)
2155            .is_some_and(|m| {
2156                m.values()
2157                    .any(|a| a.namespace == namespace && a.service_account == service_account)
2158            })
2159        {
2160            return Err(AwsServiceError::aws_error(
2161                StatusCode::CONFLICT,
2162                "ResourceInUseException",
2163                format!(
2164                    "Association already exists for namespace {namespace} and service account {service_account}"
2165                ),
2166            ));
2167        }
2168
2169        let suffix = uuid::Uuid::new_v4().to_string().replace('-', "");
2170        let suffix = &suffix[..17.min(suffix.len())];
2171        let association_id = format!("a-{suffix}");
2172        let association_arn =
2173            pod_identity_association_arn(&region, &account_id, cluster_name, suffix);
2174        let target_role_arn = str_field(&body, "targetRoleArn");
2175        // AWS returns an externalId (for the target role's trust policy) only
2176        // when a cross-account targetRoleArn is supplied.
2177        let external_id = target_role_arn
2178            .as_ref()
2179            .map(|_| uuid::Uuid::new_v4().to_string().replace('-', ""));
2180        let now = Utc::now();
2181        let assoc = PodIdentityAssociation {
2182            cluster_name: cluster_name.to_string(),
2183            namespace,
2184            service_account,
2185            role_arn,
2186            association_arn,
2187            association_id: association_id.clone(),
2188            created_at: now,
2189            modified_at: now,
2190            disable_session_tags: body
2191                .get("disableSessionTags")
2192                .and_then(|v| v.as_bool())
2193                .unwrap_or(false),
2194            target_role_arn,
2195            external_id,
2196            tags: parse_tag_map(body.get("tags")),
2197        };
2198        let out = pod_identity_association_json(&assoc);
2199        state
2200            .pod_identity_associations
2201            .entry(cluster_name.to_string())
2202            .or_default()
2203            .insert(association_id, assoc);
2204        Ok(AwsResponse::json(
2205            StatusCode::OK,
2206            json!({ "association": out }).to_string(),
2207        ))
2208    }
2209
2210    fn describe_pod_identity_association(
2211        &self,
2212        req: &AwsRequest,
2213        cluster_name: &str,
2214        association_id: &str,
2215    ) -> Result<AwsResponse, AwsServiceError> {
2216        let accounts = self.state.read();
2217        let state = accounts
2218            .get(&req.account_id)
2219            .ok_or_else(not_found_cluster(cluster_name))?;
2220        if !state.clusters.contains_key(cluster_name) {
2221            return Err(not_found_cluster(cluster_name)());
2222        }
2223        let assoc = state
2224            .pod_identity_associations
2225            .get(cluster_name)
2226            .and_then(|m| m.get(association_id))
2227            .ok_or_else(not_found_pod_identity_association(association_id))?;
2228        Ok(AwsResponse::json(
2229            StatusCode::OK,
2230            json!({ "association": pod_identity_association_json(assoc) }).to_string(),
2231        ))
2232    }
2233
2234    fn list_pod_identity_associations(
2235        &self,
2236        req: &AwsRequest,
2237        cluster_name: &str,
2238    ) -> Result<AwsResponse, AwsServiceError> {
2239        let max_results = validate_max_results(req)?;
2240        let next_token = req.query_params.get("nextToken").cloned();
2241        let namespace = req.query_params.get("namespace").cloned();
2242        let service_account = req.query_params.get("serviceAccount").cloned();
2243        let accounts = self.state.read();
2244        let state = accounts
2245            .get(&req.account_id)
2246            .ok_or_else(not_found_cluster(cluster_name))?;
2247        if !state.clusters.contains_key(cluster_name) {
2248            return Err(not_found_cluster(cluster_name)());
2249        }
2250        let summaries: Vec<Value> = state
2251            .pod_identity_associations
2252            .get(cluster_name)
2253            .map(|m| {
2254                m.values()
2255                    .filter(|a| namespace.as_deref().is_none_or(|n| a.namespace == n))
2256                    .filter(|a| {
2257                        service_account
2258                            .as_deref()
2259                            .is_none_or(|s| a.service_account == s)
2260                    })
2261                    .map(pod_identity_association_summary_json)
2262                    .collect()
2263            })
2264            .unwrap_or_default();
2265        let (page, token) = paginate_checked(&summaries, next_token.as_deref(), max_results)
2266            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
2267        let mut out = json!({ "associations": page });
2268        if let Some(t) = token {
2269            out["nextToken"] = Value::String(t);
2270        }
2271        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
2272    }
2273
2274    fn delete_pod_identity_association(
2275        &self,
2276        req: &AwsRequest,
2277        cluster_name: &str,
2278        association_id: &str,
2279    ) -> Result<AwsResponse, AwsServiceError> {
2280        let mut accounts = self.state.write();
2281        let state = accounts.get_or_create(&req.account_id);
2282        if !state.clusters.contains_key(cluster_name) {
2283            return Err(not_found_cluster(cluster_name)());
2284        }
2285        let assoc = state
2286            .pod_identity_associations
2287            .get_mut(cluster_name)
2288            .and_then(|m| m.remove(association_id))
2289            .ok_or_else(not_found_pod_identity_association(association_id))?;
2290        Ok(AwsResponse::json(
2291            StatusCode::OK,
2292            json!({ "association": pod_identity_association_json(&assoc) }).to_string(),
2293        ))
2294    }
2295
2296    fn update_pod_identity_association(
2297        &self,
2298        req: &AwsRequest,
2299        cluster_name: &str,
2300        association_id: &str,
2301    ) -> Result<AwsResponse, AwsServiceError> {
2302        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2303        let mut accounts = self.state.write();
2304        let state = accounts.get_or_create(&req.account_id);
2305        if !state.clusters.contains_key(cluster_name) {
2306            return Err(not_found_cluster(cluster_name)());
2307        }
2308        let assoc = state
2309            .pod_identity_associations
2310            .get_mut(cluster_name)
2311            .and_then(|m| m.get_mut(association_id))
2312            .ok_or_else(not_found_pod_identity_association(association_id))?;
2313
2314        if let Some(role) = body.get("roleArn").and_then(|v| v.as_str()) {
2315            assoc.role_arn = role.to_string();
2316        }
2317        if let Some(target) = body.get("targetRoleArn").and_then(|v| v.as_str()) {
2318            assoc.target_role_arn = Some(target.to_string());
2319            // Mint an externalId for the target trust policy if we don't have one.
2320            if assoc.external_id.is_none() {
2321                assoc.external_id = Some(uuid::Uuid::new_v4().to_string().replace('-', ""));
2322            }
2323        }
2324        if let Some(disable) = body.get("disableSessionTags").and_then(|v| v.as_bool()) {
2325            assoc.disable_session_tags = disable;
2326        }
2327        assoc.modified_at = Utc::now();
2328        Ok(AwsResponse::json(
2329            StatusCode::OK,
2330            json!({ "association": pod_identity_association_json(assoc) }).to_string(),
2331        ))
2332    }
2333
2334    // -----------------------------------------------------------------------
2335    // Insights
2336    // -----------------------------------------------------------------------
2337
2338    fn list_insights(
2339        &self,
2340        req: &AwsRequest,
2341        cluster_name: &str,
2342    ) -> Result<AwsResponse, AwsServiceError> {
2343        // ListInsights is a POST carrying the filter/pagination in its body.
2344        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2345        let max_results = body
2346            .get("maxResults")
2347            .and_then(|v| v.as_u64())
2348            .map(|n| (n as usize).max(1))
2349            .unwrap_or(100);
2350        let next_token = body
2351            .get("nextToken")
2352            .and_then(|v| v.as_str())
2353            .map(|s| s.to_string());
2354        // Optional category / status filters.
2355        let categories = string_list(body.get("filter").and_then(|f| f.get("categories")));
2356        let statuses = string_list(body.get("filter").and_then(|f| f.get("statuses")));
2357
2358        let mut accounts = self.state.write();
2359        let state = accounts.get_or_create(&req.account_id);
2360        let version = state
2361            .clusters
2362            .get(cluster_name)
2363            .ok_or_else(not_found_cluster(cluster_name))?
2364            .version
2365            .clone();
2366        let insights = state
2367            .insights
2368            .entry(cluster_name.to_string())
2369            .or_insert_with(|| {
2370                default_insights(&version)
2371                    .into_iter()
2372                    .map(|i| (i.id.clone(), i))
2373                    .collect()
2374            });
2375        let summaries: Vec<Value> = insights
2376            .values()
2377            .filter(|i| categories.is_empty() || categories.iter().any(|c| c == &i.category))
2378            .filter(|i| statuses.is_empty() || statuses.iter().any(|s| s == &i.status))
2379            .map(insight_summary_json)
2380            .collect();
2381        let (page, token) = paginate_checked(&summaries, next_token.as_deref(), max_results)
2382            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
2383        let mut out = json!({ "insights": page });
2384        if let Some(t) = token {
2385            out["nextToken"] = Value::String(t);
2386        }
2387        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
2388    }
2389
2390    fn describe_insight(
2391        &self,
2392        req: &AwsRequest,
2393        cluster_name: &str,
2394        id: &str,
2395    ) -> Result<AwsResponse, AwsServiceError> {
2396        let mut accounts = self.state.write();
2397        let state = accounts.get_or_create(&req.account_id);
2398        let version = state
2399            .clusters
2400            .get(cluster_name)
2401            .ok_or_else(not_found_cluster(cluster_name))?
2402            .version
2403            .clone();
2404        let insights = state
2405            .insights
2406            .entry(cluster_name.to_string())
2407            .or_insert_with(|| {
2408                default_insights(&version)
2409                    .into_iter()
2410                    .map(|i| (i.id.clone(), i))
2411                    .collect()
2412            });
2413        let insight = insights.get(id).ok_or_else(not_found_insight(id))?;
2414        Ok(AwsResponse::json(
2415            StatusCode::OK,
2416            json!({ "insight": insight_json(insight) }).to_string(),
2417        ))
2418    }
2419
2420    fn describe_insights_refresh(
2421        &self,
2422        req: &AwsRequest,
2423        cluster_name: &str,
2424    ) -> Result<AwsResponse, AwsServiceError> {
2425        let mut accounts = self.state.write();
2426        let state = accounts.get_or_create(&req.account_id);
2427        if !state.clusters.contains_key(cluster_name) {
2428            return Err(not_found_cluster(cluster_name)());
2429        }
2430        let refresh = state
2431            .insights_refresh
2432            .entry(cluster_name.to_string())
2433            .or_insert_with(|| InsightsRefresh {
2434                status: "COMPLETED".to_string(),
2435                started_at: Utc::now(),
2436                ended_at: Some(Utc::now()),
2437            });
2438        // An in-progress refresh settles to COMPLETED on first describe.
2439        if refresh.status == "IN_PROGRESS" {
2440            refresh.status = "COMPLETED".to_string();
2441            refresh.ended_at = Some(Utc::now());
2442        }
2443        Ok(AwsResponse::json(
2444            StatusCode::OK,
2445            insights_refresh_json(refresh).to_string(),
2446        ))
2447    }
2448
2449    fn start_insights_refresh(
2450        &self,
2451        req: &AwsRequest,
2452        cluster_name: &str,
2453    ) -> Result<AwsResponse, AwsServiceError> {
2454        let mut accounts = self.state.write();
2455        let state = accounts.get_or_create(&req.account_id);
2456        let version = state
2457            .clusters
2458            .get(cluster_name)
2459            .ok_or_else(not_found_cluster(cluster_name))?
2460            .version
2461            .clone();
2462        // A refresh regenerates the cluster's insights.
2463        state.insights.insert(
2464            cluster_name.to_string(),
2465            default_insights(&version)
2466                .into_iter()
2467                .map(|i| (i.id.clone(), i))
2468                .collect(),
2469        );
2470        let refresh = InsightsRefresh {
2471            status: "IN_PROGRESS".to_string(),
2472            started_at: Utc::now(),
2473            ended_at: None,
2474        };
2475        // StartInsightsRefresh's response carries only `message` + `status`;
2476        // the timestamps are read back through DescribeInsightsRefresh.
2477        let out = json!({
2478            "message": "Insights refresh started for the cluster.",
2479            "status": refresh.status,
2480        });
2481        state
2482            .insights_refresh
2483            .insert(cluster_name.to_string(), refresh);
2484        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
2485    }
2486
2487    // -----------------------------------------------------------------------
2488    // Cluster misc (encryption config, cancel update, register/deregister,
2489    // cluster versions)
2490    // -----------------------------------------------------------------------
2491
2492    fn associate_encryption_config(
2493        &self,
2494        req: &AwsRequest,
2495        cluster_name: &str,
2496    ) -> Result<AwsResponse, AwsServiceError> {
2497        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2498        let encryption_config = body
2499            .get("encryptionConfig")
2500            .filter(|v| v.is_array())
2501            .cloned()
2502            .ok_or_else(|| invalid_parameter("encryptionConfig is required"))?;
2503
2504        let mut accounts = self.state.write();
2505        let state = accounts.get_or_create(&req.account_id);
2506        let cluster = state
2507            .clusters
2508            .get_mut(cluster_name)
2509            .ok_or_else(not_found_cluster(cluster_name))?;
2510        cluster.encryption_config = Some(encryption_config.clone());
2511
2512        let update = new_update(
2513            "AssociateEncryptionConfig",
2514            vec![(
2515                "EncryptionConfig".to_string(),
2516                encryption_config.to_string(),
2517            )],
2518        );
2519        let out = update_json(&update);
2520        cluster.updates.insert(update.id.clone(), update);
2521        Ok(AwsResponse::json(
2522            StatusCode::OK,
2523            json!({ "update": out }).to_string(),
2524        ))
2525    }
2526
2527    fn cancel_update(
2528        &self,
2529        req: &AwsRequest,
2530        name: &str,
2531        update_id: &str,
2532    ) -> Result<AwsResponse, AwsServiceError> {
2533        let mut accounts = self.state.write();
2534        let state = accounts.get_or_create(&req.account_id);
2535        let cluster = state
2536            .clusters
2537            .get_mut(name)
2538            .ok_or_else(not_found_cluster(name))?;
2539        let update = cluster
2540            .updates
2541            .get_mut(update_id)
2542            .ok_or_else(not_found_update(update_id))?;
2543        update.status = "Cancelled".to_string();
2544        Ok(AwsResponse::json(
2545            StatusCode::OK,
2546            json!({ "update": update_json(update) }).to_string(),
2547        ))
2548    }
2549
2550    fn register_cluster(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2551        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2552        let name = body
2553            .get("name")
2554            .and_then(|v| v.as_str())
2555            .ok_or_else(|| invalid_parameter("name is required"))?
2556            .to_string();
2557        validate_cluster_name(&name)?;
2558        let connector = body
2559            .get("connectorConfig")
2560            .filter(|v| v.is_object())
2561            .ok_or_else(|| invalid_parameter("connectorConfig is required"))?;
2562        let role_arn = connector
2563            .get("roleArn")
2564            .and_then(|v| v.as_str())
2565            .ok_or_else(|| invalid_parameter("connectorConfig.roleArn is required"))?
2566            .to_string();
2567        let provider = connector
2568            .get("provider")
2569            .and_then(|v| v.as_str())
2570            .ok_or_else(|| invalid_parameter("connectorConfig.provider is required"))?
2571            .to_string();
2572
2573        let region = req.region.clone();
2574        let account_id = req.account_id.clone();
2575
2576        let mut accounts = self.state.write();
2577        let state = accounts.get_or_create(&req.account_id);
2578        if state.clusters.contains_key(&name) {
2579            return Err(AwsServiceError::aws_error(
2580                StatusCode::CONFLICT,
2581                "ResourceInUseException",
2582                format!("Cluster already exists with name: {name}"),
2583            ));
2584        }
2585
2586        let arn = cluster_arn(&region, &account_id, &name);
2587        let id = uuid::Uuid::new_v4().to_string();
2588        let activation_id = uuid::Uuid::new_v4().to_string();
2589        let activation_code = uuid::Uuid::new_v4().to_string().replace('-', "");
2590        let connector_config = json!({
2591            "activationId": activation_id,
2592            "activationCode": activation_code,
2593            "activationExpiry": timestamp_to_number(Utc::now() + chrono::Duration::hours(72)),
2594            "provider": provider,
2595            "roleArn": role_arn,
2596        });
2597
2598        let cluster = Cluster {
2599            name: name.clone(),
2600            arn,
2601            version: String::new(),
2602            role_arn: String::new(),
2603            status: "PENDING".to_string(),
2604            created_at: Utc::now(),
2605            endpoint: String::new(),
2606            platform_version: "eks.1".to_string(),
2607            certificate_authority_data: String::new(),
2608            resources_vpc_config: json!({}),
2609            kubernetes_network_config: json!({}),
2610            logging: build_logging(None),
2611            tags: parse_tag_map(body.get("tags")),
2612            updates: Default::default(),
2613            connector_config: Some(connector_config),
2614            encryption_config: None,
2615            access_config: build_access_config(None),
2616            upgrade_policy: build_upgrade_policy(None),
2617        };
2618        let out = connected_cluster_json(&cluster, &id);
2619        state.clusters.insert(name, cluster);
2620        Ok(AwsResponse::json(
2621            StatusCode::OK,
2622            json!({ "cluster": out }).to_string(),
2623        ))
2624    }
2625
2626    fn deregister_cluster(
2627        &self,
2628        req: &AwsRequest,
2629        name: &str,
2630    ) -> Result<AwsResponse, AwsServiceError> {
2631        let mut accounts = self.state.write();
2632        let state = accounts.get_or_create(&req.account_id);
2633        // Deregister only applies to connected (registered) clusters.
2634        let is_connected = state
2635            .clusters
2636            .get(name)
2637            .map(|c| c.connector_config.is_some())
2638            .unwrap_or(false);
2639        if !is_connected {
2640            return Err(not_found_cluster(name)());
2641        }
2642        let mut cluster = state.clusters.remove(name).unwrap();
2643        cluster.status = "DELETING".to_string();
2644        let id = uuid::Uuid::new_v4().to_string();
2645        Ok(AwsResponse::json(
2646            StatusCode::OK,
2647            json!({ "cluster": connected_cluster_json(&cluster, &id) }).to_string(),
2648        ))
2649    }
2650
2651    fn describe_cluster_versions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2652        let next_token = req.query_params.get("nextToken").cloned();
2653        let max_results = match req.query_params.get("maxResults") {
2654            Some(raw) => {
2655                let n: i64 = raw
2656                    .parse()
2657                    .map_err(|_| invalid_parameter("maxResults must be an integer"))?;
2658                if !(1..=100).contains(&n) {
2659                    return Err(invalid_parameter("maxResults must be between 1 and 100"));
2660                }
2661                n as usize
2662            }
2663            None => 100,
2664        };
2665        // Enum query params reject values outside the model's declared members.
2666        if let Some(status) = req.query_params.get("status") {
2667            if !["unsupported", "standard_support", "extended_support"].contains(&status.as_str()) {
2668                return Err(invalid_parameter(format!("Invalid status: {status}")));
2669            }
2670        }
2671        if let Some(vs) = req.query_params.get("versionStatus") {
2672            if !["UNSUPPORTED", "STANDARD_SUPPORT", "EXTENDED_SUPPORT"].contains(&vs.as_str()) {
2673                return Err(invalid_parameter(format!("Invalid versionStatus: {vs}")));
2674            }
2675        }
2676        let default_only = req
2677            .query_params
2678            .get("defaultOnly")
2679            .map(|v| v == "true")
2680            .unwrap_or(false);
2681        let version_filter = parse_multi_query(&req.raw_query, "clusterVersions");
2682        let cluster_type = req
2683            .query_params
2684            .get("clusterType")
2685            .cloned()
2686            .unwrap_or_else(|| "eks".to_string());
2687
2688        let mut catalog: Vec<Value> = cluster_version_catalog(&cluster_type)
2689            .into_iter()
2690            .filter(|v| {
2691                version_filter.is_empty()
2692                    || version_filter
2693                        .iter()
2694                        .any(|f| v["clusterVersion"] == f.as_str())
2695            })
2696            .filter(|v| !default_only || v["defaultVersion"] == true)
2697            .collect();
2698        // AWS reports the default version first; a stable sort keying non-default
2699        // rows after default ones preserves the ascending version order otherwise.
2700        catalog.sort_by_key(|v| v["defaultVersion"] != true);
2701
2702        let (page, token) = paginate_checked(&catalog, next_token.as_deref(), max_results)
2703            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
2704        let mut out = json!({ "clusterVersions": page });
2705        if let Some(t) = token {
2706            out["nextToken"] = Value::String(t);
2707        }
2708        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
2709    }
2710
2711    // -----------------------------------------------------------------------
2712    // Capabilities
2713    // -----------------------------------------------------------------------
2714
2715    fn create_capability(
2716        &self,
2717        req: &AwsRequest,
2718        cluster_name: &str,
2719    ) -> Result<AwsResponse, AwsServiceError> {
2720        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2721        let name = body
2722            .get("capabilityName")
2723            .and_then(|v| v.as_str())
2724            .ok_or_else(|| invalid_parameter("capabilityName is required"))?
2725            .to_string();
2726        let type_ = body
2727            .get("type")
2728            .and_then(|v| v.as_str())
2729            .ok_or_else(|| invalid_parameter("type is required"))?
2730            .to_string();
2731        let role_arn = body
2732            .get("roleArn")
2733            .and_then(|v| v.as_str())
2734            .ok_or_else(|| invalid_parameter("roleArn is required"))?
2735            .to_string();
2736
2737        let region = req.region.clone();
2738        let account_id = req.account_id.clone();
2739
2740        let mut accounts = self.state.write();
2741        let state = accounts.get_or_create(&req.account_id);
2742        if !state.clusters.contains_key(cluster_name) {
2743            return Err(not_found_cluster(cluster_name)());
2744        }
2745        if state
2746            .capabilities
2747            .get(cluster_name)
2748            .is_some_and(|m| m.contains_key(&name))
2749        {
2750            return Err(AwsServiceError::aws_error(
2751                StatusCode::CONFLICT,
2752                "ResourceInUseException",
2753                format!(
2754                    "Capability already exists with name {name} and cluster name {cluster_name}"
2755                ),
2756            ));
2757        }
2758
2759        let id = uuid::Uuid::new_v4().to_string();
2760        let arn = capability_arn(&region, &account_id, cluster_name, &name, &id);
2761        let now = Utc::now();
2762        let capability = Capability {
2763            name: name.clone(),
2764            arn,
2765            cluster_name: cluster_name.to_string(),
2766            type_,
2767            role_arn,
2768            status: "CREATING".to_string(),
2769            version: "v1".to_string(),
2770            configuration: normalize_capability_configuration(body.get("configuration")),
2771            tags: parse_tag_map(body.get("tags")),
2772            created_at: now,
2773            modified_at: now,
2774            delete_propagation_policy: str_field(&body, "deletePropagationPolicy"),
2775        };
2776        let out = capability_json(&capability);
2777        state
2778            .capabilities
2779            .entry(cluster_name.to_string())
2780            .or_default()
2781            .insert(name, capability);
2782        Ok(AwsResponse::json(
2783            StatusCode::OK,
2784            json!({ "capability": out }).to_string(),
2785        ))
2786    }
2787
2788    fn describe_capability(
2789        &self,
2790        req: &AwsRequest,
2791        cluster_name: &str,
2792        name: &str,
2793    ) -> Result<AwsResponse, AwsServiceError> {
2794        let mut accounts = self.state.write();
2795        let state = accounts.get_or_create(&req.account_id);
2796        if !state.clusters.contains_key(cluster_name) {
2797            return Err(not_found_cluster(cluster_name)());
2798        }
2799        let capability = state
2800            .capabilities
2801            .get_mut(cluster_name)
2802            .and_then(|m| m.get_mut(name))
2803            .ok_or_else(not_found_capability(name))?;
2804        if capability.status == "CREATING" {
2805            capability.status = "ACTIVE".to_string();
2806        }
2807        Ok(AwsResponse::json(
2808            StatusCode::OK,
2809            json!({ "capability": capability_json(capability) }).to_string(),
2810        ))
2811    }
2812
2813    fn list_capabilities(
2814        &self,
2815        req: &AwsRequest,
2816        cluster_name: &str,
2817    ) -> Result<AwsResponse, AwsServiceError> {
2818        let max_results = validate_max_results(req)?;
2819        let next_token = req.query_params.get("nextToken").cloned();
2820        let accounts = self.state.read();
2821        let state = accounts
2822            .get(&req.account_id)
2823            .ok_or_else(not_found_cluster(cluster_name))?;
2824        if !state.clusters.contains_key(cluster_name) {
2825            return Err(not_found_cluster(cluster_name)());
2826        }
2827        let summaries: Vec<Value> = state
2828            .capabilities
2829            .get(cluster_name)
2830            .map(|m| m.values().map(capability_summary_json).collect())
2831            .unwrap_or_default();
2832        let (page, token) = paginate_checked(&summaries, next_token.as_deref(), max_results)
2833            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
2834        let mut out = json!({ "capabilities": page });
2835        if let Some(t) = token {
2836            out["nextToken"] = Value::String(t);
2837        }
2838        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
2839    }
2840
2841    fn delete_capability(
2842        &self,
2843        req: &AwsRequest,
2844        cluster_name: &str,
2845        name: &str,
2846    ) -> Result<AwsResponse, AwsServiceError> {
2847        let mut accounts = self.state.write();
2848        let state = accounts.get_or_create(&req.account_id);
2849        if !state.clusters.contains_key(cluster_name) {
2850            return Err(not_found_cluster(cluster_name)());
2851        }
2852        let mut capability = state
2853            .capabilities
2854            .get_mut(cluster_name)
2855            .and_then(|m| m.remove(name))
2856            .ok_or_else(not_found_capability(name))?;
2857        capability.status = "DELETING".to_string();
2858        Ok(AwsResponse::json(
2859            StatusCode::OK,
2860            json!({ "capability": capability_json(&capability) }).to_string(),
2861        ))
2862    }
2863
2864    fn update_capability(
2865        &self,
2866        req: &AwsRequest,
2867        cluster_name: &str,
2868        name: &str,
2869    ) -> Result<AwsResponse, AwsServiceError> {
2870        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2871        let mut accounts = self.state.write();
2872        let state = accounts.get_or_create(&req.account_id);
2873        if !state.clusters.contains_key(cluster_name) {
2874            return Err(not_found_cluster(cluster_name)());
2875        }
2876        let capability = state
2877            .capabilities
2878            .get_mut(cluster_name)
2879            .and_then(|m| m.get_mut(name))
2880            .ok_or_else(not_found_capability(name))?;
2881
2882        let mut params = Vec::new();
2883        if let Some(role) = body.get("roleArn").and_then(|v| v.as_str()) {
2884            capability.role_arn = role.to_string();
2885            params.push(("RoleArn".to_string(), role.to_string()));
2886        }
2887        if let Some(cfg) = normalize_capability_configuration(body.get("configuration")) {
2888            params.push(("Configuration".to_string(), cfg.to_string()));
2889            capability.configuration = Some(cfg);
2890        }
2891        capability.modified_at = Utc::now();
2892
2893        // UpdateCapability returns a tracked cluster-scoped Update.
2894        let update = new_update("CapabilityUpdate", params);
2895        let out = update_json(&update);
2896        let cluster = state.clusters.get_mut(cluster_name).unwrap();
2897        cluster.updates.insert(update.id.clone(), update);
2898        Ok(AwsResponse::json(
2899            StatusCode::OK,
2900            json!({ "update": out }).to_string(),
2901        ))
2902    }
2903
2904    // -----------------------------------------------------------------------
2905    // EKS Anywhere subscriptions
2906    // -----------------------------------------------------------------------
2907
2908    fn create_eks_anywhere_subscription(
2909        &self,
2910        req: &AwsRequest,
2911    ) -> Result<AwsResponse, AwsServiceError> {
2912        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
2913        let name = body
2914            .get("name")
2915            .and_then(|v| v.as_str())
2916            .ok_or_else(|| invalid_parameter("name is required"))?
2917            .to_string();
2918        // EksAnywhereSubscriptionName: length 1-100, ^[0-9A-Za-z][A-Za-z0-9\-_]*$.
2919        if name.is_empty() || name.len() > 100 {
2920            return Err(invalid_parameter("name must be 1-100 characters"));
2921        }
2922        if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
2923            || !name
2924                .chars()
2925                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2926        {
2927            return Err(invalid_parameter(
2928                "name must match ^[0-9A-Za-z][A-Za-z0-9\\-_]*$",
2929            ));
2930        }
2931        if let Some(lt) = body.get("licenseType").and_then(|v| v.as_str()) {
2932            if lt != "Cluster" {
2933                return Err(invalid_parameter(format!("Invalid licenseType: {lt}")));
2934            }
2935        }
2936        let term = body
2937            .get("term")
2938            .filter(|v| v.is_object())
2939            .ok_or_else(|| invalid_parameter("term is required"))?;
2940        let term_duration = term.get("duration").and_then(|v| v.as_i64()).unwrap_or(12);
2941        let term_unit = term
2942            .get("unit")
2943            .and_then(|v| v.as_str())
2944            .unwrap_or("MONTHS")
2945            .to_string();
2946        let license_quantity = body
2947            .get("licenseQuantity")
2948            .and_then(|v| v.as_i64())
2949            .unwrap_or(0);
2950        let license_type = body
2951            .get("licenseType")
2952            .and_then(|v| v.as_str())
2953            .unwrap_or("Cluster")
2954            .to_string();
2955        let auto_renew = body
2956            .get("autoRenew")
2957            .and_then(|v| v.as_bool())
2958            .unwrap_or(false);
2959
2960        let region = req.region.clone();
2961        let account_id = req.account_id.clone();
2962
2963        let mut accounts = self.state.write();
2964        let state = accounts.get_or_create(&req.account_id);
2965
2966        let raw = uuid::Uuid::new_v4().to_string().replace('-', "");
2967        let id = raw[..17.min(raw.len())].to_string();
2968        let arn = eks_anywhere_subscription_arn(&region, &account_id, &id);
2969        let now = Utc::now();
2970        let expiration = now + chrono::Duration::days(term_duration * 30);
2971        let subscription = EksAnywhereSubscription {
2972            id: id.clone(),
2973            arn,
2974            name,
2975            created_at: now,
2976            effective_date: now,
2977            expiration_date: expiration,
2978            license_quantity,
2979            license_type,
2980            term_duration,
2981            term_unit,
2982            status: "ACTIVE".to_string(),
2983            auto_renew,
2984            tags: parse_tag_map(body.get("tags")),
2985        };
2986        let out = subscription_json(&subscription);
2987        state.eks_anywhere_subscriptions.insert(id, subscription);
2988        Ok(AwsResponse::json(
2989            StatusCode::OK,
2990            json!({ "subscription": out }).to_string(),
2991        ))
2992    }
2993
2994    fn list_eks_anywhere_subscriptions(
2995        &self,
2996        req: &AwsRequest,
2997    ) -> Result<AwsResponse, AwsServiceError> {
2998        let max_results = validate_max_results(req)?;
2999        let next_token = req.query_params.get("nextToken").cloned();
3000        let accounts = self.state.read();
3001        let Some(state) = accounts.get(&req.account_id) else {
3002            return Ok(AwsResponse::json(
3003                StatusCode::OK,
3004                json!({ "subscriptions": [] }).to_string(),
3005            ));
3006        };
3007        let subs: Vec<Value> = state
3008            .eks_anywhere_subscriptions
3009            .values()
3010            .map(subscription_json)
3011            .collect();
3012        let (page, token) = paginate_checked(&subs, next_token.as_deref(), max_results)
3013            .map_err(|_| invalid_parameter("Invalid nextToken"))?;
3014        let mut out = json!({ "subscriptions": page });
3015        if let Some(t) = token {
3016            out["nextToken"] = Value::String(t);
3017        }
3018        Ok(AwsResponse::json(StatusCode::OK, out.to_string()))
3019    }
3020
3021    fn describe_eks_anywhere_subscription(
3022        &self,
3023        req: &AwsRequest,
3024        id: &str,
3025    ) -> Result<AwsResponse, AwsServiceError> {
3026        let accounts = self.state.read();
3027        let state = accounts
3028            .get(&req.account_id)
3029            .ok_or_else(not_found_subscription(id))?;
3030        let subscription = state
3031            .eks_anywhere_subscriptions
3032            .get(id)
3033            .ok_or_else(not_found_subscription(id))?;
3034        Ok(AwsResponse::json(
3035            StatusCode::OK,
3036            json!({ "subscription": subscription_json(subscription) }).to_string(),
3037        ))
3038    }
3039
3040    fn delete_eks_anywhere_subscription(
3041        &self,
3042        req: &AwsRequest,
3043        id: &str,
3044    ) -> Result<AwsResponse, AwsServiceError> {
3045        let mut accounts = self.state.write();
3046        let state = accounts.get_or_create(&req.account_id);
3047        let mut subscription = state
3048            .eks_anywhere_subscriptions
3049            .remove(id)
3050            .ok_or_else(not_found_subscription(id))?;
3051        subscription.status = "DELETING".to_string();
3052        Ok(AwsResponse::json(
3053            StatusCode::OK,
3054            json!({ "subscription": subscription_json(&subscription) }).to_string(),
3055        ))
3056    }
3057
3058    fn update_eks_anywhere_subscription(
3059        &self,
3060        req: &AwsRequest,
3061        id: &str,
3062    ) -> Result<AwsResponse, AwsServiceError> {
3063        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
3064        let auto_renew = body
3065            .get("autoRenew")
3066            .and_then(|v| v.as_bool())
3067            .ok_or_else(|| invalid_parameter("autoRenew is required"))?;
3068        let mut accounts = self.state.write();
3069        let state = accounts.get_or_create(&req.account_id);
3070        let subscription = state
3071            .eks_anywhere_subscriptions
3072            .get_mut(id)
3073            .ok_or_else(not_found_subscription(id))?;
3074        subscription.auto_renew = auto_renew;
3075        Ok(AwsResponse::json(
3076            StatusCode::OK,
3077            json!({ "subscription": subscription_json(subscription) }).to_string(),
3078        ))
3079    }
3080}
3081
3082/// Persist the current EKS state as a snapshot. Shared by
3083/// `EksService::save_snapshot` and the CFN provisioner's persist hook.
3084pub async fn save_eks_snapshot(
3085    state: &SharedEksState,
3086    store: Option<Arc<dyn SnapshotStore>>,
3087    lock: &AsyncMutex<()>,
3088) {
3089    let Some(store) = store else {
3090        return;
3091    };
3092    let _guard = lock.lock().await;
3093    let snapshot = EksSnapshot {
3094        schema_version: EKS_SNAPSHOT_SCHEMA_VERSION,
3095        accounts: state.read().clone(),
3096    };
3097    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
3098        let bytes = serde_json::to_vec(&snapshot)
3099            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
3100        store.save(&bytes)
3101    })
3102    .await;
3103    match join {
3104        Ok(Ok(())) => {}
3105        Ok(Err(err)) => tracing::error!(%err, "failed to write eks snapshot"),
3106        Err(err) => tracing::error!(%err, "eks snapshot task panicked"),
3107    }
3108}
3109
3110#[async_trait]
3111impl AwsService for EksService {
3112    fn service_name(&self) -> &str {
3113        "eks"
3114    }
3115
3116    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
3117        let (action, args) = Self::resolve_action(&req).ok_or_else(|| {
3118            AwsServiceError::aws_error(
3119                StatusCode::NOT_FOUND,
3120                "UnknownOperationException",
3121                format!("Unknown operation: {} {}", req.method, req.raw_path),
3122            )
3123        })?;
3124
3125        let mutates = matches!(
3126            action,
3127            "CreateCluster"
3128                | "DeleteCluster"
3129                | "UpdateClusterConfig"
3130                | "UpdateClusterVersion"
3131                | "TagResource"
3132                | "UntagResource"
3133                // Describe settles CREATING -> ACTIVE and IN_PROGRESS updates,
3134                // which mutates persisted state.
3135                | "DescribeCluster"
3136                | "DescribeUpdate"
3137                | "CreateNodegroup"
3138                | "DeleteNodegroup"
3139                | "UpdateNodegroupConfig"
3140                | "UpdateNodegroupVersion"
3141                | "DescribeNodegroup"
3142                | "CreateFargateProfile"
3143                | "DeleteFargateProfile"
3144                | "DescribeFargateProfile"
3145                | "CreateAddon"
3146                | "DeleteAddon"
3147                | "UpdateAddon"
3148                | "DescribeAddon"
3149                | "CreateAccessEntry"
3150                | "DeleteAccessEntry"
3151                | "UpdateAccessEntry"
3152                | "AssociateAccessPolicy"
3153                | "DisassociateAccessPolicy"
3154                | "AssociateIdentityProviderConfig"
3155                | "DisassociateIdentityProviderConfig"
3156                // Describe settles the config's CREATING -> ACTIVE transition.
3157                | "DescribeIdentityProviderConfig"
3158                | "CreatePodIdentityAssociation"
3159                | "DeletePodIdentityAssociation"
3160                | "UpdatePodIdentityAssociation"
3161                // Insights are seeded/settled on read; refresh settles on describe.
3162                | "ListInsights"
3163                | "DescribeInsight"
3164                | "DescribeInsightsRefresh"
3165                | "StartInsightsRefresh"
3166                | "AssociateEncryptionConfig"
3167                | "CancelUpdate"
3168                | "RegisterCluster"
3169                | "DeregisterCluster"
3170                | "CreateCapability"
3171                | "DeleteCapability"
3172                | "UpdateCapability"
3173                | "DescribeCapability"
3174                | "CreateEksAnywhereSubscription"
3175                | "DeleteEksAnywhereSubscription"
3176                | "UpdateEksAnywhereSubscription"
3177                | "DescribeEksAnywhereSubscription"
3178        );
3179
3180        let result = match (action, &args) {
3181            ("CreateCluster", _) => self.create_cluster(&req),
3182            ("ListClusters", _) => self.list_clusters(&req),
3183            ("DescribeCluster", PathArgs::Name(n)) => self.describe_cluster(&req, n),
3184            ("DeleteCluster", PathArgs::Name(n)) => self.delete_cluster(&req, n),
3185            ("UpdateClusterConfig", PathArgs::Name(n)) => self.update_cluster_config(&req, n),
3186            ("UpdateClusterVersion", PathArgs::Name(n)) => self.update_cluster_version(&req, n),
3187            ("ListUpdates", PathArgs::Name(n)) => self.list_updates(&req, n),
3188            ("DescribeUpdate", PathArgs::Update { name, update_id }) => {
3189                self.describe_update(&req, name, update_id)
3190            }
3191            ("TagResource", PathArgs::Arn(a)) => self.tag_resource(&req, a),
3192            ("UntagResource", PathArgs::Arn(a)) => self.untag_resource(&req, a),
3193            ("ListTagsForResource", PathArgs::Arn(a)) => self.list_tags_for_resource(&req, a),
3194            ("CreateNodegroup", PathArgs::Cluster(c)) => self.create_nodegroup(&req, c),
3195            ("ListNodegroups", PathArgs::Cluster(c)) => self.list_nodegroups(&req, c),
3196            ("DescribeNodegroup", PathArgs::ClusterChild { cluster, name }) => {
3197                self.describe_nodegroup(&req, cluster, name)
3198            }
3199            ("DeleteNodegroup", PathArgs::ClusterChild { cluster, name }) => {
3200                self.delete_nodegroup(&req, cluster, name)
3201            }
3202            ("UpdateNodegroupConfig", PathArgs::ClusterChild { cluster, name }) => {
3203                self.update_nodegroup_config(&req, cluster, name)
3204            }
3205            ("UpdateNodegroupVersion", PathArgs::ClusterChild { cluster, name }) => {
3206                self.update_nodegroup_version(&req, cluster, name)
3207            }
3208            ("CreateFargateProfile", PathArgs::Cluster(c)) => self.create_fargate_profile(&req, c),
3209            ("ListFargateProfiles", PathArgs::Cluster(c)) => self.list_fargate_profiles(&req, c),
3210            ("DescribeFargateProfile", PathArgs::ClusterChild { cluster, name }) => {
3211                self.describe_fargate_profile(&req, cluster, name)
3212            }
3213            ("DeleteFargateProfile", PathArgs::ClusterChild { cluster, name }) => {
3214                self.delete_fargate_profile(&req, cluster, name)
3215            }
3216            ("CreateAddon", PathArgs::Cluster(c)) => self.create_addon(&req, c),
3217            ("ListAddons", PathArgs::Cluster(c)) => self.list_addons(&req, c),
3218            ("DescribeAddon", PathArgs::ClusterChild { cluster, name }) => {
3219                self.describe_addon(&req, cluster, name)
3220            }
3221            ("DeleteAddon", PathArgs::ClusterChild { cluster, name }) => {
3222                self.delete_addon(&req, cluster, name)
3223            }
3224            ("UpdateAddon", PathArgs::ClusterChild { cluster, name }) => {
3225                self.update_addon(&req, cluster, name)
3226            }
3227            ("DescribeAddonVersions", _) => self.describe_addon_versions(&req),
3228            ("DescribeAddonConfiguration", _) => self.describe_addon_configuration(&req),
3229            ("CreateAccessEntry", PathArgs::Cluster(c)) => self.create_access_entry(&req, c),
3230            ("ListAccessEntries", PathArgs::Cluster(c)) => self.list_access_entries(&req, c),
3231            ("DescribeAccessEntry", PathArgs::ClusterChild { cluster, name }) => {
3232                self.describe_access_entry(&req, cluster, name)
3233            }
3234            ("DeleteAccessEntry", PathArgs::ClusterChild { cluster, name }) => {
3235                self.delete_access_entry(&req, cluster, name)
3236            }
3237            ("UpdateAccessEntry", PathArgs::ClusterChild { cluster, name }) => {
3238                self.update_access_entry(&req, cluster, name)
3239            }
3240            ("AssociateAccessPolicy", PathArgs::ClusterChild { cluster, name }) => {
3241                self.associate_access_policy(&req, cluster, name)
3242            }
3243            ("ListAssociatedAccessPolicies", PathArgs::ClusterChild { cluster, name }) => {
3244                self.list_associated_access_policies(&req, cluster, name)
3245            }
3246            (
3247                "DisassociateAccessPolicy",
3248                PathArgs::AccessPolicyChild {
3249                    cluster,
3250                    principal,
3251                    policy_arn,
3252                },
3253            ) => self.disassociate_access_policy(&req, cluster, principal, policy_arn),
3254            ("ListAccessPolicies", _) => self.list_access_policies(&req),
3255            ("AssociateIdentityProviderConfig", PathArgs::Cluster(c)) => {
3256                self.associate_identity_provider_config(&req, c)
3257            }
3258            ("DisassociateIdentityProviderConfig", PathArgs::Cluster(c)) => {
3259                self.disassociate_identity_provider_config(&req, c)
3260            }
3261            ("DescribeIdentityProviderConfig", PathArgs::Cluster(c)) => {
3262                self.describe_identity_provider_config(&req, c)
3263            }
3264            ("ListIdentityProviderConfigs", PathArgs::Cluster(c)) => {
3265                self.list_identity_provider_configs(&req, c)
3266            }
3267            ("CreatePodIdentityAssociation", PathArgs::Cluster(c)) => {
3268                self.create_pod_identity_association(&req, c)
3269            }
3270            ("ListPodIdentityAssociations", PathArgs::Cluster(c)) => {
3271                self.list_pod_identity_associations(&req, c)
3272            }
3273            ("DescribePodIdentityAssociation", PathArgs::ClusterChild { cluster, name }) => {
3274                self.describe_pod_identity_association(&req, cluster, name)
3275            }
3276            ("DeletePodIdentityAssociation", PathArgs::ClusterChild { cluster, name }) => {
3277                self.delete_pod_identity_association(&req, cluster, name)
3278            }
3279            ("UpdatePodIdentityAssociation", PathArgs::ClusterChild { cluster, name }) => {
3280                self.update_pod_identity_association(&req, cluster, name)
3281            }
3282            ("ListInsights", PathArgs::Cluster(c)) => self.list_insights(&req, c),
3283            ("DescribeInsight", PathArgs::ClusterChild { cluster, name }) => {
3284                self.describe_insight(&req, cluster, name)
3285            }
3286            ("DescribeInsightsRefresh", PathArgs::Cluster(c)) => {
3287                self.describe_insights_refresh(&req, c)
3288            }
3289            ("StartInsightsRefresh", PathArgs::Cluster(c)) => self.start_insights_refresh(&req, c),
3290            ("AssociateEncryptionConfig", PathArgs::Cluster(c)) => {
3291                self.associate_encryption_config(&req, c)
3292            }
3293            ("CancelUpdate", PathArgs::Update { name, update_id }) => {
3294                self.cancel_update(&req, name, update_id)
3295            }
3296            ("RegisterCluster", _) => self.register_cluster(&req),
3297            ("DeregisterCluster", PathArgs::Name(n)) => self.deregister_cluster(&req, n),
3298            ("DescribeClusterVersions", _) => self.describe_cluster_versions(&req),
3299            ("CreateCapability", PathArgs::Cluster(c)) => self.create_capability(&req, c),
3300            ("ListCapabilities", PathArgs::Cluster(c)) => self.list_capabilities(&req, c),
3301            ("DescribeCapability", PathArgs::ClusterChild { cluster, name }) => {
3302                self.describe_capability(&req, cluster, name)
3303            }
3304            ("DeleteCapability", PathArgs::ClusterChild { cluster, name }) => {
3305                self.delete_capability(&req, cluster, name)
3306            }
3307            ("UpdateCapability", PathArgs::ClusterChild { cluster, name }) => {
3308                self.update_capability(&req, cluster, name)
3309            }
3310            ("CreateEksAnywhereSubscription", _) => self.create_eks_anywhere_subscription(&req),
3311            ("ListEksAnywhereSubscriptions", _) => self.list_eks_anywhere_subscriptions(&req),
3312            ("DescribeEksAnywhereSubscription", PathArgs::Name(id)) => {
3313                self.describe_eks_anywhere_subscription(&req, id)
3314            }
3315            ("DeleteEksAnywhereSubscription", PathArgs::Name(id)) => {
3316                self.delete_eks_anywhere_subscription(&req, id)
3317            }
3318            ("UpdateEksAnywhereSubscription", PathArgs::Name(id)) => {
3319                self.update_eks_anywhere_subscription(&req, id)
3320            }
3321            _ => Err(AwsServiceError::action_not_implemented("eks", action)),
3322        };
3323
3324        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
3325            self.save_snapshot().await;
3326        }
3327        result
3328    }
3329
3330    fn supported_actions(&self) -> &[&str] {
3331        EKS_ACTIONS
3332    }
3333}
3334
3335// ---------------------------------------------------------------------------
3336// Helpers
3337// ---------------------------------------------------------------------------
3338
3339fn decode(s: &str) -> String {
3340    percent_encoding::percent_decode_str(s)
3341        .decode_utf8_lossy()
3342        .into_owned()
3343}
3344
3345fn invalid_parameter(msg: impl Into<String>) -> AwsServiceError {
3346    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidParameterException", msg)
3347}
3348
3349fn bad_request(msg: impl Into<String>) -> AwsServiceError {
3350    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
3351}
3352
3353/// Validate the shared `maxResults` query param (range 1-100) used by the list
3354/// operations, returning the effective page size.
3355fn validate_max_results(req: &AwsRequest) -> Result<usize, AwsServiceError> {
3356    match req.query_params.get("maxResults") {
3357        Some(raw) => {
3358            let n: i64 = raw
3359                .parse()
3360                .map_err(|_| invalid_parameter("maxResults must be an integer"))?;
3361            if !(1..=100).contains(&n) {
3362                return Err(invalid_parameter("maxResults must be between 1 and 100"));
3363            }
3364            Ok(n as usize)
3365        }
3366        None => Ok(100),
3367    }
3368}
3369
3370fn validate_cluster_name(name: &str) -> Result<(), AwsServiceError> {
3371    if name.is_empty() || name.len() > 100 {
3372        return Err(invalid_parameter("name must be 1-100 characters"));
3373    }
3374    let mut chars = name.chars();
3375    let first = chars.next().unwrap();
3376    if !first.is_ascii_alphanumeric() {
3377        return Err(invalid_parameter(
3378            "name must start with an alphanumeric character",
3379        ));
3380    }
3381    if !name
3382        .chars()
3383        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
3384    {
3385        return Err(invalid_parameter(
3386            "name must match ^[0-9A-Za-z][A-Za-z0-9\\-_]*$",
3387        ));
3388    }
3389    Ok(())
3390}
3391
3392fn not_found_cluster(name: &str) -> impl Fn() -> AwsServiceError + 'static {
3393    let name = name.to_string();
3394    move || {
3395        AwsServiceError::aws_error(
3396            StatusCode::NOT_FOUND,
3397            "ResourceNotFoundException",
3398            format!("No cluster found for name: {name}."),
3399        )
3400    }
3401}
3402
3403fn not_found_update(id: &str) -> impl Fn() -> AwsServiceError + 'static {
3404    let id = id.to_string();
3405    move || {
3406        AwsServiceError::aws_error(
3407            StatusCode::NOT_FOUND,
3408            "ResourceNotFoundException",
3409            format!("No update found for id: {id}."),
3410        )
3411    }
3412}
3413
3414fn not_found_nodegroup(name: &str) -> impl Fn() -> AwsServiceError + 'static {
3415    let name = name.to_string();
3416    move || {
3417        AwsServiceError::aws_error(
3418            StatusCode::NOT_FOUND,
3419            "ResourceNotFoundException",
3420            format!("No node group found for name: {name}."),
3421        )
3422    }
3423}
3424
3425fn not_found_fargate_profile(name: &str) -> impl Fn() -> AwsServiceError + 'static {
3426    let name = name.to_string();
3427    move || {
3428        AwsServiceError::aws_error(
3429            StatusCode::NOT_FOUND,
3430            "ResourceNotFoundException",
3431            format!("No Fargate Profile found with name: {name}."),
3432        )
3433    }
3434}
3435
3436fn not_found_addon(name: &str) -> impl Fn() -> AwsServiceError + 'static {
3437    let name = name.to_string();
3438    move || {
3439        AwsServiceError::aws_error(
3440            StatusCode::NOT_FOUND,
3441            "ResourceNotFoundException",
3442            format!("No addon found for name: {name}."),
3443        )
3444    }
3445}
3446
3447fn not_found_access_entry(principal_arn: &str) -> impl Fn() -> AwsServiceError + 'static {
3448    let principal_arn = principal_arn.to_string();
3449    move || {
3450        AwsServiceError::aws_error(
3451            StatusCode::NOT_FOUND,
3452            "ResourceNotFoundException",
3453            format!("No access entry found for principal ARN: {principal_arn}."),
3454        )
3455    }
3456}
3457
3458fn not_found_identity_provider_config(name: &str) -> impl Fn() -> AwsServiceError + 'static {
3459    let name = name.to_string();
3460    move || {
3461        AwsServiceError::aws_error(
3462            StatusCode::NOT_FOUND,
3463            "ResourceNotFoundException",
3464            format!("No identity provider config found for name: {name}."),
3465        )
3466    }
3467}
3468
3469fn not_found_pod_identity_association(id: &str) -> impl Fn() -> AwsServiceError + 'static {
3470    let id = id.to_string();
3471    move || {
3472        AwsServiceError::aws_error(
3473            StatusCode::NOT_FOUND,
3474            "ResourceNotFoundException",
3475            format!("No pod identity association found for id: {id}."),
3476        )
3477    }
3478}
3479
3480fn not_found_arn(arn: &str) -> impl Fn() -> AwsServiceError + 'static {
3481    let arn = arn.to_string();
3482    move || {
3483        AwsServiceError::aws_error(
3484            StatusCode::NOT_FOUND,
3485            "NotFoundException",
3486            format!("Resource not found: {arn}"),
3487        )
3488    }
3489}
3490
3491/// Extract the cluster name from an EKS cluster ARN, rejecting malformed or
3492/// non-cluster ARNs with a BadRequestException (matching the tag ops' error set).
3493fn cluster_name_from_arn(arn: &str) -> Result<String, AwsServiceError> {
3494    let parts: Vec<&str> = arn.split(':').collect();
3495    if parts.len() < 6 || parts[0] != "arn" || parts[2] != "eks" {
3496        return Err(bad_request(format!("Invalid EKS ARN: {arn}")));
3497    }
3498    let resource = parts[5];
3499    let name = resource
3500        .strip_prefix("cluster/")
3501        .filter(|n| !n.is_empty())
3502        .ok_or_else(|| bad_request(format!("Unsupported resource ARN: {arn}")))?;
3503    Ok(name.to_string())
3504}
3505
3506fn parse_tag_map(v: Option<&Value>) -> crate::state::TagMap {
3507    let mut out = crate::state::TagMap::new();
3508    if let Some(obj) = v.and_then(|v| v.as_object()) {
3509        for (k, val) in obj {
3510            if let Some(s) = val.as_str() {
3511                out.insert(k.clone(), s.to_string());
3512            }
3513        }
3514    }
3515    out
3516}
3517
3518/// Parse a repeated query-string key (`?tagKeys=a&tagKeys=b`) into decoded values.
3519fn parse_multi_query(raw_query: &str, key: &str) -> Vec<String> {
3520    let prefix = format!("{key}=");
3521    raw_query
3522        .split('&')
3523        .filter(|pair| pair.starts_with(&prefix))
3524        .map(|pair| decode(&pair[prefix.len()..]))
3525        .collect()
3526}
3527
3528fn new_update(update_type: &str, params: Vec<(String, String)>) -> Update {
3529    Update {
3530        id: uuid::Uuid::new_v4().to_string(),
3531        status: "InProgress".to_string(),
3532        type_: update_type.to_string(),
3533        params,
3534        created_at: Utc::now(),
3535    }
3536}
3537
3538fn timestamp_to_number(t: DateTime<Utc>) -> Value {
3539    let secs = t.timestamp() as f64;
3540    let frac = t.timestamp_subsec_millis() as f64 / 1000.0;
3541    Value::from(secs + frac)
3542}
3543
3544/// Recover the synthetic cluster id from a stored endpoint URL so describe/delete
3545/// can re-derive the OIDC issuer without persisting the id separately.
3546fn arn_cluster_id(endpoint: &str) -> String {
3547    endpoint
3548        .strip_prefix("https://")
3549        .and_then(|s| s.split('.').next())
3550        .map(|s| s.to_lowercase())
3551        .unwrap_or_default()
3552}
3553
3554fn default_ca_data() -> String {
3555    // A stable placeholder base64 blob; real clusters return a PEM-encoded CA.
3556    "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCg==".to_string()
3557}
3558
3559fn build_vpc_config_response(req: &Value, id: &str) -> Value {
3560    let subnet_ids = req.get("subnetIds").cloned().unwrap_or_else(|| json!([]));
3561    let security_group_ids = req
3562        .get("securityGroupIds")
3563        .cloned()
3564        .unwrap_or_else(|| json!([]));
3565    let public_access_cidrs = req
3566        .get("publicAccessCidrs")
3567        .cloned()
3568        .unwrap_or_else(|| json!(["0.0.0.0/0"]));
3569    let endpoint_public = req
3570        .get("endpointPublicAccess")
3571        .and_then(|v| v.as_bool())
3572        .unwrap_or(true);
3573    let endpoint_private = req
3574        .get("endpointPrivateAccess")
3575        .and_then(|v| v.as_bool())
3576        .unwrap_or(false);
3577    let mut out = json!({
3578        "subnetIds": subnet_ids,
3579        "securityGroupIds": security_group_ids,
3580        "clusterSecurityGroupId": format!("sg-{}", &id.replace('-', "")[..17.min(id.replace('-', "").len())]),
3581        "vpcId": format!("vpc-{}", &id.replace('-', "")[..17.min(id.replace('-', "").len())]),
3582        "endpointPublicAccess": endpoint_public,
3583        "endpointPrivateAccess": endpoint_private,
3584        "publicAccessCidrs": public_access_cidrs,
3585    });
3586    if let Some(mode) = req.get("controlPlaneEgressMode") {
3587        out["controlPlaneEgressMode"] = mode.clone();
3588    }
3589    out
3590}
3591
3592fn build_k8s_network_config(req: Option<&Value>) -> Value {
3593    let ip_family = req
3594        .and_then(|v| v.get("ipFamily"))
3595        .and_then(|v| v.as_str())
3596        .unwrap_or("ipv4")
3597        .to_string();
3598    let service_cidr = req
3599        .and_then(|v| v.get("serviceIpv4Cidr"))
3600        .and_then(|v| v.as_str())
3601        .unwrap_or("172.20.0.0/16")
3602        .to_string();
3603    // `elasticLoadBalancing` is always present in the response; `enabled`
3604    // defaults to false unless the caller opts in (auto mode / EKS Auto).
3605    let elb_enabled = req
3606        .and_then(|v| v.get("elasticLoadBalancing"))
3607        .and_then(|v| v.get("enabled"))
3608        .and_then(|v| v.as_bool())
3609        .unwrap_or(false);
3610    json!({
3611        "serviceIpv4Cidr": service_cidr,
3612        "ipFamily": ip_family,
3613        "elasticLoadBalancing": { "enabled": elb_enabled },
3614    })
3615}
3616
3617/// Build an `AccessConfigResponse` object. The API only reports
3618/// `authenticationMode` (bootstrap-creator permission is a create-only input),
3619/// defaulting to `CONFIG_MAP` when the caller omits `accessConfig`.
3620fn build_access_config(req: Option<&Value>) -> Value {
3621    let mode = req
3622        .and_then(|v| v.get("authenticationMode"))
3623        .and_then(|v| v.as_str())
3624        .unwrap_or("CONFIG_MAP")
3625        .to_string();
3626    json!({ "authenticationMode": mode })
3627}
3628
3629/// Build an `UpgradePolicyResponse` object, defaulting `supportType` to
3630/// `EXTENDED` (extended support enabled) when the caller omits `upgradePolicy`.
3631fn build_upgrade_policy(req: Option<&Value>) -> Value {
3632    let support = req
3633        .and_then(|v| v.get("supportType"))
3634        .and_then(|v| v.as_str())
3635        .unwrap_or("EXTENDED")
3636        .to_string();
3637    json!({ "supportType": support })
3638}
3639
3640fn build_logging(req: Option<&Value>) -> Value {
3641    // If the caller supplies a Logging block, echo its clusterLogging; else
3642    // default to all types disabled (what AWS reports for a fresh cluster).
3643    if let Some(setups) = req
3644        .and_then(|v| v.get("clusterLogging"))
3645        .and_then(|v| v.as_array())
3646    {
3647        return json!({ "clusterLogging": setups });
3648    }
3649    json!({
3650        "clusterLogging": [{
3651            "types": LOG_TYPES,
3652            "enabled": false,
3653        }],
3654    })
3655}
3656
3657fn cluster_json(c: &Cluster, id: &str) -> Value {
3658    let mut out = json!({
3659        "name": c.name,
3660        "arn": c.arn,
3661        "createdAt": timestamp_to_number(c.created_at),
3662        "version": c.version,
3663        "endpoint": c.endpoint,
3664        "roleArn": c.role_arn,
3665        "resourcesVpcConfig": c.resources_vpc_config,
3666        "kubernetesNetworkConfig": c.kubernetes_network_config,
3667        "logging": c.logging,
3668        "identity": {
3669            "oidc": {
3670                "issuer": format!("https://oidc.eks.amazonaws.com/id/{}", id.to_uppercase()),
3671            },
3672        },
3673        "status": c.status,
3674        "certificateAuthority": { "data": c.certificate_authority_data },
3675        "platformVersion": c.platform_version,
3676        "tags": c.tags,
3677        "health": { "issues": [] },
3678        "accessConfig": c.access_config,
3679        "upgradePolicy": c.upgrade_policy,
3680    });
3681    if let Some(cc) = &c.connector_config {
3682        out["connectorConfig"] = cc.clone();
3683    }
3684    if let Some(ec) = &c.encryption_config {
3685        out["encryptionConfig"] = ec.clone();
3686    }
3687    out
3688}
3689
3690fn update_json(u: &Update) -> Value {
3691    let params: Vec<Value> = u
3692        .params
3693        .iter()
3694        .map(|(t, v)| json!({ "type": t, "value": v }))
3695        .collect();
3696    json!({
3697        "id": u.id,
3698        "status": u.status,
3699        "type": u.type_,
3700        "params": params,
3701        "createdAt": timestamp_to_number(u.created_at),
3702        "errors": [],
3703    })
3704}
3705
3706/// Build a `NodegroupScalingConfig`, defaulting to AWS's create-time defaults
3707/// (min 1 / max 2 / desired 2) for any member the caller omits.
3708fn build_scaling_config(req: Option<&Value>) -> Value {
3709    let min = req
3710        .and_then(|v| v.get("minSize"))
3711        .and_then(|v| v.as_i64())
3712        .unwrap_or(1);
3713    let max = req
3714        .and_then(|v| v.get("maxSize"))
3715        .and_then(|v| v.as_i64())
3716        .unwrap_or(2);
3717    let desired = req
3718        .and_then(|v| v.get("desiredSize"))
3719        .and_then(|v| v.as_i64())
3720        .unwrap_or(2);
3721    json!({ "minSize": min, "maxSize": max, "desiredSize": desired })
3722}
3723
3724/// Build a `NodegroupUpdateConfig`, defaulting `maxUnavailable` to 1.
3725fn build_nodegroup_update_config(req: Option<&Value>) -> Value {
3726    if let Some(pct) = req
3727        .and_then(|v| v.get("maxUnavailablePercentage"))
3728        .and_then(|v| v.as_i64())
3729    {
3730        return json!({ "maxUnavailablePercentage": pct });
3731    }
3732    let max_unavailable = req
3733        .and_then(|v| v.get("maxUnavailable"))
3734        .and_then(|v| v.as_i64())
3735        .unwrap_or(1);
3736    json!({ "maxUnavailable": max_unavailable })
3737}
3738
3739fn nodegroup_json(n: &Nodegroup) -> Value {
3740    let mut out = json!({
3741        "nodegroupName": n.name,
3742        "nodegroupArn": n.arn,
3743        "clusterName": n.cluster_name,
3744        "version": n.version,
3745        "releaseVersion": n.release_version,
3746        "createdAt": timestamp_to_number(n.created_at),
3747        "modifiedAt": timestamp_to_number(n.modified_at),
3748        "status": n.status,
3749        "capacityType": n.capacity_type,
3750        "scalingConfig": n.scaling_config,
3751        "instanceTypes": n.instance_types,
3752        "subnets": n.subnets,
3753        "amiType": n.ami_type,
3754        "nodeRole": n.node_role,
3755        "labels": n.labels,
3756        "taints": n.taints,
3757        "resources": {
3758            "autoScalingGroups": [{ "name": n.asg_name }],
3759        },
3760        "diskSize": n.disk_size,
3761        "health": { "issues": [] },
3762        "updateConfig": n.update_config,
3763        "tags": n.tags,
3764    });
3765    if let Some(ra) = &n.remote_access {
3766        out["remoteAccess"] = ra.clone();
3767    }
3768    if let Some(lt) = &n.launch_template {
3769        out["launchTemplate"] = lt.clone();
3770    }
3771    out
3772}
3773
3774fn fargate_profile_json(p: &FargateProfile) -> Value {
3775    json!({
3776        "fargateProfileName": p.name,
3777        "fargateProfileArn": p.arn,
3778        "clusterName": p.cluster_name,
3779        "createdAt": timestamp_to_number(p.created_at),
3780        "podExecutionRoleArn": p.pod_execution_role_arn,
3781        "subnets": p.subnets,
3782        "selectors": p.selectors,
3783        "status": p.status,
3784        "tags": p.tags,
3785        "health": { "issues": [] },
3786    })
3787}
3788
3789/// The AWS default add-on version for a well-known add-on at a given cluster
3790/// version. Falls back to a generic `v1.0.0-eksbuild.1` for unknown add-ons so
3791/// CreateAddon always echoes a plausible version even without an explicit one.
3792fn default_addon_version(addon_name: &str, _cluster_version: &str) -> String {
3793    match addon_name {
3794        "vpc-cni" => "v1.18.3-eksbuild.2".to_string(),
3795        "coredns" => "v1.11.1-eksbuild.9".to_string(),
3796        "kube-proxy" => "v1.31.0-eksbuild.2".to_string(),
3797        "aws-ebs-csi-driver" => "v1.35.0-eksbuild.1".to_string(),
3798        "aws-efs-csi-driver" => "v2.1.0-eksbuild.1".to_string(),
3799        _ => "v1.0.0-eksbuild.1".to_string(),
3800    }
3801}
3802
3803/// Turn the request's `podIdentityAssociations` (structs of
3804/// `{serviceAccount, roleArn}`) into the association ARNs echoed back on the
3805/// add-on as a StringList.
3806fn build_pod_identity_association_arns(
3807    region: &str,
3808    account_id: &str,
3809    cluster: &str,
3810    req: Option<&Value>,
3811) -> Vec<String> {
3812    let Some(list) = req.and_then(|v| v.as_array()) else {
3813        return Vec::new();
3814    };
3815    list.iter()
3816        .map(|_| {
3817            let id = uuid::Uuid::new_v4().to_string().replace('-', "");
3818            pod_identity_association_arn(region, account_id, cluster, &id[..17.min(id.len())])
3819        })
3820        .collect()
3821}
3822
3823fn addon_json(a: &Addon) -> Value {
3824    let mut out = json!({
3825        "addonName": a.name,
3826        "clusterName": a.cluster_name,
3827        "status": a.status,
3828        "addonVersion": a.addon_version,
3829        "addonArn": a.arn,
3830        "createdAt": timestamp_to_number(a.created_at),
3831        "modifiedAt": timestamp_to_number(a.modified_at),
3832        "tags": a.tags,
3833        "health": { "issues": [] },
3834    });
3835    if let Some(role) = &a.service_account_role_arn {
3836        out["serviceAccountRoleArn"] = Value::String(role.clone());
3837    }
3838    if let Some(cfg) = &a.configuration_values {
3839        out["configurationValues"] = Value::String(cfg.clone());
3840    }
3841    if let Some(ns) = &a.namespace {
3842        out["namespaceConfig"] = json!({ "namespace": ns });
3843    }
3844    if !a.pod_identity_associations.is_empty() {
3845        out["podIdentityAssociations"] = json!(a.pod_identity_associations);
3846    }
3847    out
3848}
3849
3850/// A single `AddonVersionInfo` catalog entry.
3851fn addon_version_info(version: &str, cluster_version: &str, requires_config: bool) -> Value {
3852    json!({
3853        "addonVersion": version,
3854        "architecture": ["amd64", "arm64"],
3855        "computeTypes": ["ec2", "fargate"],
3856        "compatibilities": [{
3857            "clusterVersion": cluster_version,
3858            "platformVersions": ["*"],
3859            "defaultVersion": true,
3860        }],
3861        "requiresConfiguration": requires_config,
3862        "requiresIamPermissions": false,
3863    })
3864}
3865
3866/// A plausible real-AWS add-on version catalog scoped to a cluster version,
3867/// returned by `DescribeAddonVersions`. Each entry is an `AddonInfo`.
3868fn addon_catalog(cluster_version: &str) -> Vec<Value> {
3869    let entry = |name: &str, atype: &str, ns: &str, requires_config: bool| -> Value {
3870        let versions = vec![addon_version_info(
3871            &default_addon_version(name, cluster_version),
3872            cluster_version,
3873            requires_config,
3874        )];
3875        json!({
3876            "addonName": name,
3877            "type": atype,
3878            "addonVersions": versions,
3879            "publisher": "eks",
3880            "owner": "aws",
3881            "defaultNamespace": ns,
3882        })
3883    };
3884    vec![
3885        entry("vpc-cni", "networking", "kube-system", false),
3886        entry("coredns", "networking", "kube-system", false),
3887        entry("kube-proxy", "networking", "kube-system", false),
3888        entry("aws-ebs-csi-driver", "storage", "kube-system", false),
3889        entry("aws-efs-csi-driver", "storage", "kube-system", false),
3890    ]
3891}
3892
3893/// A JSON-schema string describing the configuration accepted by an add-on,
3894/// returned by `DescribeAddonConfiguration`.
3895fn addon_configuration_schema(addon_name: &str) -> String {
3896    json!({
3897        "$schema": "http://json-schema.org/draft-07/schema#",
3898        "type": "object",
3899        "title": format!("{addon_name} configuration schema"),
3900        "additionalProperties": false,
3901        "properties": {
3902            "resources": {
3903                "type": "object",
3904                "additionalProperties": false,
3905                "properties": {
3906                    "limits": { "type": "object" },
3907                    "requests": { "type": "object" },
3908                },
3909            },
3910            "tolerations": { "type": "array" },
3911            "nodeSelector": { "type": "object" },
3912        },
3913    })
3914    .to_string()
3915}
3916
3917/// The recommended pod-identity configuration for an add-on, returned by
3918/// `DescribeAddonConfiguration` as `podIdentityConfiguration`.
3919fn pod_identity_configuration(addon_name: &str) -> Value {
3920    match addon_name {
3921        "vpc-cni" => json!([{
3922            "serviceAccount": "aws-node",
3923            "recommendedManagedPolicies": ["arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"],
3924        }]),
3925        "aws-ebs-csi-driver" => json!([{
3926            "serviceAccount": "ebs-csi-controller-sa",
3927            "recommendedManagedPolicies": ["arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"],
3928        }]),
3929        "aws-efs-csi-driver" => json!([{
3930            "serviceAccount": "efs-csi-controller-sa",
3931            "recommendedManagedPolicies": ["arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy"],
3932        }]),
3933        _ => json!([]),
3934    }
3935}
3936
3937/// Split a principal ARN into its access-entry `(type, name)` ARN segments.
3938/// `arn:aws:iam::123:role/Foo` -> `("role", "Foo")`; users map to `user`;
3939/// anything else (root, assumed-role, unrecognised) falls back to `standard`.
3940fn principal_parts(principal_arn: &str) -> (String, String) {
3941    let resource = principal_arn
3942        .split(':')
3943        .next_back()
3944        .unwrap_or(principal_arn);
3945    let (kind, name) = resource.split_once('/').unwrap_or(("", resource));
3946    let name = name.rsplit('/').next().unwrap_or(name);
3947    let type_seg = match kind {
3948        "role" | "assumed-role" => "role",
3949        "user" => "user",
3950        _ => "standard",
3951    };
3952    (type_seg.to_string(), name.to_string())
3953}
3954
3955/// The default `username` EKS derives for an access entry when the caller omits
3956/// one: `arn:aws:iam::123:role/Foo` -> a role SessionName template that mirrors
3957/// what AWS records for a role principal.
3958fn default_username(principal_arn: &str) -> String {
3959    let (type_seg, name) = principal_parts(principal_arn);
3960    match type_seg.as_str() {
3961        "role" => format!("arn:aws:sts::{{{{AccountID}}}}:assumed-role/{name}/{{{{SessionName}}}}"),
3962        _ => principal_arn.to_string(),
3963    }
3964}
3965
3966/// Parse a `StringList` request member into a `Vec<String>`.
3967fn string_list(v: Option<&Value>) -> Vec<String> {
3968    v.and_then(|v| v.as_array())
3969        .map(|arr| {
3970            arr.iter()
3971                .filter_map(|x| x.as_str().map(|s| s.to_string()))
3972                .collect()
3973        })
3974        .unwrap_or_default()
3975}
3976
3977/// Build an `AccessScope` response object, defaulting `type` to `cluster` (the
3978/// AWS default) and echoing any supplied namespaces.
3979fn build_access_scope(req: Option<&Value>) -> Value {
3980    let scope_type = req
3981        .and_then(|v| v.get("type"))
3982        .and_then(|v| v.as_str())
3983        .unwrap_or("cluster")
3984        .to_string();
3985    let namespaces = req
3986        .and_then(|v| v.get("namespaces"))
3987        .cloned()
3988        .unwrap_or_else(|| json!([]));
3989    json!({ "type": scope_type, "namespaces": namespaces })
3990}
3991
3992fn access_entry_json(e: &AccessEntry) -> Value {
3993    json!({
3994        "clusterName": e.cluster_name,
3995        "principalArn": e.principal_arn,
3996        "kubernetesGroups": e.kubernetes_groups,
3997        "accessEntryArn": e.arn,
3998        "createdAt": timestamp_to_number(e.created_at),
3999        "modifiedAt": timestamp_to_number(e.modified_at),
4000        "tags": e.tags,
4001        "username": e.username,
4002        "type": e.type_,
4003    })
4004}
4005
4006fn associated_policy_json(ap: &AssociatedPolicy) -> Value {
4007    json!({
4008        "policyArn": ap.policy_arn,
4009        "accessScope": ap.access_scope,
4010        "associatedAt": timestamp_to_number(ap.associated_at),
4011        "modifiedAt": timestamp_to_number(ap.modified_at),
4012    })
4013}
4014
4015/// Read an optional string member from a JSON object.
4016fn str_field(v: &Value, key: &str) -> Option<String> {
4017    v.get(key).and_then(|x| x.as_str()).map(|s| s.to_string())
4018}
4019
4020fn identity_provider_config_json(c: &IdentityProviderConfig) -> Value {
4021    let mut oidc = json!({
4022        "identityProviderConfigName": c.name,
4023        "identityProviderConfigArn": c.arn,
4024        "clusterName": c.cluster_name,
4025        "issuerUrl": c.issuer_url,
4026        "clientId": c.client_id,
4027        "requiredClaims": c.required_claims,
4028        "status": c.status,
4029        "tags": c.tags,
4030    });
4031    if let Some(v) = &c.username_claim {
4032        oidc["usernameClaim"] = Value::String(v.clone());
4033    }
4034    if let Some(v) = &c.username_prefix {
4035        oidc["usernamePrefix"] = Value::String(v.clone());
4036    }
4037    if let Some(v) = &c.groups_claim {
4038        oidc["groupsClaim"] = Value::String(v.clone());
4039    }
4040    if let Some(v) = &c.groups_prefix {
4041        oidc["groupsPrefix"] = Value::String(v.clone());
4042    }
4043    json!({ "oidc": oidc })
4044}
4045
4046fn pod_identity_association_json(a: &PodIdentityAssociation) -> Value {
4047    let mut out = json!({
4048        "clusterName": a.cluster_name,
4049        "namespace": a.namespace,
4050        "serviceAccount": a.service_account,
4051        "roleArn": a.role_arn,
4052        "associationArn": a.association_arn,
4053        "associationId": a.association_id,
4054        "createdAt": timestamp_to_number(a.created_at),
4055        "modifiedAt": timestamp_to_number(a.modified_at),
4056        "disableSessionTags": a.disable_session_tags,
4057        "tags": a.tags,
4058    });
4059    if let Some(v) = &a.target_role_arn {
4060        out["targetRoleArn"] = Value::String(v.clone());
4061    }
4062    if let Some(v) = &a.external_id {
4063        out["externalId"] = Value::String(v.clone());
4064    }
4065    out
4066}
4067
4068fn pod_identity_association_summary_json(a: &PodIdentityAssociation) -> Value {
4069    json!({
4070        "clusterName": a.cluster_name,
4071        "namespace": a.namespace,
4072        "serviceAccount": a.service_account,
4073        "associationArn": a.association_arn,
4074        "associationId": a.association_id,
4075    })
4076}
4077
4078/// The real AWS EKS cluster access-policy catalogue returned by
4079/// `ListAccessPolicies`. Every entry is an `arn:aws:eks::aws:cluster-access-policy/*`
4080/// managed policy.
4081fn access_policy_catalog() -> Vec<Value> {
4082    const POLICIES: &[&str] = &[
4083        "AmazonEKSClusterAdminPolicy",
4084        "AmazonEKSAdminPolicy",
4085        "AmazonEKSEditPolicy",
4086        "AmazonEKSViewPolicy",
4087        "AmazonEKSAdminViewPolicy",
4088        "AmazonEKSAutoNodePolicy",
4089        "AmazonEKSBlockStoragePolicy",
4090        "AmazonEKSLoadBalancingPolicy",
4091        "AmazonEKSNetworkingPolicy",
4092        "AmazonEKSComputePolicy",
4093    ];
4094    POLICIES
4095        .iter()
4096        .map(|name| {
4097            json!({
4098                "name": name,
4099                "arn": format!("arn:aws:eks::aws:cluster-access-policy/{name}"),
4100            })
4101        })
4102        .collect()
4103}
4104
4105fn not_found_insight(id: &str) -> impl Fn() -> AwsServiceError + 'static {
4106    let id = id.to_string();
4107    move || {
4108        AwsServiceError::aws_error(
4109            StatusCode::NOT_FOUND,
4110            "ResourceNotFoundException",
4111            format!("No insight found for id: {id}."),
4112        )
4113    }
4114}
4115
4116fn not_found_capability(name: &str) -> impl Fn() -> AwsServiceError + 'static {
4117    let name = name.to_string();
4118    move || {
4119        AwsServiceError::aws_error(
4120            StatusCode::NOT_FOUND,
4121            "ResourceNotFoundException",
4122            format!("No capability found for name: {name}."),
4123        )
4124    }
4125}
4126
4127fn not_found_subscription(id: &str) -> impl Fn() -> AwsServiceError + 'static {
4128    let id = id.to_string();
4129    move || {
4130        AwsServiceError::aws_error(
4131            StatusCode::NOT_FOUND,
4132            "ResourceNotFoundException",
4133            format!("No EKS Anywhere subscription found for id: {id}."),
4134        )
4135    }
4136}
4137
4138/// Render a connected (registered) cluster. Same shape as `cluster_json` but
4139/// tolerant of the empty fields a not-yet-activated connected cluster carries.
4140fn connected_cluster_json(c: &Cluster, id: &str) -> Value {
4141    cluster_json(c, id)
4142}
4143
4144/// A set of plausible upgrade-readiness Insights EKS auto-generates for a
4145/// cluster. All seed as `PASSING` (a healthy cluster) so List/Describe return
4146/// real, non-empty content. Ids are generated per seed so Describe round-trips.
4147fn default_insights(cluster_version: &str) -> Vec<Insight> {
4148    let now = Utc::now();
4149    let mk = |name: &str, category: &str, description: &str, recommendation: &str| Insight {
4150        id: uuid::Uuid::new_v4().to_string(),
4151        name: name.to_string(),
4152        category: category.to_string(),
4153        kubernetes_version: cluster_version.to_string(),
4154        description: description.to_string(),
4155        recommendation: recommendation.to_string(),
4156        status: "PASSING".to_string(),
4157        reason: "No deprecated API usage detected.".to_string(),
4158        last_refresh_time: now,
4159        last_transition_time: now,
4160    };
4161    vec![
4162        mk(
4163            "Deprecated APIs removed in Kubernetes",
4164            "UPGRADE_READINESS",
4165            "Checks for usage of Kubernetes APIs that are removed in the next version.",
4166            "Migrate any deprecated API usage to the supported apiVersion before upgrading.",
4167        ),
4168        mk(
4169            "Kubelet version skew",
4170            "UPGRADE_READINESS",
4171            "Checks that node kubelet versions are within the supported skew of the control plane.",
4172            "Upgrade node groups so kubelet stays within two minor versions of the control plane.",
4173        ),
4174        mk(
4175            "EKS add-on version compatibility",
4176            "UPGRADE_READINESS",
4177            "Checks that installed EKS add-on versions are compatible with the target cluster version.",
4178            "Update add-ons to a version compatible with the target Kubernetes version.",
4179        ),
4180    ]
4181}
4182
4183fn insight_status_json(i: &Insight) -> Value {
4184    json!({ "status": i.status, "reason": i.reason })
4185}
4186
4187fn insight_json(i: &Insight) -> Value {
4188    json!({
4189        "id": i.id,
4190        "name": i.name,
4191        "category": i.category,
4192        "kubernetesVersion": i.kubernetes_version,
4193        "lastRefreshTime": timestamp_to_number(i.last_refresh_time),
4194        "lastTransitionTime": timestamp_to_number(i.last_transition_time),
4195        "description": i.description,
4196        "insightStatus": insight_status_json(i),
4197        "recommendation": i.recommendation,
4198        "additionalInfo": {},
4199        "resources": [],
4200    })
4201}
4202
4203fn insight_summary_json(i: &Insight) -> Value {
4204    json!({
4205        "id": i.id,
4206        "name": i.name,
4207        "category": i.category,
4208        "kubernetesVersion": i.kubernetes_version,
4209        "lastRefreshTime": timestamp_to_number(i.last_refresh_time),
4210        "lastTransitionTime": timestamp_to_number(i.last_transition_time),
4211        "description": i.description,
4212        "insightStatus": insight_status_json(i),
4213    })
4214}
4215
4216fn insights_refresh_json(r: &InsightsRefresh) -> Value {
4217    let mut out = json!({
4218        "message": "Insights refresh for the cluster.",
4219        "status": r.status,
4220        "startedAt": timestamp_to_number(r.started_at),
4221    });
4222    if let Some(ended) = r.ended_at {
4223        out["endedAt"] = timestamp_to_number(ended);
4224    }
4225    out
4226}
4227
4228/// A real catalogue of recent Kubernetes minor versions with plausible
4229/// platform versions, release dates, and support windows, returned by
4230/// `DescribeClusterVersions`. 1.31 is the default.
4231fn cluster_version_catalog(cluster_type: &str) -> Vec<Value> {
4232    // (version, patch, platformVersion, releaseYmd, eoStandardYmd, eoExtendedYmd,
4233    //  status, default)
4234    type VersionRow = (
4235        &'static str,
4236        &'static str,
4237        &'static str,
4238        &'static str,
4239        &'static str,
4240        &'static str,
4241        &'static str,
4242        bool,
4243    );
4244    const ROWS: &[VersionRow] = &[
4245        (
4246            "1.28",
4247            "1.28.15",
4248            "eks.30",
4249            "2023-09-26",
4250            "2024-11-26",
4251            "2025-11-26",
4252            "extended_support",
4253            false,
4254        ),
4255        (
4256            "1.29",
4257            "1.29.10",
4258            "eks.24",
4259            "2024-01-23",
4260            "2025-03-23",
4261            "2026-03-23",
4262            "extended_support",
4263            false,
4264        ),
4265        (
4266            "1.30",
4267            "1.30.6",
4268            "eks.20",
4269            "2024-05-23",
4270            "2025-07-23",
4271            "2026-07-23",
4272            "standard_support",
4273            false,
4274        ),
4275        (
4276            "1.31",
4277            "1.31.2",
4278            "eks.9",
4279            "2024-09-26",
4280            "2025-11-26",
4281            "2026-11-26",
4282            "standard_support",
4283            true,
4284        ),
4285        (
4286            "1.32",
4287            "1.32.0",
4288            "eks.2",
4289            "2025-01-23",
4290            "2026-03-23",
4291            "2027-03-23",
4292            "standard_support",
4293            false,
4294        ),
4295    ];
4296    ROWS.iter()
4297        .map(
4298            |(ver, patch, plat, release, eos, eoe, status, is_default)| {
4299                let version_status = match *status {
4300                    "standard_support" => "STANDARD_SUPPORT",
4301                    "extended_support" => "EXTENDED_SUPPORT",
4302                    _ => "UNSUPPORTED",
4303                };
4304                json!({
4305                    "clusterVersion": ver,
4306                    "clusterType": cluster_type,
4307                    "defaultPlatformVersion": plat,
4308                    "defaultVersion": is_default,
4309                    "releaseDate": date_to_number(release),
4310                    "endOfStandardSupportDate": date_to_number(eos),
4311                    "endOfExtendedSupportDate": date_to_number(eoe),
4312                    "status": status,
4313                    "versionStatus": version_status,
4314                    "kubernetesPatchVersion": patch,
4315                })
4316            },
4317        )
4318        .collect()
4319}
4320
4321/// Parse a `YYYY-MM-DD` date into an epoch-seconds JSON number (midnight UTC).
4322fn date_to_number(ymd: &str) -> Value {
4323    let ts = chrono::NaiveDate::parse_from_str(ymd, "%Y-%m-%d")
4324        .ok()
4325        .and_then(|d| d.and_hms_opt(0, 0, 0))
4326        .map(|dt| dt.and_utc())
4327        .unwrap_or_else(Utc::now);
4328    timestamp_to_number(ts)
4329}
4330
4331/// Keep only the `argoCd` member of a capability configuration (the sole
4332/// member of `CapabilityConfigurationResponse`), so the echoed config stays
4333/// shape-faithful. Returns `None` when nothing recognisable is present.
4334fn normalize_capability_configuration(req: Option<&Value>) -> Option<Value> {
4335    let argo = req.and_then(|v| v.get("argoCd"))?;
4336    Some(json!({ "argoCd": argo }))
4337}
4338
4339fn capability_json(c: &Capability) -> Value {
4340    let mut out = json!({
4341        "capabilityName": c.name,
4342        "arn": c.arn,
4343        "clusterName": c.cluster_name,
4344        "type": c.type_,
4345        "roleArn": c.role_arn,
4346        "status": c.status,
4347        "version": c.version,
4348        "tags": c.tags,
4349        "health": { "issues": [] },
4350        "createdAt": timestamp_to_number(c.created_at),
4351        "modifiedAt": timestamp_to_number(c.modified_at),
4352    });
4353    if let Some(cfg) = &c.configuration {
4354        out["configuration"] = cfg.clone();
4355    }
4356    if let Some(p) = &c.delete_propagation_policy {
4357        out["deletePropagationPolicy"] = Value::String(p.clone());
4358    }
4359    out
4360}
4361
4362fn capability_summary_json(c: &Capability) -> Value {
4363    json!({
4364        "capabilityName": c.name,
4365        "arn": c.arn,
4366        "type": c.type_,
4367        "status": c.status,
4368        "version": c.version,
4369        "createdAt": timestamp_to_number(c.created_at),
4370        "modifiedAt": timestamp_to_number(c.modified_at),
4371    })
4372}
4373
4374fn subscription_json(s: &EksAnywhereSubscription) -> Value {
4375    json!({
4376        "id": s.id,
4377        "arn": s.arn,
4378        "createdAt": timestamp_to_number(s.created_at),
4379        "effectiveDate": timestamp_to_number(s.effective_date),
4380        "expirationDate": timestamp_to_number(s.expiration_date),
4381        "licenseQuantity": s.license_quantity,
4382        "licenseType": s.license_type,
4383        "term": { "duration": s.term_duration, "unit": s.term_unit },
4384        "status": s.status,
4385        "autoRenew": s.auto_renew,
4386        "licenseArns": [],
4387        "licenses": [],
4388        "tags": s.tags,
4389    })
4390}
4391
4392#[cfg(test)]
4393mod tests {
4394    use super::*;
4395    use bytes::Bytes;
4396    use http::HeaderMap;
4397    use parking_lot::RwLock;
4398    use std::collections::HashMap;
4399
4400    fn make_state() -> SharedEksState {
4401        Arc::new(RwLock::new(
4402            fakecloud_core::multi_account::MultiAccountState::new("111122223333", "us-east-1", ""),
4403        ))
4404    }
4405
4406    fn make_request(method: Method, path: &str, body: &str) -> AwsRequest {
4407        let (p, q) = match path.find('?') {
4408            Some(i) => (&path[..i], &path[i + 1..]),
4409            None => (path, ""),
4410        };
4411        let path_segments: Vec<String> = p
4412            .split('/')
4413            .filter(|s| !s.is_empty())
4414            .map(|s| s.to_string())
4415            .collect();
4416        let query_params: HashMap<String, String> = q
4417            .split('&')
4418            .filter(|s| !s.is_empty())
4419            .filter_map(|pair| {
4420                let (k, v) = pair.split_once('=')?;
4421                Some((k.to_string(), v.to_string()))
4422            })
4423            .collect();
4424        AwsRequest {
4425            service: "eks".to_string(),
4426            action: String::new(),
4427            region: "us-east-1".to_string(),
4428            account_id: "111122223333".to_string(),
4429            request_id: "test".to_string(),
4430            headers: HeaderMap::new(),
4431            query_params,
4432            body: Bytes::from(body.to_string()),
4433            body_stream: parking_lot::Mutex::new(None),
4434            path_segments,
4435            raw_path: p.to_string(),
4436            raw_query: q.to_string(),
4437            method,
4438            is_query_protocol: false,
4439            access_key_id: None,
4440            principal: None,
4441        }
4442    }
4443
4444    fn create_body(name: &str) -> String {
4445        json!({
4446            "name": name,
4447            "roleArn": "arn:aws:iam::111122223333:role/eks-cluster",
4448            "resourcesVpcConfig": {
4449                "subnetIds": ["subnet-1", "subnet-2"],
4450                "endpointPublicAccess": true
4451            }
4452        })
4453        .to_string()
4454    }
4455
4456    #[test]
4457    fn snapshot_hook_is_none_without_store() {
4458        let svc = EksService::new(make_state());
4459        assert!(svc.snapshot_hook().is_none());
4460    }
4461
4462    #[tokio::test]
4463    async fn snapshot_hook_fires_with_store() {
4464        let store: Arc<dyn SnapshotStore> =
4465            Arc::new(fakecloud_persistence::MemorySnapshotStore::new());
4466        let svc = EksService::new(make_state()).with_snapshot_store(store);
4467        let hook = svc.snapshot_hook().expect("hook present when store set");
4468        hook().await;
4469    }
4470
4471    #[tokio::test]
4472    async fn create_describe_round_trip_settles_active() {
4473        let svc = EksService::new(make_state());
4474        let resp = svc
4475            .handle(make_request(
4476                Method::POST,
4477                "/clusters",
4478                &create_body("demo"),
4479            ))
4480            .await
4481            .unwrap();
4482        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4483        assert_eq!(v["cluster"]["name"], "demo");
4484        assert_eq!(v["cluster"]["status"], "CREATING");
4485        assert_eq!(
4486            v["cluster"]["arn"],
4487            "arn:aws:eks:us-east-1:111122223333:cluster/demo"
4488        );
4489        assert_eq!(v["cluster"]["version"], DEFAULT_K8S_VERSION);
4490
4491        let resp = svc
4492            .handle(make_request(Method::GET, "/clusters/demo", ""))
4493            .await
4494            .unwrap();
4495        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4496        assert_eq!(v["cluster"]["status"], "ACTIVE");
4497        assert_eq!(
4498            v["cluster"]["resourcesVpcConfig"]["endpointPublicAccess"],
4499            true
4500        );
4501    }
4502
4503    #[tokio::test]
4504    async fn create_rejects_duplicate() {
4505        let svc = EksService::new(make_state());
4506        svc.handle(make_request(Method::POST, "/clusters", &create_body("dup")))
4507            .await
4508            .unwrap();
4509        let err = svc
4510            .handle(make_request(Method::POST, "/clusters", &create_body("dup")))
4511            .await
4512            .err()
4513            .unwrap();
4514        assert_eq!(err.status(), StatusCode::CONFLICT);
4515        assert_eq!(err.code(), "ResourceInUseException");
4516    }
4517
4518    #[tokio::test]
4519    async fn describe_missing_cluster_is_not_found() {
4520        let svc = EksService::new(make_state());
4521        let err = svc
4522            .handle(make_request(Method::GET, "/clusters/ghost", ""))
4523            .await
4524            .err()
4525            .unwrap();
4526        assert_eq!(err.status(), StatusCode::NOT_FOUND);
4527        assert_eq!(err.code(), "ResourceNotFoundException");
4528    }
4529
4530    #[tokio::test]
4531    async fn list_then_delete_cluster() {
4532        let svc = EksService::new(make_state());
4533        svc.handle(make_request(Method::POST, "/clusters", &create_body("c1")))
4534            .await
4535            .unwrap();
4536        let resp = svc
4537            .handle(make_request(Method::GET, "/clusters", ""))
4538            .await
4539            .unwrap();
4540        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4541        assert_eq!(v["clusters"], json!(["c1"]));
4542
4543        let resp = svc
4544            .handle(make_request(Method::DELETE, "/clusters/c1", ""))
4545            .await
4546            .unwrap();
4547        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4548        assert_eq!(v["cluster"]["status"], "DELETING");
4549
4550        let err = svc
4551            .handle(make_request(Method::GET, "/clusters/c1", ""))
4552            .await
4553            .err()
4554            .unwrap();
4555        assert_eq!(err.status(), StatusCode::NOT_FOUND);
4556    }
4557
4558    #[tokio::test]
4559    async fn cluster_reports_access_config_upgrade_policy_and_elb_defaults() {
4560        let svc = EksService::new(make_state());
4561        // Create with no accessConfig/upgradePolicy/kubernetesNetworkConfig: the
4562        // response defaults them the way the real control plane does.
4563        let resp = svc
4564            .handle(make_request(Method::POST, "/clusters", &create_body("c1")))
4565            .await
4566            .unwrap();
4567        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4568        let c = &v["cluster"];
4569        assert_eq!(c["accessConfig"]["authenticationMode"], "CONFIG_MAP");
4570        assert_eq!(c["upgradePolicy"]["supportType"], "EXTENDED");
4571        assert_eq!(
4572            c["kubernetesNetworkConfig"]["elasticLoadBalancing"]["enabled"],
4573            false
4574        );
4575        assert_eq!(
4576            c["kubernetesNetworkConfig"]["serviceIpv4Cidr"],
4577            "172.20.0.0/16"
4578        );
4579        assert_eq!(c["kubernetesNetworkConfig"]["ipFamily"], "ipv4");
4580
4581        // Describe echoes the same shape (drift-free round-trip).
4582        let resp = svc
4583            .handle(make_request(Method::GET, "/clusters/c1", ""))
4584            .await
4585            .unwrap();
4586        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4587        assert_eq!(
4588            v["cluster"]["accessConfig"]["authenticationMode"],
4589            "CONFIG_MAP"
4590        );
4591        assert_eq!(v["cluster"]["upgradePolicy"]["supportType"], "EXTENDED");
4592        assert_eq!(
4593            v["cluster"]["kubernetesNetworkConfig"]["elasticLoadBalancing"]["enabled"],
4594            false
4595        );
4596    }
4597
4598    #[tokio::test]
4599    async fn cluster_echoes_supplied_access_config() {
4600        let svc = EksService::new(make_state());
4601        let body = json!({
4602            "name": "c1",
4603            "roleArn": "arn:aws:iam::111122223333:role/eks-cluster",
4604            "resourcesVpcConfig": { "subnetIds": ["subnet-1", "subnet-2"] },
4605            "accessConfig": { "authenticationMode": "API" },
4606        })
4607        .to_string();
4608        let resp = svc
4609            .handle(make_request(Method::POST, "/clusters", &body))
4610            .await
4611            .unwrap();
4612        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4613        assert_eq!(v["cluster"]["accessConfig"]["authenticationMode"], "API");
4614    }
4615
4616    #[tokio::test]
4617    async fn update_version_and_describe_update() {
4618        let svc = EksService::new(make_state());
4619        svc.handle(make_request(Method::POST, "/clusters", &create_body("up")))
4620            .await
4621            .unwrap();
4622        let resp = svc
4623            .handle(make_request(
4624                Method::POST,
4625                "/clusters/up/updates",
4626                &json!({ "version": "1.32" }).to_string(),
4627            ))
4628            .await
4629            .unwrap();
4630        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4631        assert_eq!(v["update"]["type"], "VersionUpdate");
4632        assert_eq!(v["update"]["status"], "InProgress");
4633        let update_id = v["update"]["id"].as_str().unwrap().to_string();
4634
4635        let resp = svc
4636            .handle(make_request(
4637                Method::GET,
4638                &format!("/clusters/up/updates/{update_id}"),
4639                "",
4640            ))
4641            .await
4642            .unwrap();
4643        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4644        assert_eq!(v["update"]["status"], "Successful");
4645
4646        // The cluster's version reflects the update.
4647        let resp = svc
4648            .handle(make_request(Method::GET, "/clusters/up", ""))
4649            .await
4650            .unwrap();
4651        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4652        assert_eq!(v["cluster"]["version"], "1.32");
4653
4654        let resp = svc
4655            .handle(make_request(Method::GET, "/clusters/up/updates", ""))
4656            .await
4657            .unwrap();
4658        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4659        assert_eq!(v["updateIds"].as_array().unwrap().len(), 1);
4660    }
4661
4662    #[tokio::test]
4663    async fn tag_untag_round_trip() {
4664        let svc = EksService::new(make_state());
4665        svc.handle(make_request(Method::POST, "/clusters", &create_body("tg")))
4666            .await
4667            .unwrap();
4668        let arn = "arn:aws:eks:us-east-1:111122223333:cluster/tg";
4669        let encoded = arn.replace('/', "%2F");
4670        svc.handle(make_request(
4671            Method::POST,
4672            &format!("/tags/{encoded}"),
4673            &json!({ "tags": { "env": "prod" } }).to_string(),
4674        ))
4675        .await
4676        .unwrap();
4677
4678        let resp = svc
4679            .handle(make_request(Method::GET, &format!("/tags/{encoded}"), ""))
4680            .await
4681            .unwrap();
4682        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4683        assert_eq!(v["tags"]["env"], "prod");
4684
4685        svc.handle(make_request(
4686            Method::DELETE,
4687            &format!("/tags/{encoded}?tagKeys=env"),
4688            "",
4689        ))
4690        .await
4691        .unwrap();
4692        let resp = svc
4693            .handle(make_request(Method::GET, &format!("/tags/{encoded}"), ""))
4694            .await
4695            .unwrap();
4696        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4697        assert_eq!(v["tags"], json!({}));
4698    }
4699
4700    // -----------------------------------------------------------------------
4701    // Node groups
4702    // -----------------------------------------------------------------------
4703
4704    fn nodegroup_body(name: &str) -> String {
4705        json!({
4706            "nodegroupName": name,
4707            "nodeRole": "arn:aws:iam::111122223333:role/eks-node",
4708            "subnets": ["subnet-1", "subnet-2"],
4709            "scalingConfig": { "minSize": 1, "maxSize": 3, "desiredSize": 2 },
4710            "instanceTypes": ["t3.large"],
4711            "labels": { "team": "core" }
4712        })
4713        .to_string()
4714    }
4715
4716    async fn create_cluster(svc: &EksService, name: &str) {
4717        svc.handle(make_request(Method::POST, "/clusters", &create_body(name)))
4718            .await
4719            .unwrap();
4720    }
4721
4722    #[tokio::test]
4723    async fn nodegroup_create_describe_list_delete() {
4724        let svc = EksService::new(make_state());
4725        create_cluster(&svc, "c1").await;
4726
4727        let resp = svc
4728            .handle(make_request(
4729                Method::POST,
4730                "/clusters/c1/node-groups",
4731                &nodegroup_body("ng1"),
4732            ))
4733            .await
4734            .unwrap();
4735        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4736        assert_eq!(v["nodegroup"]["nodegroupName"], "ng1");
4737        assert_eq!(v["nodegroup"]["status"], "CREATING");
4738        assert_eq!(v["nodegroup"]["clusterName"], "c1");
4739        assert_eq!(v["nodegroup"]["scalingConfig"]["maxSize"], 3);
4740        assert!(v["nodegroup"]["nodegroupArn"]
4741            .as_str()
4742            .unwrap()
4743            .starts_with("arn:aws:eks:us-east-1:111122223333:nodegroup/c1/ng1/"));
4744
4745        // Describe settles CREATING -> ACTIVE.
4746        let resp = svc
4747            .handle(make_request(
4748                Method::GET,
4749                "/clusters/c1/node-groups/ng1",
4750                "",
4751            ))
4752            .await
4753            .unwrap();
4754        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4755        assert_eq!(v["nodegroup"]["status"], "ACTIVE");
4756        assert_eq!(v["nodegroup"]["labels"]["team"], "core");
4757
4758        let resp = svc
4759            .handle(make_request(Method::GET, "/clusters/c1/node-groups", ""))
4760            .await
4761            .unwrap();
4762        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4763        assert_eq!(v["nodegroups"], json!(["ng1"]));
4764
4765        let resp = svc
4766            .handle(make_request(
4767                Method::DELETE,
4768                "/clusters/c1/node-groups/ng1",
4769                "",
4770            ))
4771            .await
4772            .unwrap();
4773        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4774        assert_eq!(v["nodegroup"]["status"], "DELETING");
4775
4776        let err = svc
4777            .handle(make_request(
4778                Method::GET,
4779                "/clusters/c1/node-groups/ng1",
4780                "",
4781            ))
4782            .await
4783            .err()
4784            .unwrap();
4785        assert_eq!(err.status(), StatusCode::NOT_FOUND);
4786        assert_eq!(err.code(), "ResourceNotFoundException");
4787    }
4788
4789    #[tokio::test]
4790    async fn nodegroup_on_missing_cluster_is_not_found() {
4791        let svc = EksService::new(make_state());
4792        let err = svc
4793            .handle(make_request(
4794                Method::POST,
4795                "/clusters/ghost/node-groups",
4796                &nodegroup_body("ng1"),
4797            ))
4798            .await
4799            .err()
4800            .unwrap();
4801        assert_eq!(err.status(), StatusCode::NOT_FOUND);
4802        assert_eq!(err.code(), "ResourceNotFoundException");
4803    }
4804
4805    #[tokio::test]
4806    async fn nodegroup_duplicate_is_in_use() {
4807        let svc = EksService::new(make_state());
4808        create_cluster(&svc, "c1").await;
4809        svc.handle(make_request(
4810            Method::POST,
4811            "/clusters/c1/node-groups",
4812            &nodegroup_body("ng1"),
4813        ))
4814        .await
4815        .unwrap();
4816        let err = svc
4817            .handle(make_request(
4818                Method::POST,
4819                "/clusters/c1/node-groups",
4820                &nodegroup_body("ng1"),
4821            ))
4822            .await
4823            .err()
4824            .unwrap();
4825        assert_eq!(err.status(), StatusCode::CONFLICT);
4826        assert_eq!(err.code(), "ResourceInUseException");
4827    }
4828
4829    #[tokio::test]
4830    async fn nodegroup_cross_cluster_isolation() {
4831        let svc = EksService::new(make_state());
4832        create_cluster(&svc, "c1").await;
4833        create_cluster(&svc, "c2").await;
4834        svc.handle(make_request(
4835            Method::POST,
4836            "/clusters/c1/node-groups",
4837            &nodegroup_body("ng1"),
4838        ))
4839        .await
4840        .unwrap();
4841
4842        // c2 sees no node groups, and can't describe c1's node group.
4843        let resp = svc
4844            .handle(make_request(Method::GET, "/clusters/c2/node-groups", ""))
4845            .await
4846            .unwrap();
4847        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4848        assert_eq!(v["nodegroups"], json!([]));
4849
4850        let err = svc
4851            .handle(make_request(
4852                Method::GET,
4853                "/clusters/c2/node-groups/ng1",
4854                "",
4855            ))
4856            .await
4857            .err()
4858            .unwrap();
4859        assert_eq!(err.code(), "ResourceNotFoundException");
4860    }
4861
4862    #[tokio::test]
4863    async fn nodegroup_update_config_and_version_have_own_updates() {
4864        let svc = EksService::new(make_state());
4865        create_cluster(&svc, "c1").await;
4866        svc.handle(make_request(
4867            Method::POST,
4868            "/clusters/c1/node-groups",
4869            &nodegroup_body("ng1"),
4870        ))
4871        .await
4872        .unwrap();
4873
4874        // UpdateNodegroupVersion returns an Update.
4875        let resp = svc
4876            .handle(make_request(
4877                Method::POST,
4878                "/clusters/c1/node-groups/ng1/update-version",
4879                &json!({ "version": "1.30" }).to_string(),
4880            ))
4881            .await
4882            .unwrap();
4883        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4884        assert_eq!(v["update"]["type"], "VersionUpdate");
4885        let update_id = v["update"]["id"].as_str().unwrap().to_string();
4886
4887        // UpdateNodegroupConfig returns another Update.
4888        svc.handle(make_request(
4889            Method::POST,
4890            "/clusters/c1/node-groups/ng1/update-config",
4891            &json!({ "scalingConfig": { "minSize": 2, "maxSize": 5, "desiredSize": 3 } })
4892                .to_string(),
4893        ))
4894        .await
4895        .unwrap();
4896
4897        // ListUpdates with nodegroupName returns the node group's updates.
4898        let resp = svc
4899            .handle(make_request(
4900                Method::GET,
4901                "/clusters/c1/updates?nodegroupName=ng1",
4902                "",
4903            ))
4904            .await
4905            .unwrap();
4906        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4907        assert_eq!(v["updateIds"].as_array().unwrap().len(), 2);
4908
4909        // Cluster-scoped ListUpdates (no nodegroupName) is empty.
4910        let resp = svc
4911            .handle(make_request(Method::GET, "/clusters/c1/updates", ""))
4912            .await
4913            .unwrap();
4914        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4915        assert_eq!(v["updateIds"].as_array().unwrap().len(), 0);
4916
4917        // DescribeUpdate with nodegroupName settles the node group update.
4918        let resp = svc
4919            .handle(make_request(
4920                Method::GET,
4921                &format!("/clusters/c1/updates/{update_id}?nodegroupName=ng1"),
4922                "",
4923            ))
4924            .await
4925            .unwrap();
4926        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4927        assert_eq!(v["update"]["status"], "Successful");
4928
4929        // The node group reflects the new version and scaling config.
4930        let resp = svc
4931            .handle(make_request(
4932                Method::GET,
4933                "/clusters/c1/node-groups/ng1",
4934                "",
4935            ))
4936            .await
4937            .unwrap();
4938        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4939        assert_eq!(v["nodegroup"]["version"], "1.30");
4940        assert_eq!(v["nodegroup"]["scalingConfig"]["maxSize"], 5);
4941    }
4942
4943    // -----------------------------------------------------------------------
4944    // Fargate profiles
4945    // -----------------------------------------------------------------------
4946
4947    fn fargate_body(name: &str) -> String {
4948        json!({
4949            "fargateProfileName": name,
4950            "podExecutionRoleArn": "arn:aws:iam::111122223333:role/eks-fargate",
4951            "subnets": ["subnet-1"],
4952            "selectors": [{ "namespace": "default", "labels": { "app": "web" } }]
4953        })
4954        .to_string()
4955    }
4956
4957    #[tokio::test]
4958    async fn fargate_create_describe_list_delete() {
4959        let svc = EksService::new(make_state());
4960        create_cluster(&svc, "c1").await;
4961
4962        let resp = svc
4963            .handle(make_request(
4964                Method::POST,
4965                "/clusters/c1/fargate-profiles",
4966                &fargate_body("fp1"),
4967            ))
4968            .await
4969            .unwrap();
4970        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4971        assert_eq!(v["fargateProfile"]["fargateProfileName"], "fp1");
4972        assert_eq!(v["fargateProfile"]["status"], "CREATING");
4973        assert!(v["fargateProfile"]["fargateProfileArn"]
4974            .as_str()
4975            .unwrap()
4976            .starts_with("arn:aws:eks:us-east-1:111122223333:fargateprofile/c1/fp1/"));
4977
4978        let resp = svc
4979            .handle(make_request(
4980                Method::GET,
4981                "/clusters/c1/fargate-profiles/fp1",
4982                "",
4983            ))
4984            .await
4985            .unwrap();
4986        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4987        assert_eq!(v["fargateProfile"]["status"], "ACTIVE");
4988        assert_eq!(v["fargateProfile"]["selectors"][0]["namespace"], "default");
4989
4990        let resp = svc
4991            .handle(make_request(
4992                Method::GET,
4993                "/clusters/c1/fargate-profiles",
4994                "",
4995            ))
4996            .await
4997            .unwrap();
4998        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
4999        assert_eq!(v["fargateProfileNames"], json!(["fp1"]));
5000
5001        let resp = svc
5002            .handle(make_request(
5003                Method::DELETE,
5004                "/clusters/c1/fargate-profiles/fp1",
5005                "",
5006            ))
5007            .await
5008            .unwrap();
5009        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5010        assert_eq!(v["fargateProfile"]["status"], "DELETING");
5011
5012        let err = svc
5013            .handle(make_request(
5014                Method::GET,
5015                "/clusters/c1/fargate-profiles/fp1",
5016                "",
5017            ))
5018            .await
5019            .err()
5020            .unwrap();
5021        assert_eq!(err.code(), "ResourceNotFoundException");
5022    }
5023
5024    #[tokio::test]
5025    async fn fargate_on_missing_cluster_is_not_found() {
5026        let svc = EksService::new(make_state());
5027        let err = svc
5028            .handle(make_request(
5029                Method::POST,
5030                "/clusters/ghost/fargate-profiles",
5031                &fargate_body("fp1"),
5032            ))
5033            .await
5034            .err()
5035            .unwrap();
5036        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5037        assert_eq!(err.code(), "ResourceNotFoundException");
5038    }
5039
5040    #[tokio::test]
5041    async fn fargate_duplicate_is_in_use() {
5042        let svc = EksService::new(make_state());
5043        create_cluster(&svc, "c1").await;
5044        svc.handle(make_request(
5045            Method::POST,
5046            "/clusters/c1/fargate-profiles",
5047            &fargate_body("fp1"),
5048        ))
5049        .await
5050        .unwrap();
5051        let err = svc
5052            .handle(make_request(
5053                Method::POST,
5054                "/clusters/c1/fargate-profiles",
5055                &fargate_body("fp1"),
5056            ))
5057            .await
5058            .err()
5059            .unwrap();
5060        assert_eq!(err.status(), StatusCode::CONFLICT);
5061        assert_eq!(err.code(), "ResourceInUseException");
5062    }
5063
5064    #[tokio::test]
5065    async fn unimplemented_subresource_falls_through() {
5066        // An unknown sub-resource collection must not accidentally match via
5067        // the node-group/fargate-profile/addon/access-entry/idp/pod-identity
5068        // arms; the router falls through to UnknownOperationException.
5069        let svc = EksService::new(make_state());
5070        create_cluster(&svc, "c1").await;
5071        let err = svc
5072            .handle(make_request(Method::GET, "/clusters/c1/insights", ""))
5073            .await
5074            .err()
5075            .unwrap();
5076        assert_eq!(err.code(), "UnknownOperationException");
5077    }
5078
5079    // -----------------------------------------------------------------------
5080    // Add-ons
5081    // -----------------------------------------------------------------------
5082
5083    fn addon_body(name: &str) -> String {
5084        json!({
5085            "addonName": name,
5086            "addonVersion": "v1.18.3-eksbuild.2",
5087            "serviceAccountRoleArn": "arn:aws:iam::111122223333:role/eks-addon",
5088            "configurationValues": "{\"replicaCount\":2}",
5089            "tags": { "team": "core" }
5090        })
5091        .to_string()
5092    }
5093
5094    #[tokio::test]
5095    async fn addon_create_describe_list_update_delete() {
5096        let svc = EksService::new(make_state());
5097        create_cluster(&svc, "c1").await;
5098
5099        let resp = svc
5100            .handle(make_request(
5101                Method::POST,
5102                "/clusters/c1/addons",
5103                &addon_body("vpc-cni"),
5104            ))
5105            .await
5106            .unwrap();
5107        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5108        assert_eq!(v["addon"]["addonName"], "vpc-cni");
5109        assert_eq!(v["addon"]["status"], "CREATING");
5110        assert_eq!(v["addon"]["clusterName"], "c1");
5111        assert_eq!(v["addon"]["addonVersion"], "v1.18.3-eksbuild.2");
5112        assert_eq!(v["addon"]["configurationValues"], "{\"replicaCount\":2}");
5113        assert!(v["addon"]["addonArn"]
5114            .as_str()
5115            .unwrap()
5116            .starts_with("arn:aws:eks:us-east-1:111122223333:addon/c1/vpc-cni/"));
5117
5118        // Describe settles CREATING -> ACTIVE.
5119        let resp = svc
5120            .handle(make_request(Method::GET, "/clusters/c1/addons/vpc-cni", ""))
5121            .await
5122            .unwrap();
5123        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5124        assert_eq!(v["addon"]["status"], "ACTIVE");
5125        assert_eq!(v["addon"]["tags"]["team"], "core");
5126
5127        let resp = svc
5128            .handle(make_request(Method::GET, "/clusters/c1/addons", ""))
5129            .await
5130            .unwrap();
5131        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5132        assert_eq!(v["addons"], json!(["vpc-cni"]));
5133
5134        // UpdateAddon mints a tracked Update, discoverable via addonName.
5135        let resp = svc
5136            .handle(make_request(
5137                Method::POST,
5138                "/clusters/c1/addons/vpc-cni/update",
5139                &json!({ "addonVersion": "v1.18.5-eksbuild.1" }).to_string(),
5140            ))
5141            .await
5142            .unwrap();
5143        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5144        assert_eq!(v["update"]["type"], "AddonUpdate");
5145        assert_eq!(v["update"]["status"], "InProgress");
5146        let update_id = v["update"]["id"].as_str().unwrap().to_string();
5147
5148        let resp = svc
5149            .handle(make_request(
5150                Method::GET,
5151                &format!("/clusters/c1/updates/{update_id}?addonName=vpc-cni"),
5152                "",
5153            ))
5154            .await
5155            .unwrap();
5156        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5157        assert_eq!(v["update"]["status"], "Successful");
5158
5159        // Addon reflects the new version.
5160        let resp = svc
5161            .handle(make_request(Method::GET, "/clusters/c1/addons/vpc-cni", ""))
5162            .await
5163            .unwrap();
5164        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5165        assert_eq!(v["addon"]["addonVersion"], "v1.18.5-eksbuild.1");
5166
5167        // ListUpdates with addonName sees the update.
5168        let resp = svc
5169            .handle(make_request(
5170                Method::GET,
5171                "/clusters/c1/updates?addonName=vpc-cni",
5172                "",
5173            ))
5174            .await
5175            .unwrap();
5176        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5177        assert_eq!(v["updateIds"].as_array().unwrap().len(), 1);
5178
5179        // Delete.
5180        let resp = svc
5181            .handle(make_request(
5182                Method::DELETE,
5183                "/clusters/c1/addons/vpc-cni",
5184                "",
5185            ))
5186            .await
5187            .unwrap();
5188        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5189        assert_eq!(v["addon"]["status"], "DELETING");
5190
5191        let err = svc
5192            .handle(make_request(Method::GET, "/clusters/c1/addons/vpc-cni", ""))
5193            .await
5194            .err()
5195            .unwrap();
5196        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5197        assert_eq!(err.code(), "ResourceNotFoundException");
5198    }
5199
5200    #[tokio::test]
5201    async fn addon_on_missing_cluster_is_not_found() {
5202        let svc = EksService::new(make_state());
5203        let err = svc
5204            .handle(make_request(
5205                Method::POST,
5206                "/clusters/ghost/addons",
5207                &addon_body("coredns"),
5208            ))
5209            .await
5210            .err()
5211            .unwrap();
5212        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5213        assert_eq!(err.code(), "ResourceNotFoundException");
5214    }
5215
5216    #[tokio::test]
5217    async fn addon_duplicate_is_in_use() {
5218        let svc = EksService::new(make_state());
5219        create_cluster(&svc, "c1").await;
5220        svc.handle(make_request(
5221            Method::POST,
5222            "/clusters/c1/addons",
5223            &addon_body("coredns"),
5224        ))
5225        .await
5226        .unwrap();
5227        let err = svc
5228            .handle(make_request(
5229                Method::POST,
5230                "/clusters/c1/addons",
5231                &addon_body("coredns"),
5232            ))
5233            .await
5234            .err()
5235            .unwrap();
5236        assert_eq!(err.status(), StatusCode::CONFLICT);
5237        assert_eq!(err.code(), "ResourceInUseException");
5238    }
5239
5240    #[tokio::test]
5241    async fn describe_addon_versions_catalog_is_non_empty() {
5242        let svc = EksService::new(make_state());
5243        let resp = svc
5244            .handle(make_request(Method::GET, "/addons/supported-versions", ""))
5245            .await
5246            .unwrap();
5247        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5248        let addons = v["addons"].as_array().unwrap();
5249        assert!(addons.len() >= 5);
5250        let names: Vec<&str> = addons
5251            .iter()
5252            .map(|a| a["addonName"].as_str().unwrap())
5253            .collect();
5254        assert!(names.contains(&"vpc-cni"));
5255        assert!(names.contains(&"coredns"));
5256        assert!(!addons[0]["addonVersions"].as_array().unwrap().is_empty());
5257
5258        // Filtered by addonName.
5259        let resp = svc
5260            .handle(make_request(
5261                Method::GET,
5262                "/addons/supported-versions?addonName=coredns",
5263                "",
5264            ))
5265            .await
5266            .unwrap();
5267        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5268        let addons = v["addons"].as_array().unwrap();
5269        assert_eq!(addons.len(), 1);
5270        assert_eq!(addons[0]["addonName"], "coredns");
5271    }
5272
5273    #[tokio::test]
5274    async fn describe_addon_configuration_returns_schema() {
5275        let svc = EksService::new(make_state());
5276        let resp = svc
5277            .handle(make_request(
5278                Method::GET,
5279                "/addons/configuration-schemas?addonName=vpc-cni&addonVersion=v1.18.3-eksbuild.2",
5280                "",
5281            ))
5282            .await
5283            .unwrap();
5284        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5285        assert_eq!(v["addonName"], "vpc-cni");
5286        assert_eq!(v["addonVersion"], "v1.18.3-eksbuild.2");
5287        assert!(v["configurationSchema"]
5288            .as_str()
5289            .unwrap()
5290            .contains("$schema"));
5291        assert_eq!(
5292            v["podIdentityConfiguration"][0]["serviceAccount"],
5293            "aws-node"
5294        );
5295    }
5296
5297    // -----------------------------------------------------------------------
5298    // Access entries / access policies
5299    // -----------------------------------------------------------------------
5300
5301    const PRINCIPAL: &str = "arn:aws:iam::111122223333:role/dev";
5302
5303    fn access_entry_body() -> String {
5304        json!({
5305            "principalArn": PRINCIPAL,
5306            "kubernetesGroups": ["viewers"],
5307            "tags": { "team": "core" }
5308        })
5309        .to_string()
5310    }
5311
5312    fn url_encode(s: &str) -> String {
5313        s.replace('%', "%25")
5314            .replace(':', "%3A")
5315            .replace('/', "%2F")
5316    }
5317
5318    fn encoded_principal() -> String {
5319        url_encode(PRINCIPAL)
5320    }
5321
5322    #[tokio::test]
5323    async fn access_entry_full_lifecycle() {
5324        let svc = EksService::new(make_state());
5325        create_cluster(&svc, "c1").await;
5326
5327        // Create.
5328        let resp = svc
5329            .handle(make_request(
5330                Method::POST,
5331                "/clusters/c1/access-entries",
5332                &access_entry_body(),
5333            ))
5334            .await
5335            .unwrap();
5336        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5337        assert_eq!(v["accessEntry"]["principalArn"], PRINCIPAL);
5338        assert_eq!(v["accessEntry"]["clusterName"], "c1");
5339        assert_eq!(v["accessEntry"]["type"], "STANDARD");
5340        assert_eq!(v["accessEntry"]["kubernetesGroups"], json!(["viewers"]));
5341        assert_eq!(v["accessEntry"]["tags"]["team"], "core");
5342        assert!(v["accessEntry"]["accessEntryArn"]
5343            .as_str()
5344            .unwrap()
5345            .starts_with("arn:aws:eks:us-east-1:111122223333:access-entry/c1/role/"));
5346        assert!(v["accessEntry"]["username"]
5347            .as_str()
5348            .unwrap()
5349            .contains("dev"));
5350
5351        let enc = encoded_principal();
5352
5353        // Describe.
5354        let resp = svc
5355            .handle(make_request(
5356                Method::GET,
5357                &format!("/clusters/c1/access-entries/{enc}"),
5358                "",
5359            ))
5360            .await
5361            .unwrap();
5362        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5363        assert_eq!(v["accessEntry"]["principalArn"], PRINCIPAL);
5364
5365        // List.
5366        let resp = svc
5367            .handle(make_request(Method::GET, "/clusters/c1/access-entries", ""))
5368            .await
5369            .unwrap();
5370        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5371        assert_eq!(v["accessEntries"], json!([PRINCIPAL]));
5372
5373        // Update.
5374        let resp = svc
5375            .handle(make_request(
5376                Method::POST,
5377                &format!("/clusters/c1/access-entries/{enc}"),
5378                &json!({ "kubernetesGroups": ["admins"], "username": "custom" }).to_string(),
5379            ))
5380            .await
5381            .unwrap();
5382        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5383        assert_eq!(v["accessEntry"]["kubernetesGroups"], json!(["admins"]));
5384        assert_eq!(v["accessEntry"]["username"], "custom");
5385
5386        // Associate an access policy.
5387        let policy = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy";
5388        let resp = svc
5389            .handle(make_request(
5390                Method::POST,
5391                &format!("/clusters/c1/access-entries/{enc}/access-policies"),
5392                &json!({
5393                    "policyArn": policy,
5394                    "accessScope": { "type": "namespace", "namespaces": ["default"] }
5395                })
5396                .to_string(),
5397            ))
5398            .await
5399            .unwrap();
5400        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5401        assert_eq!(v["clusterName"], "c1");
5402        assert_eq!(v["principalArn"], PRINCIPAL);
5403        assert_eq!(v["associatedAccessPolicy"]["policyArn"], policy);
5404        assert_eq!(
5405            v["associatedAccessPolicy"]["accessScope"]["type"],
5406            "namespace"
5407        );
5408
5409        // List associated policies.
5410        let resp = svc
5411            .handle(make_request(
5412                Method::GET,
5413                &format!("/clusters/c1/access-entries/{enc}/access-policies"),
5414                "",
5415            ))
5416            .await
5417            .unwrap();
5418        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5419        assert_eq!(v["clusterName"], "c1");
5420        assert_eq!(v["associatedAccessPolicies"].as_array().unwrap().len(), 1);
5421        assert_eq!(v["associatedAccessPolicies"][0]["policyArn"], policy);
5422
5423        // ListAccessEntries with associatedPolicyArn filter finds the entry.
5424        let resp = svc
5425            .handle(make_request(
5426                Method::GET,
5427                &format!("/clusters/c1/access-entries?associatedPolicyArn={policy}"),
5428                "",
5429            ))
5430            .await
5431            .unwrap();
5432        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5433        assert_eq!(v["accessEntries"], json!([PRINCIPAL]));
5434
5435        // Disassociate.
5436        let enc_policy = encoded_principal_of(policy);
5437        let resp = svc
5438            .handle(make_request(
5439                Method::DELETE,
5440                &format!("/clusters/c1/access-entries/{enc}/access-policies/{enc_policy}"),
5441                "",
5442            ))
5443            .await
5444            .unwrap();
5445        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5446        assert_eq!(v, json!({}));
5447
5448        let resp = svc
5449            .handle(make_request(
5450                Method::GET,
5451                &format!("/clusters/c1/access-entries/{enc}/access-policies"),
5452                "",
5453            ))
5454            .await
5455            .unwrap();
5456        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5457        assert_eq!(v["associatedAccessPolicies"].as_array().unwrap().len(), 0);
5458
5459        // Delete the entry.
5460        let resp = svc
5461            .handle(make_request(
5462                Method::DELETE,
5463                &format!("/clusters/c1/access-entries/{enc}"),
5464                "",
5465            ))
5466            .await
5467            .unwrap();
5468        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5469        assert_eq!(v, json!({}));
5470
5471        let err = svc
5472            .handle(make_request(
5473                Method::GET,
5474                &format!("/clusters/c1/access-entries/{enc}"),
5475                "",
5476            ))
5477            .await
5478            .err()
5479            .unwrap();
5480        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5481        assert_eq!(err.code(), "ResourceNotFoundException");
5482    }
5483
5484    fn encoded_principal_of(s: &str) -> String {
5485        url_encode(s)
5486    }
5487
5488    #[tokio::test]
5489    async fn access_entry_on_missing_cluster_is_not_found() {
5490        let svc = EksService::new(make_state());
5491        let err = svc
5492            .handle(make_request(
5493                Method::POST,
5494                "/clusters/ghost/access-entries",
5495                &access_entry_body(),
5496            ))
5497            .await
5498            .err()
5499            .unwrap();
5500        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5501        assert_eq!(err.code(), "ResourceNotFoundException");
5502    }
5503
5504    #[tokio::test]
5505    async fn access_entry_duplicate_is_in_use() {
5506        let svc = EksService::new(make_state());
5507        create_cluster(&svc, "c1").await;
5508        svc.handle(make_request(
5509            Method::POST,
5510            "/clusters/c1/access-entries",
5511            &access_entry_body(),
5512        ))
5513        .await
5514        .unwrap();
5515        let err = svc
5516            .handle(make_request(
5517                Method::POST,
5518                "/clusters/c1/access-entries",
5519                &access_entry_body(),
5520            ))
5521            .await
5522            .err()
5523            .unwrap();
5524        assert_eq!(err.status(), StatusCode::CONFLICT);
5525        assert_eq!(err.code(), "ResourceInUseException");
5526    }
5527
5528    #[tokio::test]
5529    async fn describe_access_entry_missing_is_not_found() {
5530        let svc = EksService::new(make_state());
5531        create_cluster(&svc, "c1").await;
5532        let err = svc
5533            .handle(make_request(
5534                Method::GET,
5535                &format!("/clusters/c1/access-entries/{}", encoded_principal()),
5536                "",
5537            ))
5538            .await
5539            .err()
5540            .unwrap();
5541        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5542        assert_eq!(err.code(), "ResourceNotFoundException");
5543    }
5544
5545    #[tokio::test]
5546    async fn list_access_policies_catalog_is_non_empty() {
5547        let svc = EksService::new(make_state());
5548        let resp = svc
5549            .handle(make_request(Method::GET, "/access-policies", ""))
5550            .await
5551            .unwrap();
5552        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5553        let policies = v["accessPolicies"].as_array().unwrap();
5554        assert!(policies.len() >= 10);
5555        let names: Vec<&str> = policies
5556            .iter()
5557            .map(|p| p["name"].as_str().unwrap())
5558            .collect();
5559        assert!(names.contains(&"AmazonEKSClusterAdminPolicy"));
5560        assert!(names.contains(&"AmazonEKSViewPolicy"));
5561        assert!(policies[0]["arn"]
5562            .as_str()
5563            .unwrap()
5564            .starts_with("arn:aws:eks::aws:cluster-access-policy/"));
5565    }
5566
5567    // -----------------------------------------------------------------------
5568    // Identity-provider configs
5569    // -----------------------------------------------------------------------
5570
5571    fn idp_body(name: &str) -> String {
5572        json!({
5573            "oidc": {
5574                "identityProviderConfigName": name,
5575                "issuerUrl": "https://example.com",
5576                "clientId": "kubernetes",
5577                "usernameClaim": "email",
5578                "groupsClaim": "groups"
5579            },
5580            "tags": { "team": "core" }
5581        })
5582        .to_string()
5583    }
5584
5585    #[tokio::test]
5586    async fn idp_associate_describe_list_disassociate() {
5587        let svc = EksService::new(make_state());
5588        create_cluster(&svc, "c1").await;
5589
5590        // Associate mints a tracked cluster-scoped Update and echoes tags.
5591        let resp = svc
5592            .handle(make_request(
5593                Method::POST,
5594                "/clusters/c1/identity-provider-configs/associate",
5595                &idp_body("oidc1"),
5596            ))
5597            .await
5598            .unwrap();
5599        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5600        assert_eq!(v["update"]["type"], "AssociateIdentityProviderConfig");
5601        assert_eq!(v["update"]["status"], "InProgress");
5602        assert_eq!(v["tags"]["team"], "core");
5603        let update_id = v["update"]["id"].as_str().unwrap().to_string();
5604
5605        // The Update is cluster-scoped and settles on DescribeUpdate.
5606        let resp = svc
5607            .handle(make_request(
5608                Method::GET,
5609                &format!("/clusters/c1/updates/{update_id}"),
5610                "",
5611            ))
5612            .await
5613            .unwrap();
5614        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5615        assert_eq!(v["update"]["status"], "Successful");
5616
5617        // Describe returns the OIDC config and settles CREATING -> ACTIVE.
5618        let describe =
5619            json!({ "identityProviderConfig": { "type": "oidc", "name": "oidc1" } }).to_string();
5620        let resp = svc
5621            .handle(make_request(
5622                Method::POST,
5623                "/clusters/c1/identity-provider-configs/describe",
5624                &describe,
5625            ))
5626            .await
5627            .unwrap();
5628        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5629        let oidc = &v["identityProviderConfig"]["oidc"];
5630        assert_eq!(oidc["identityProviderConfigName"], "oidc1");
5631        assert_eq!(oidc["issuerUrl"], "https://example.com");
5632        assert_eq!(oidc["clientId"], "kubernetes");
5633        assert_eq!(oidc["usernameClaim"], "email");
5634        assert_eq!(oidc["status"], "ACTIVE");
5635        assert!(oidc["identityProviderConfigArn"]
5636            .as_str()
5637            .unwrap()
5638            .starts_with(
5639                "arn:aws:eks:us-east-1:111122223333:identityproviderconfig/c1/oidc/oidc1/"
5640            ));
5641
5642        // List returns the {type, name} summary.
5643        let resp = svc
5644            .handle(make_request(
5645                Method::GET,
5646                "/clusters/c1/identity-provider-configs",
5647                "",
5648            ))
5649            .await
5650            .unwrap();
5651        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5652        assert_eq!(
5653            v["identityProviderConfigs"],
5654            json!([{ "type": "oidc", "name": "oidc1" }])
5655        );
5656
5657        // Disassociate mints another tracked Update and removes the config.
5658        let resp = svc
5659            .handle(make_request(
5660                Method::POST,
5661                "/clusters/c1/identity-provider-configs/disassociate",
5662                &describe,
5663            ))
5664            .await
5665            .unwrap();
5666        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5667        assert_eq!(v["update"]["type"], "DisassociateIdentityProviderConfig");
5668
5669        let resp = svc
5670            .handle(make_request(
5671                Method::GET,
5672                "/clusters/c1/identity-provider-configs",
5673                "",
5674            ))
5675            .await
5676            .unwrap();
5677        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5678        assert_eq!(v["identityProviderConfigs"], json!([]));
5679
5680        // Describe of the removed config is not found.
5681        let err = svc
5682            .handle(make_request(
5683                Method::POST,
5684                "/clusters/c1/identity-provider-configs/describe",
5685                &describe,
5686            ))
5687            .await
5688            .err()
5689            .unwrap();
5690        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5691        assert_eq!(err.code(), "ResourceNotFoundException");
5692    }
5693
5694    #[tokio::test]
5695    async fn idp_on_missing_cluster_is_not_found() {
5696        let svc = EksService::new(make_state());
5697        let err = svc
5698            .handle(make_request(
5699                Method::POST,
5700                "/clusters/ghost/identity-provider-configs/associate",
5701                &idp_body("oidc1"),
5702            ))
5703            .await
5704            .err()
5705            .unwrap();
5706        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5707        assert_eq!(err.code(), "ResourceNotFoundException");
5708    }
5709
5710    #[tokio::test]
5711    async fn idp_duplicate_is_in_use() {
5712        let svc = EksService::new(make_state());
5713        create_cluster(&svc, "c1").await;
5714        svc.handle(make_request(
5715            Method::POST,
5716            "/clusters/c1/identity-provider-configs/associate",
5717            &idp_body("oidc1"),
5718        ))
5719        .await
5720        .unwrap();
5721        let err = svc
5722            .handle(make_request(
5723                Method::POST,
5724                "/clusters/c1/identity-provider-configs/associate",
5725                &idp_body("oidc1"),
5726            ))
5727            .await
5728            .err()
5729            .unwrap();
5730        assert_eq!(err.status(), StatusCode::CONFLICT);
5731        assert_eq!(err.code(), "ResourceInUseException");
5732    }
5733
5734    // -----------------------------------------------------------------------
5735    // Pod identity associations
5736    // -----------------------------------------------------------------------
5737
5738    fn pod_identity_body(namespace: &str, sa: &str) -> String {
5739        json!({
5740            "namespace": namespace,
5741            "serviceAccount": sa,
5742            "roleArn": "arn:aws:iam::111122223333:role/pod-role",
5743            "tags": { "team": "core" }
5744        })
5745        .to_string()
5746    }
5747
5748    #[tokio::test]
5749    async fn pod_identity_create_describe_list_update_delete() {
5750        let svc = EksService::new(make_state());
5751        create_cluster(&svc, "c1").await;
5752
5753        let resp = svc
5754            .handle(make_request(
5755                Method::POST,
5756                "/clusters/c1/pod-identity-associations",
5757                &pod_identity_body("default", "app"),
5758            ))
5759            .await
5760            .unwrap();
5761        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5762        let assoc = &v["association"];
5763        assert_eq!(assoc["clusterName"], "c1");
5764        assert_eq!(assoc["namespace"], "default");
5765        assert_eq!(assoc["serviceAccount"], "app");
5766        assert_eq!(assoc["roleArn"], "arn:aws:iam::111122223333:role/pod-role");
5767        assert_eq!(assoc["disableSessionTags"], false);
5768        assert_eq!(assoc["tags"]["team"], "core");
5769        // No targetRoleArn was supplied, so no externalId is returned.
5770        assert!(assoc.get("externalId").is_none());
5771        let association_id = assoc["associationId"].as_str().unwrap().to_string();
5772        assert!(association_id.starts_with("a-"));
5773        assert!(assoc["associationArn"]
5774            .as_str()
5775            .unwrap()
5776            .starts_with("arn:aws:eks:us-east-1:111122223333:podidentityassociation/c1/a-"));
5777
5778        // Describe round-trips.
5779        let resp = svc
5780            .handle(make_request(
5781                Method::GET,
5782                &format!("/clusters/c1/pod-identity-associations/{association_id}"),
5783                "",
5784            ))
5785            .await
5786            .unwrap();
5787        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5788        assert_eq!(v["association"]["serviceAccount"], "app");
5789
5790        // List returns a summary, filterable by namespace.
5791        let resp = svc
5792            .handle(make_request(
5793                Method::GET,
5794                "/clusters/c1/pod-identity-associations?namespace=default",
5795                "",
5796            ))
5797            .await
5798            .unwrap();
5799        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5800        assert_eq!(v["associations"].as_array().unwrap().len(), 1);
5801        assert_eq!(v["associations"][0]["associationId"], association_id);
5802
5803        // A non-matching namespace filter returns nothing.
5804        let resp = svc
5805            .handle(make_request(
5806                Method::GET,
5807                "/clusters/c1/pod-identity-associations?namespace=other",
5808                "",
5809            ))
5810            .await
5811            .unwrap();
5812        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5813        assert_eq!(v["associations"], json!([]));
5814
5815        // Update upserts the role and cross-account target (minting externalId).
5816        let resp = svc
5817            .handle(make_request(
5818                Method::POST,
5819                &format!("/clusters/c1/pod-identity-associations/{association_id}"),
5820                &json!({
5821                    "roleArn": "arn:aws:iam::111122223333:role/new-role",
5822                    "targetRoleArn": "arn:aws:iam::444455556666:role/target",
5823                    "disableSessionTags": true
5824                })
5825                .to_string(),
5826            ))
5827            .await
5828            .unwrap();
5829        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5830        assert_eq!(
5831            v["association"]["roleArn"],
5832            "arn:aws:iam::111122223333:role/new-role"
5833        );
5834        assert_eq!(
5835            v["association"]["targetRoleArn"],
5836            "arn:aws:iam::444455556666:role/target"
5837        );
5838        assert_eq!(v["association"]["disableSessionTags"], true);
5839        assert!(v["association"]["externalId"].is_string());
5840
5841        // Delete returns the association and then it's gone.
5842        let resp = svc
5843            .handle(make_request(
5844                Method::DELETE,
5845                &format!("/clusters/c1/pod-identity-associations/{association_id}"),
5846                "",
5847            ))
5848            .await
5849            .unwrap();
5850        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5851        assert_eq!(v["association"]["associationId"], association_id);
5852
5853        let err = svc
5854            .handle(make_request(
5855                Method::GET,
5856                &format!("/clusters/c1/pod-identity-associations/{association_id}"),
5857                "",
5858            ))
5859            .await
5860            .err()
5861            .unwrap();
5862        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5863        assert_eq!(err.code(), "ResourceNotFoundException");
5864    }
5865
5866    #[tokio::test]
5867    async fn pod_identity_on_missing_cluster_is_not_found() {
5868        let svc = EksService::new(make_state());
5869        let err = svc
5870            .handle(make_request(
5871                Method::POST,
5872                "/clusters/ghost/pod-identity-associations",
5873                &pod_identity_body("default", "app"),
5874            ))
5875            .await
5876            .err()
5877            .unwrap();
5878        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5879        assert_eq!(err.code(), "ResourceNotFoundException");
5880    }
5881
5882    #[tokio::test]
5883    async fn pod_identity_duplicate_is_in_use() {
5884        let svc = EksService::new(make_state());
5885        create_cluster(&svc, "c1").await;
5886        svc.handle(make_request(
5887            Method::POST,
5888            "/clusters/c1/pod-identity-associations",
5889            &pod_identity_body("default", "app"),
5890        ))
5891        .await
5892        .unwrap();
5893        let err = svc
5894            .handle(make_request(
5895                Method::POST,
5896                "/clusters/c1/pod-identity-associations",
5897                &pod_identity_body("default", "app"),
5898            ))
5899            .await
5900            .err()
5901            .unwrap();
5902        assert_eq!(err.status(), StatusCode::CONFLICT);
5903        assert_eq!(err.code(), "ResourceInUseException");
5904    }
5905
5906    // -----------------------------------------------------------------------
5907    // Insights
5908    // -----------------------------------------------------------------------
5909
5910    #[tokio::test]
5911    async fn insights_list_describe_and_refresh() {
5912        let svc = EksService::new(make_state());
5913        create_cluster(&svc, "c1").await;
5914
5915        // ListInsights seeds and returns a non-empty, real catalogue.
5916        let resp = svc
5917            .handle(make_request(Method::POST, "/clusters/c1/insights", "{}"))
5918            .await
5919            .unwrap();
5920        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5921        let insights = v["insights"].as_array().unwrap();
5922        assert!(!insights.is_empty());
5923        assert_eq!(insights[0]["insightStatus"]["status"], "PASSING");
5924        let id = insights[0]["id"].as_str().unwrap().to_string();
5925
5926        // DescribeInsight round-trips by id.
5927        let resp = svc
5928            .handle(make_request(
5929                Method::GET,
5930                &format!("/clusters/c1/insights/{id}"),
5931                "",
5932            ))
5933            .await
5934            .unwrap();
5935        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5936        assert_eq!(v["insight"]["id"], id);
5937        assert_eq!(v["insight"]["category"], "UPGRADE_READINESS");
5938
5939        // StartInsightsRefresh returns only message + status.
5940        let resp = svc
5941            .handle(make_request(
5942                Method::POST,
5943                "/clusters/c1/insights-refresh",
5944                "{}",
5945            ))
5946            .await
5947            .unwrap();
5948        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5949        assert_eq!(v["status"], "IN_PROGRESS");
5950        assert!(v.get("startedAt").is_none());
5951
5952        // DescribeInsightsRefresh settles IN_PROGRESS -> COMPLETED.
5953        let resp = svc
5954            .handle(make_request(
5955                Method::GET,
5956                "/clusters/c1/insights-refresh",
5957                "",
5958            ))
5959            .await
5960            .unwrap();
5961        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
5962        assert_eq!(v["status"], "COMPLETED");
5963        assert!(v.get("endedAt").is_some());
5964    }
5965
5966    #[tokio::test]
5967    async fn insights_on_missing_cluster_is_not_found() {
5968        let svc = EksService::new(make_state());
5969        let err = svc
5970            .handle(make_request(Method::POST, "/clusters/ghost/insights", "{}"))
5971            .await
5972            .err()
5973            .unwrap();
5974        assert_eq!(err.status(), StatusCode::NOT_FOUND);
5975        assert_eq!(err.code(), "ResourceNotFoundException");
5976    }
5977
5978    #[tokio::test]
5979    async fn describe_insight_missing_is_not_found() {
5980        let svc = EksService::new(make_state());
5981        create_cluster(&svc, "c1").await;
5982        let err = svc
5983            .handle(make_request(Method::GET, "/clusters/c1/insights/ghost", ""))
5984            .await
5985            .err()
5986            .unwrap();
5987        assert_eq!(err.code(), "ResourceNotFoundException");
5988    }
5989
5990    // -----------------------------------------------------------------------
5991    // Encryption config / cancel update / register / cluster versions
5992    // -----------------------------------------------------------------------
5993
5994    #[tokio::test]
5995    async fn associate_encryption_config_mints_update() {
5996        let svc = EksService::new(make_state());
5997        create_cluster(&svc, "c1").await;
5998        let body = json!({
5999            "encryptionConfig": [{
6000                "resources": ["secrets"],
6001                "provider": { "keyArn": "arn:aws:kms:us-east-1:111122223333:key/abc" }
6002            }]
6003        })
6004        .to_string();
6005        let resp = svc
6006            .handle(make_request(
6007                Method::POST,
6008                "/clusters/c1/encryption-config/associate",
6009                &body,
6010            ))
6011            .await
6012            .unwrap();
6013        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6014        assert_eq!(v["update"]["type"], "AssociateEncryptionConfig");
6015        assert_eq!(v["update"]["status"], "InProgress");
6016        let update_id = v["update"]["id"].as_str().unwrap().to_string();
6017
6018        // The update is a cluster update discoverable via DescribeUpdate, and
6019        // CancelUpdate marks it Cancelled.
6020        let resp = svc
6021            .handle(make_request(
6022                Method::POST,
6023                &format!("/clusters/c1/updates/{update_id}/cancel-update"),
6024                "{}",
6025            ))
6026            .await
6027            .unwrap();
6028        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6029        assert_eq!(v["update"]["status"], "Cancelled");
6030    }
6031
6032    #[tokio::test]
6033    async fn encryption_config_on_missing_cluster_is_not_found() {
6034        let svc = EksService::new(make_state());
6035        let err = svc
6036            .handle(make_request(
6037                Method::POST,
6038                "/clusters/ghost/encryption-config/associate",
6039                &json!({ "encryptionConfig": [] }).to_string(),
6040            ))
6041            .await
6042            .err()
6043            .unwrap();
6044        assert_eq!(err.code(), "ResourceNotFoundException");
6045    }
6046
6047    #[tokio::test]
6048    async fn register_and_deregister_cluster() {
6049        let svc = EksService::new(make_state());
6050        let body = json!({
6051            "name": "connected1",
6052            "connectorConfig": {
6053                "roleArn": "arn:aws:iam::111122223333:role/eks-connector",
6054                "provider": "EKS_ANYWHERE"
6055            }
6056        })
6057        .to_string();
6058        let resp = svc
6059            .handle(make_request(Method::POST, "/cluster-registrations", &body))
6060            .await
6061            .unwrap();
6062        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6063        assert_eq!(v["cluster"]["name"], "connected1");
6064        assert_eq!(v["cluster"]["status"], "PENDING");
6065        assert_eq!(v["cluster"]["connectorConfig"]["provider"], "EKS_ANYWHERE");
6066        assert!(v["cluster"]["connectorConfig"]["activationId"].is_string());
6067
6068        // Duplicate registration conflicts.
6069        let err = svc
6070            .handle(make_request(Method::POST, "/cluster-registrations", &body))
6071            .await
6072            .err()
6073            .unwrap();
6074        assert_eq!(err.code(), "ResourceInUseException");
6075
6076        // Deregister removes the connected cluster.
6077        let resp = svc
6078            .handle(make_request(
6079                Method::DELETE,
6080                "/cluster-registrations/connected1",
6081                "",
6082            ))
6083            .await
6084            .unwrap();
6085        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6086        assert_eq!(v["cluster"]["status"], "DELETING");
6087
6088        let err = svc
6089            .handle(make_request(
6090                Method::DELETE,
6091                "/cluster-registrations/connected1",
6092                "",
6093            ))
6094            .await
6095            .err()
6096            .unwrap();
6097        assert_eq!(err.code(), "ResourceNotFoundException");
6098    }
6099
6100    #[tokio::test]
6101    async fn deregister_non_connected_cluster_is_not_found() {
6102        let svc = EksService::new(make_state());
6103        create_cluster(&svc, "regular").await;
6104        // A regular (non-registered) cluster can't be deregistered.
6105        let err = svc
6106            .handle(make_request(
6107                Method::DELETE,
6108                "/cluster-registrations/regular",
6109                "",
6110            ))
6111            .await
6112            .err()
6113            .unwrap();
6114        assert_eq!(err.code(), "ResourceNotFoundException");
6115    }
6116
6117    #[tokio::test]
6118    async fn describe_cluster_versions_catalog_is_non_empty() {
6119        let svc = EksService::new(make_state());
6120        let resp = svc
6121            .handle(make_request(Method::GET, "/cluster-versions", ""))
6122            .await
6123            .unwrap();
6124        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6125        let versions = v["clusterVersions"].as_array().unwrap();
6126        assert!(versions.len() >= 5);
6127        let names: Vec<&str> = versions
6128            .iter()
6129            .map(|x| x["clusterVersion"].as_str().unwrap())
6130            .collect();
6131        assert!(names.contains(&"1.31"));
6132        // Exactly one default version.
6133        assert_eq!(
6134            versions
6135                .iter()
6136                .filter(|x| x["defaultVersion"] == true)
6137                .count(),
6138            1
6139        );
6140        // AWS reports the default version first (index 0).
6141        assert_eq!(versions[0]["defaultVersion"], true);
6142
6143        // defaultOnly filter narrows to the single default.
6144        let resp = svc
6145            .handle(make_request(
6146                Method::GET,
6147                "/cluster-versions?defaultOnly=true",
6148                "",
6149            ))
6150            .await
6151            .unwrap();
6152        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6153        assert_eq!(v["clusterVersions"].as_array().unwrap().len(), 1);
6154        assert_eq!(v["clusterVersions"][0]["clusterVersion"], "1.31");
6155    }
6156
6157    #[tokio::test]
6158    async fn describe_cluster_versions_rejects_bad_maxresults() {
6159        let svc = EksService::new(make_state());
6160        let err = svc
6161            .handle(make_request(
6162                Method::GET,
6163                "/cluster-versions?maxResults=0",
6164                "",
6165            ))
6166            .await
6167            .err()
6168            .unwrap();
6169        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
6170        assert_eq!(err.code(), "InvalidParameterException");
6171    }
6172
6173    // -----------------------------------------------------------------------
6174    // Capabilities
6175    // -----------------------------------------------------------------------
6176
6177    fn capability_body(name: &str) -> String {
6178        json!({
6179            "capabilityName": name,
6180            "type": "ACK",
6181            "roleArn": "arn:aws:iam::111122223333:role/eks-capability",
6182            "deletePropagationPolicy": "RETAIN",
6183            "tags": { "team": "core" }
6184        })
6185        .to_string()
6186    }
6187
6188    #[tokio::test]
6189    async fn capability_create_describe_list_update_delete() {
6190        let svc = EksService::new(make_state());
6191        create_cluster(&svc, "c1").await;
6192
6193        let resp = svc
6194            .handle(make_request(
6195                Method::POST,
6196                "/clusters/c1/capabilities",
6197                &capability_body("ack"),
6198            ))
6199            .await
6200            .unwrap();
6201        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6202        assert_eq!(v["capability"]["capabilityName"], "ack");
6203        assert_eq!(v["capability"]["status"], "CREATING");
6204        assert_eq!(v["capability"]["type"], "ACK");
6205        assert!(v["capability"]["arn"]
6206            .as_str()
6207            .unwrap()
6208            .starts_with("arn:aws:eks:us-east-1:111122223333:capability/c1/ack/"));
6209
6210        // Describe settles CREATING -> ACTIVE.
6211        let resp = svc
6212            .handle(make_request(
6213                Method::GET,
6214                "/clusters/c1/capabilities/ack",
6215                "",
6216            ))
6217            .await
6218            .unwrap();
6219        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6220        assert_eq!(v["capability"]["status"], "ACTIVE");
6221        assert_eq!(v["capability"]["tags"]["team"], "core");
6222
6223        let resp = svc
6224            .handle(make_request(Method::GET, "/clusters/c1/capabilities", ""))
6225            .await
6226            .unwrap();
6227        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6228        assert_eq!(v["capabilities"].as_array().unwrap().len(), 1);
6229        assert_eq!(v["capabilities"][0]["capabilityName"], "ack");
6230
6231        // UpdateCapability mints a tracked cluster Update.
6232        let resp = svc
6233            .handle(make_request(
6234                Method::POST,
6235                "/clusters/c1/capabilities/ack",
6236                &json!({ "roleArn": "arn:aws:iam::111122223333:role/new" }).to_string(),
6237            ))
6238            .await
6239            .unwrap();
6240        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6241        assert_eq!(v["update"]["type"], "CapabilityUpdate");
6242
6243        // Delete.
6244        let resp = svc
6245            .handle(make_request(
6246                Method::DELETE,
6247                "/clusters/c1/capabilities/ack",
6248                "",
6249            ))
6250            .await
6251            .unwrap();
6252        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6253        assert_eq!(v["capability"]["status"], "DELETING");
6254
6255        let err = svc
6256            .handle(make_request(
6257                Method::GET,
6258                "/clusters/c1/capabilities/ack",
6259                "",
6260            ))
6261            .await
6262            .err()
6263            .unwrap();
6264        assert_eq!(err.code(), "ResourceNotFoundException");
6265    }
6266
6267    #[tokio::test]
6268    async fn capability_on_missing_cluster_is_not_found() {
6269        let svc = EksService::new(make_state());
6270        let err = svc
6271            .handle(make_request(
6272                Method::POST,
6273                "/clusters/ghost/capabilities",
6274                &capability_body("ack"),
6275            ))
6276            .await
6277            .err()
6278            .unwrap();
6279        assert_eq!(err.code(), "ResourceNotFoundException");
6280    }
6281
6282    #[tokio::test]
6283    async fn capability_duplicate_is_in_use() {
6284        let svc = EksService::new(make_state());
6285        create_cluster(&svc, "c1").await;
6286        svc.handle(make_request(
6287            Method::POST,
6288            "/clusters/c1/capabilities",
6289            &capability_body("ack"),
6290        ))
6291        .await
6292        .unwrap();
6293        let err = svc
6294            .handle(make_request(
6295                Method::POST,
6296                "/clusters/c1/capabilities",
6297                &capability_body("ack"),
6298            ))
6299            .await
6300            .err()
6301            .unwrap();
6302        assert_eq!(err.status(), StatusCode::CONFLICT);
6303        assert_eq!(err.code(), "ResourceInUseException");
6304    }
6305
6306    // -----------------------------------------------------------------------
6307    // EKS Anywhere subscriptions
6308    // -----------------------------------------------------------------------
6309
6310    fn subscription_body(name: &str) -> String {
6311        json!({
6312            "name": name,
6313            "term": { "duration": 12, "unit": "MONTHS" },
6314            "licenseQuantity": 5,
6315            "licenseType": "Cluster",
6316            "autoRenew": false,
6317            "tags": { "team": "core" }
6318        })
6319        .to_string()
6320    }
6321
6322    #[tokio::test]
6323    async fn subscription_create_describe_list_update_delete() {
6324        let svc = EksService::new(make_state());
6325
6326        let resp = svc
6327            .handle(make_request(
6328                Method::POST,
6329                "/eks-anywhere-subscriptions",
6330                &subscription_body("sub1"),
6331            ))
6332            .await
6333            .unwrap();
6334        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6335        let sub = &v["subscription"];
6336        assert_eq!(sub["status"], "ACTIVE");
6337        assert_eq!(sub["licenseQuantity"], 5);
6338        assert_eq!(sub["term"]["duration"], 12);
6339        assert_eq!(sub["autoRenew"], false);
6340        let id = sub["id"].as_str().unwrap().to_string();
6341        assert!(sub["arn"]
6342            .as_str()
6343            .unwrap()
6344            .starts_with("arn:aws:eks:us-east-1:111122223333:eks-anywhere-subscription/"));
6345
6346        // Describe round-trips.
6347        let resp = svc
6348            .handle(make_request(
6349                Method::GET,
6350                &format!("/eks-anywhere-subscriptions/{id}"),
6351                "",
6352            ))
6353            .await
6354            .unwrap();
6355        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6356        assert_eq!(v["subscription"]["id"], id);
6357
6358        // List returns the subscription.
6359        let resp = svc
6360            .handle(make_request(Method::GET, "/eks-anywhere-subscriptions", ""))
6361            .await
6362            .unwrap();
6363        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6364        assert_eq!(v["subscriptions"].as_array().unwrap().len(), 1);
6365
6366        // Update toggles autoRenew.
6367        let resp = svc
6368            .handle(make_request(
6369                Method::POST,
6370                &format!("/eks-anywhere-subscriptions/{id}"),
6371                &json!({ "autoRenew": true }).to_string(),
6372            ))
6373            .await
6374            .unwrap();
6375        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6376        assert_eq!(v["subscription"]["autoRenew"], true);
6377
6378        // Delete.
6379        let resp = svc
6380            .handle(make_request(
6381                Method::DELETE,
6382                &format!("/eks-anywhere-subscriptions/{id}"),
6383                "",
6384            ))
6385            .await
6386            .unwrap();
6387        let v: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
6388        assert_eq!(v["subscription"]["status"], "DELETING");
6389
6390        let err = svc
6391            .handle(make_request(
6392                Method::GET,
6393                &format!("/eks-anywhere-subscriptions/{id}"),
6394                "",
6395            ))
6396            .await
6397            .err()
6398            .unwrap();
6399        assert_eq!(err.code(), "ResourceNotFoundException");
6400    }
6401
6402    #[tokio::test]
6403    async fn subscription_describe_missing_is_not_found() {
6404        let svc = EksService::new(make_state());
6405        let err = svc
6406            .handle(make_request(
6407                Method::GET,
6408                "/eks-anywhere-subscriptions/ghost",
6409                "",
6410            ))
6411            .await
6412            .err()
6413            .unwrap();
6414        assert_eq!(err.status(), StatusCode::NOT_FOUND);
6415        assert_eq!(err.code(), "ResourceNotFoundException");
6416    }
6417
6418    #[tokio::test]
6419    async fn subscription_rejects_bad_name() {
6420        let svc = EksService::new(make_state());
6421        let err = svc
6422            .handle(make_request(
6423                Method::POST,
6424                "/eks-anywhere-subscriptions",
6425                &json!({ "name": "-bad", "term": { "duration": 1, "unit": "MONTHS" } }).to_string(),
6426            ))
6427            .await
6428            .err()
6429            .unwrap();
6430        assert_eq!(err.code(), "InvalidParameterException");
6431    }
6432}