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