From 4f48a625eebfb61fee7966abc6c8fcaaa8eecded Mon Sep 17 00:00:00 2001 From: Ioannis Lafiotis Date: Wed, 8 Jul 2026 15:46:39 +0200 Subject: [PATCH 1/2] feat(eks): add nodegroups and fargate profiles --- .../src/test/java/com/floci/test/EksTest.java | 138 ++++++- docs/services/eks.md | 77 +++- .../CloudFormationResourceProvisioner.java | 4 +- .../floci/services/eks/EksController.java | 73 +++- .../floci/services/eks/EksService.java | 260 ++++++++---- .../model/CreateFargateProfileRequest.java | 51 +++ .../eks/model/CreateNodeGroupRequest.java | 99 +++++ .../services/eks/model/FargateProfile.java | 139 +++++++ .../eks/model/FargateProfileStatus.java | 5 + .../eks/EksNodegroupIntegrationTest.java | 12 +- .../floci/services/eks/EksServiceTest.java | 387 ++++++++++++++---- 11 files changed, 1061 insertions(+), 184 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/eks/model/CreateFargateProfileRequest.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/eks/model/CreateNodeGroupRequest.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfile.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfileStatus.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/EksTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/EksTest.java index d38be359f5..7bac5ecd25 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/EksTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/EksTest.java @@ -16,6 +16,8 @@ class EksTest { private static EksClient eks; private static String clusterName; private static String clusterArn; + private static String nodegroupName; + private static String fargateProfileName; @BeforeAll static void setup() { @@ -26,6 +28,24 @@ static void setup() { @AfterAll static void cleanup() { if (eks != null) { + if (clusterName != null) { + if (nodegroupName != null) { + try { + eks.deleteNodegroup(DeleteNodegroupRequest.builder() + .clusterName(clusterName) + .nodegroupName(nodegroupName) + .build()); + } catch (Exception ignored) {} + } + if (fargateProfileName != null) { + try { + eks.deleteFargateProfile(DeleteFargateProfileRequest.builder() + .clusterName(clusterName) + .fargateProfileName(fargateProfileName) + .build()); + } catch (Exception ignored) {} + } + } try { eks.deleteCluster(DeleteClusterRequest.builder() .name(clusterName) @@ -145,7 +165,7 @@ void createDuplicateClusterFails() { @Test @Order(10) - void createNodegroup() { + void createNodeGroup() { CreateNodegroupResponse response = eks.createNodegroup(CreateNodegroupRequest.builder() .clusterName(clusterName) .nodegroupName(NODEGROUP) @@ -159,14 +179,13 @@ void createNodegroup() { assertThat(response.nodegroup().clusterName()).isEqualTo(clusterName); assertThat(response.nodegroup().nodegroupArn()) .contains("nodegroup/" + clusterName + "/" + NODEGROUP); - assertThat(response.nodegroup().status()) - .isIn(NodegroupStatus.CREATING, NodegroupStatus.ACTIVE); + assertThat(response.nodegroup().status()).isEqualTo(NodegroupStatus.ACTIVE); assertThat(response.nodegroup().scalingConfig().desiredSize()).isEqualTo(2); } @Test @Order(11) - void listNodegroups() { + void listNodeGroups() { ListNodegroupsResponse response = eks.listNodegroups(ListNodegroupsRequest.builder() .clusterName(clusterName).build()); assertThat(response.nodegroups()).contains(NODEGROUP); @@ -174,7 +193,7 @@ void listNodegroups() { @Test @Order(12) - void describeNodegroup() { + void describeNodeGroup() { DescribeNodegroupResponse response = eks.describeNodegroup(DescribeNodegroupRequest.builder() .clusterName(clusterName).nodegroupName(NODEGROUP).build()); assertThat(response.nodegroup().nodegroupName()).isEqualTo(NODEGROUP); @@ -192,12 +211,119 @@ void describeMissingNodegroupFails() { @Test @Order(14) - void deleteNodegroup() { + void deleteNodeGroup() { DeleteNodegroupResponse response = eks.deleteNodegroup(DeleteNodegroupRequest.builder() .clusterName(clusterName).nodegroupName(NODEGROUP).build()); assertThat(response.nodegroup().status()).isEqualTo(NodegroupStatus.DELETING); } + @Test + @Order(8) + void nodegroupLifecycle() { + nodegroupName = "sdk-test-nodegroup-" + (System.currentTimeMillis() % 100000); + + CreateNodegroupResponse createResponse = eks.createNodegroup(CreateNodegroupRequest.builder() + .clusterName(clusterName) + .nodegroupName(nodegroupName) + .nodeRole("arn:aws:iam::000000000000:role/eks-node-role") + .subnets("subnet-12345678", "subnet-87654321") + .scalingConfig(NodegroupScalingConfig.builder() + .minSize(1) + .maxSize(3) + .desiredSize(2) + .build()) + .instanceTypes("t3.medium") + .labels(Map.of("workload", "api")) + .tags(Map.of("env", "test")) + .build()); + + assertThat(createResponse.nodegroup()).isNotNull(); + assertThat(createResponse.nodegroup().clusterName()).isEqualTo(clusterName); + assertThat(createResponse.nodegroup().nodegroupName()).isEqualTo(nodegroupName); + assertThat(createResponse.nodegroup().nodegroupArn()).contains("nodegroup/" + clusterName + "/" + nodegroupName); + assertThat(createResponse.nodegroup().status()).isEqualTo(NodegroupStatus.ACTIVE); + assertThat(createResponse.nodegroup().scalingConfig().desiredSize()).isEqualTo(2); + assertThat(createResponse.nodegroup().labels()).containsEntry("workload", "api"); + assertThat(createResponse.nodegroup().tags()).containsEntry("env", "test"); + + ListNodegroupsResponse listResponse = eks.listNodegroups(ListNodegroupsRequest.builder() + .clusterName(clusterName) + .build()); + assertThat(listResponse.nodegroups()).contains(nodegroupName); + + DescribeNodegroupResponse describeResponse = eks.describeNodegroup(DescribeNodegroupRequest.builder() + .clusterName(clusterName) + .nodegroupName(nodegroupName) + .build()); + assertThat(describeResponse.nodegroup().nodegroupName()).isEqualTo(nodegroupName); + assertThat(describeResponse.nodegroup().subnets()).containsExactly("subnet-12345678", "subnet-87654321"); + + DeleteNodegroupResponse deleteResponse = eks.deleteNodegroup(DeleteNodegroupRequest.builder() + .clusterName(clusterName) + .nodegroupName(nodegroupName) + .build()); + assertThat(deleteResponse.nodegroup().status()).isEqualTo(NodegroupStatus.DELETING); + assertThatThrownBy(() -> eks.describeNodegroup(DescribeNodegroupRequest.builder() + .clusterName(clusterName) + .nodegroupName(nodegroupName) + .build())) + .isInstanceOf(ResourceNotFoundException.class); + nodegroupName = null; + } + + @Test + @Order(9) + void fargateProfileLifecycle() { + fargateProfileName = "sdk-test-fargate-" + (System.currentTimeMillis() % 100000); + + CreateFargateProfileResponse createResponse = eks.createFargateProfile(CreateFargateProfileRequest.builder() + .clusterName(clusterName) + .fargateProfileName(fargateProfileName) + .podExecutionRoleArn("arn:aws:iam::000000000000:role/eks-fargate-role") + .subnets("subnet-12345678", "subnet-87654321") + .selectors(FargateProfileSelector.builder() + .namespace("default") + .labels(Map.of("app", "api")) + .build()) + .tags(Map.of("env", "test")) + .build()); + + assertThat(createResponse.fargateProfile()).isNotNull(); + assertThat(createResponse.fargateProfile().clusterName()).isEqualTo(clusterName); + assertThat(createResponse.fargateProfile().fargateProfileName()).isEqualTo(fargateProfileName); + assertThat(createResponse.fargateProfile().fargateProfileArn()) + .matches("arn:aws:eks:[^:]+:[0-9]+:fargateprofile/" + clusterName + "/" + fargateProfileName + "/.+"); + assertThat(createResponse.fargateProfile().status()).isEqualTo(FargateProfileStatus.ACTIVE); + assertThat(createResponse.fargateProfile().selectors()).hasSize(1); + assertThat(createResponse.fargateProfile().selectors().get(0).labels()).containsEntry("app", "api"); + assertThat(createResponse.fargateProfile().tags()).containsEntry("env", "test"); + + ListFargateProfilesResponse listResponse = eks.listFargateProfiles(ListFargateProfilesRequest.builder() + .clusterName(clusterName) + .build()); + assertThat(listResponse.fargateProfileNames()).contains(fargateProfileName); + + DescribeFargateProfileResponse describeResponse = eks.describeFargateProfile( + DescribeFargateProfileRequest.builder() + .clusterName(clusterName) + .fargateProfileName(fargateProfileName) + .build()); + assertThat(describeResponse.fargateProfile().fargateProfileName()).isEqualTo(fargateProfileName); + assertThat(describeResponse.fargateProfile().subnets()).containsExactly("subnet-12345678", "subnet-87654321"); + + DeleteFargateProfileResponse deleteResponse = eks.deleteFargateProfile(DeleteFargateProfileRequest.builder() + .clusterName(clusterName) + .fargateProfileName(fargateProfileName) + .build()); + assertThat(deleteResponse.fargateProfile().status()).isEqualTo(FargateProfileStatus.DELETING); + assertThatThrownBy(() -> eks.describeFargateProfile(DescribeFargateProfileRequest.builder() + .clusterName(clusterName) + .fargateProfileName(fargateProfileName) + .build())) + .isInstanceOf(ResourceNotFoundException.class); + fargateProfileName = null; + } + @Test @Order(100) void deleteCluster() { diff --git a/docs/services/eks.md b/docs/services/eks.md index d1138bf00c..2e5d5110c1 100644 --- a/docs/services/eks.md +++ b/docs/services/eks.md @@ -13,6 +13,14 @@ EKS uses a standard REST API with JSON bodies — not the JSON 1.1 (`X-Amz-Targe | `DescribeCluster` | Describe a cluster by name | | `ListClusters` | List all cluster names | | `DeleteCluster` | Delete a cluster | +| `CreateNodegroup` | Create node group metadata for a cluster | +| `DescribeNodegroup` | Describe a node group by cluster and name | +| `ListNodegroups` | List node group names for a cluster | +| `DeleteNodegroup` | Delete a node group | +| `CreateFargateProfile` | Create Fargate profile metadata for a cluster | +| `DescribeFargateProfile` | Describe a Fargate profile by cluster and name | +| `ListFargateProfiles` | List Fargate profile names for a cluster | +| `DeleteFargateProfile` | Delete a Fargate profile | | `TagResource` | Add tags to a cluster | | `UntagResource` | Remove tags from a cluster | | `ListTagsForResource` | List tags on a cluster | @@ -144,6 +152,18 @@ services: arn:aws:eks:::cluster/ ``` +Node groups use: + +``` +arn:aws:eks:::nodegroup/// +``` + +Fargate profiles use: + +``` +arn:aws:eks:::fargateprofile/// +``` + ## Examples ```bash @@ -165,6 +185,59 @@ aws eks describe-cluster --name my-cluster # List clusters aws eks list-clusters +# Create a node group +curl -s -X POST "$AWS_ENDPOINT_URL/clusters/my-cluster/node-groups" \ + -H "Content-Type: application/json" \ + -d '{ + "nodegroupName": "my-nodegroup", + "nodeRole": "arn:aws:iam::000000000000:role/eks-node-role", + "subnets": ["subnet-123", "subnet-456"], + "instanceTypes": ["t3.medium"], + "scalingConfig": { + "minSize": 1, + "maxSize": 3, + "desiredSize": 1 + } + }' + +# Describe the node group +curl -s "$AWS_ENDPOINT_URL/clusters/my-cluster/node-groups/my-nodegroup" + +# List node groups +curl -s "$AWS_ENDPOINT_URL/clusters/my-cluster/node-groups" + +# Delete the node group +curl -s -X DELETE "$AWS_ENDPOINT_URL/clusters/my-cluster/node-groups/my-nodegroup" + +# Create a Fargate profile +curl -s -X POST "$AWS_ENDPOINT_URL/clusters/my-cluster/fargate-profiles" \ + -H "Content-Type: application/json" \ + -d '{ + "fargateProfileName": "my-fargate-profile", + "podExecutionRoleArn": "arn:aws:iam::000000000000:role/eks-fargate-role", + "subnets": ["subnet-123", "subnet-456"], + "selectors": [ + { + "namespace": "default", + "labels": { + "app": "api" + } + } + ], + "tags": { + "env": "dev" + } + }' + +# Describe the Fargate profile +curl -s "$AWS_ENDPOINT_URL/clusters/my-cluster/fargate-profiles/my-fargate-profile" + +# List Fargate profiles +curl -s "$AWS_ENDPOINT_URL/clusters/my-cluster/fargate-profiles" + +# Delete the Fargate profile +curl -s -X DELETE "$AWS_ENDPOINT_URL/clusters/my-cluster/fargate-profiles/my-fargate-profile" + # Tag a cluster aws eks tag-resource \ --resource-arn arn:aws:eks:us-east-1:000000000000:cluster/my-cluster \ @@ -203,6 +276,8 @@ System.out.println(described.cluster().status()); // ACTIVE // List clusters List names = eks.listClusters(r -> {}).clusters(); +// Node group and Fargate profile support are currently exposed through the REST paths. + // Tag resource eks.tagResource(r -> r .resourceArn(created.cluster().arn()) @@ -216,8 +291,6 @@ eks.deleteCluster(r -> r.name("my-cluster")); The following EKS features are not yet supported: -- Node groups (`CreateNodegroup`, `DescribeNodegroup`, `ListNodegroups`, `DeleteNodegroup`) -- Fargate profiles - `UpdateClusterConfig` / `UpdateClusterVersion` - Add-ons (`CreateAddon`, `DescribeAddon`, `ListAddons`) - Identity provider configs diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java index 8bc0d37a89..173af57aa6 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java @@ -381,7 +381,7 @@ public void delete(StackResource resource, String region) { String clusterName = resource.getAttributes().get("ClusterName"); if (clusterName != null && !clusterName.isBlank()) { try { - eksService.deleteNodegroup(clusterName, resource.getPhysicalId()); + eksService.deleteNodeGroup(clusterName, resource.getPhysicalId()); } catch (Exception e) { LOG.debugv("Error deleting nodegroup {0}: {1}", resource.getPhysicalId(), e.getMessage()); } @@ -1099,7 +1099,7 @@ private void provisionEksNodegroup(StackResource r, JsonNode props, CloudFormati } } request.setSubnets(subnets); - var nodegroup = eksService.createNodegroup(clusterName, request); + var nodegroup = eksService.createNodeGroup(clusterName, request); r.setPhysicalId(nodegroup.getNodegroupName()); r.getAttributes().put("ClusterName", nodegroup.getClusterName()); r.getAttributes().put("NodegroupName", nodegroup.getNodegroupName()); diff --git a/src/main/java/io/github/hectorvent/floci/services/eks/EksController.java b/src/main/java/io/github/hectorvent/floci/services/eks/EksController.java index 253dfe82f4..02dca3ab9b 100644 --- a/src/main/java/io/github/hectorvent/floci/services/eks/EksController.java +++ b/src/main/java/io/github/hectorvent/floci/services/eks/EksController.java @@ -2,6 +2,9 @@ import io.github.hectorvent.floci.services.eks.model.Cluster; import io.github.hectorvent.floci.services.eks.model.CreateClusterRequest; +import io.github.hectorvent.floci.services.eks.model.CreateFargateProfileRequest; +import io.github.hectorvent.floci.services.eks.model.CreateNodeGroupRequest; +import io.github.hectorvent.floci.services.eks.model.FargateProfile; import io.github.hectorvent.floci.services.eks.model.Nodegroup; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; @@ -11,7 +14,6 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -21,7 +23,9 @@ /** * EKS REST-JSON controller. * - *

EKS uses standard HTTP verbs with JSON bodies — not JSON 1.1 (X-Amz-Target) or Query protocol. + *

+ * EKS uses standard HTTP verbs with JSON bodies — not JSON 1.1 (X-Amz-Target) + * or Query protocol. */ @Path("/") @Produces(MediaType.APPLICATION_JSON) @@ -44,8 +48,7 @@ public Response createCluster(CreateClusterRequest request) { @GET @Path("/clusters") - public Response listClusters(@QueryParam("nextToken") String nextToken, - @QueryParam("maxResults") Integer maxResults) { + public Response listClusters() { List clusterNames = eksService.listClusters(); return Response.ok(Map.of("clusters", clusterNames)).build(); } @@ -64,37 +67,65 @@ public Response deleteCluster(@PathParam("name") String name) { return Response.ok(Map.of("cluster", cluster)).build(); } - // Managed node groups. These explicit routes outrank S3's path-style catch-all - // (@Path("/{bucket}/{key: .+}")), which previously swallowed these paths (issue #1137). - + // Keep these concrete EKS resource paths declared explicitly so they outrank + // the S3 catch-all route; see issue #1137. @POST @Path("/clusters/{name}/node-groups") - public Response createNodegroup(@PathParam("name") String name, Nodegroup request) { - Nodegroup nodegroup = eksService.createNodegroup(name, request); - return Response.ok(Map.of("nodegroup", nodegroup)).build(); + public Response createNodeGroup(@PathParam("name") String name, CreateNodeGroupRequest request) { + Nodegroup nodeGroup = eksService.createNodeGroup(name, request); + return Response.ok(Map.of("nodegroup", nodeGroup)).build(); } @GET @Path("/clusters/{name}/node-groups") - public Response listNodegroups(@PathParam("name") String name) { - List nodegroups = eksService.listNodegroups(name); - return Response.ok(Map.of("nodegroups", nodegroups)).build(); + public Response listNodeGroups(@PathParam("name") String name) { + List nodeGroupNames = eksService.listNodeGroups(name); + return Response.ok(Map.of("nodegroups", nodeGroupNames)).build(); } @GET @Path("/clusters/{name}/node-groups/{nodegroupName}") - public Response describeNodegroup(@PathParam("name") String name, - @PathParam("nodegroupName") String nodegroupName) { - Nodegroup nodegroup = eksService.describeNodegroup(name, nodegroupName); - return Response.ok(Map.of("nodegroup", nodegroup)).build(); + public Response describeNodeGroup(@PathParam("name") String name, + @PathParam("nodegroupName") String nodegroupName) { + Nodegroup nodeGroup = eksService.describeNodeGroup(name, nodegroupName); + return Response.ok(Map.of("nodegroup", nodeGroup)).build(); } @DELETE @Path("/clusters/{name}/node-groups/{nodegroupName}") - public Response deleteNodegroup(@PathParam("name") String name, - @PathParam("nodegroupName") String nodegroupName) { - Nodegroup nodegroup = eksService.deleteNodegroup(name, nodegroupName); - return Response.ok(Map.of("nodegroup", nodegroup)).build(); + public Response deleteNodeGroup(@PathParam("name") String name, + @PathParam("nodegroupName") String nodegroupName) { + Nodegroup nodeGroup = eksService.deleteNodeGroup(name, nodegroupName); + return Response.ok(Map.of("nodegroup", nodeGroup)).build(); + } + + @POST + @Path("/clusters/{name}/fargate-profiles") + public Response createFargateProfile(@PathParam("name") String name, CreateFargateProfileRequest request) { + FargateProfile profile = eksService.createFargateProfile(name, request); + return Response.ok(Map.of("fargateProfile", profile)).build(); + } + + @GET + @Path("/clusters/{name}/fargate-profiles") + public Response listFargateProfiles(@PathParam("name") String name) { + List profileNames = eksService.listFargateProfiles(name); + return Response.ok(Map.of("fargateProfileNames", profileNames)).build(); + } + + @GET + @Path("/clusters/{name}/fargate-profiles/{fargateProfileName}") + public Response describeFargateProfile(@PathParam("name") String name, + @PathParam("fargateProfileName") String fargateProfileName) { + FargateProfile profile = eksService.describeFargateProfile(name, fargateProfileName); + return Response.ok(Map.of("fargateProfile", profile)).build(); } + @DELETE + @Path("/clusters/{name}/fargate-profiles/{fargateProfileName}") + public Response deleteFargateProfile(@PathParam("name") String name, + @PathParam("fargateProfileName") String fargateProfileName) { + FargateProfile profile = eksService.deleteFargateProfile(name, fargateProfileName); + return Response.ok(Map.of("fargateProfile", profile)).build(); + } } diff --git a/src/main/java/io/github/hectorvent/floci/services/eks/EksService.java b/src/main/java/io/github/hectorvent/floci/services/eks/EksService.java index 09e790b48d..9d79c56cc0 100644 --- a/src/main/java/io/github/hectorvent/floci/services/eks/EksService.java +++ b/src/main/java/io/github/hectorvent/floci/services/eks/EksService.java @@ -12,6 +12,10 @@ import io.github.hectorvent.floci.services.eks.model.Cluster; import io.github.hectorvent.floci.services.eks.model.ClusterStatus; import io.github.hectorvent.floci.services.eks.model.CreateClusterRequest; +import io.github.hectorvent.floci.services.eks.model.CreateFargateProfileRequest; +import io.github.hectorvent.floci.services.eks.model.CreateNodeGroupRequest; +import io.github.hectorvent.floci.services.eks.model.FargateProfile; +import io.github.hectorvent.floci.services.eks.model.FargateProfileStatus; import io.github.hectorvent.floci.services.eks.model.KubernetesNetworkConfig; import io.github.hectorvent.floci.services.eks.model.Nodegroup; import io.github.hectorvent.floci.services.eks.model.NodegroupScalingConfig; @@ -42,7 +46,8 @@ public class EksService implements TagHandler { private static final Logger LOG = Logger.getLogger(EksService.class); private final StorageBackend storage; - private final StorageBackend nodegroupStorage; + private final StorageBackend nodeGroupStorage; + private final StorageBackend fargateProfileStorage; private final EmulatorConfig config; private final RegionResolver regionResolver; private final EksClusterManager clusterManager; @@ -50,11 +55,16 @@ public class EksService implements TagHandler { @Inject public EksService(StorageFactory storageFactory, EmulatorConfig config, - RegionResolver regionResolver, EksClusterManager clusterManager) { + RegionResolver regionResolver, EksClusterManager clusterManager) { this.storage = storageFactory.create("eks", "eks-clusters.json", - new TypeReference>() {}); - this.nodegroupStorage = storageFactory.create("eks", "eks-nodegroups.json", - new TypeReference>() {}); + new TypeReference>() { + }); + this.nodeGroupStorage = storageFactory.create("eks", "eks-nodegroups.json", + new TypeReference>() { + }); + this.fargateProfileStorage = storageFactory.create("eks", "eks-fargate-profiles.json", + new TypeReference>() { + }); this.config = config; this.regionResolver = regionResolver; this.clusterManager = clusterManager; @@ -146,13 +156,31 @@ public Cluster deleteCluster(String name) { return cluster; } - // ──────────────────────────── Managed node groups ──────────────────────────── + public Nodegroup createNodeGroup(String clusterName, CreateNodeGroupRequest request) { + Nodegroup nodegroup = new Nodegroup(); + nodegroup.setNodegroupName(request.getNodegroupName()); + nodegroup.setVersion(request.getVersion()); + nodegroup.setReleaseVersion(request.getReleaseVersion()); + nodegroup.setSubnets(request.getSubnets()); + nodegroup.setNodeRole(request.getNodeRole()); + nodegroup.setAmiType(request.getAmiType()); + nodegroup.setCapacityType(request.getCapacityType()); + nodegroup.setDiskSize(request.getDiskSize()); + nodegroup.setInstanceTypes(request.getInstanceTypes()); + nodegroup.setScalingConfig(request.getScalingConfig()); + nodegroup.setUpdateConfig(request.getUpdateConfig()); + nodegroup.setLabels(request.getLabels()); + nodegroup.setTags(request.getTags()); + nodegroup.setClientRequestToken(request.getClientRequestToken()); + return createNodeGroup(clusterName, nodegroup); + } - public Nodegroup createNodegroup(String clusterName, Nodegroup request) { + public Nodegroup createNodeGroup(String clusterName, Nodegroup request) { Cluster cluster = describeCluster(clusterName); + String nodegroupName = request.getNodegroupName(); if (nodegroupName == null || nodegroupName.isBlank()) { - throw new AwsException("InvalidParameterException", "nodegroupName is required", 400); + throw new AwsException("InvalidParameterException", "Nodegroup name is required", 400); } if (request.getNodeRole() == null || request.getNodeRole().isBlank()) { throw new AwsException("InvalidParameterException", "nodeRole is required", 400); @@ -160,87 +188,132 @@ public Nodegroup createNodegroup(String clusterName, Nodegroup request) { if (request.getSubnets() == null || request.getSubnets().isEmpty()) { throw new AwsException("InvalidParameterException", "subnets are required", 400); } - String key = nodegroupKey(clusterName, nodegroupName); - if (nodegroupStorage.get(key).isPresent()) { + + String storageKey = nodeGroupKey(clusterName, nodegroupName); + if (nodeGroupStorage.get(storageKey).isPresent()) { throw new AwsException("ResourceInUseException", - "NodeGroup already exists: " + nodegroupName, 409); + "Nodegroup already exists: " + nodegroupName, 409); } String region = config.defaultRegion(); String accountId = regionResolver.getAccountId(); + String id = UUID.randomUUID().toString(); + String arn = AwsArnUtils.Arn.of("eks", region, accountId, + "nodegroup/" + clusterName + "/" + nodegroupName + "/" + id).toString(); + Instant now = Instant.now(); + Nodegroup nodeGroup = new Nodegroup(); + nodeGroup.setNodegroupName(nodegroupName); + nodeGroup.setNodegroupArn(arn); + nodeGroup.setClusterName(clusterName); + nodeGroup.setAccountId(accountId); + nodeGroup.setCreatedAt(now); + nodeGroup.setModifiedAt(now); + String resolvedVersion = request.getVersion() != null ? request.getVersion() : cluster.getVersion(); + nodeGroup.setVersion(resolvedVersion); + nodeGroup.setReleaseVersion(request.getReleaseVersion() != null + ? request.getReleaseVersion() : resolvedVersion + "-eks-1"); + nodeGroup.setStatus(NodegroupStatus.ACTIVE); + nodeGroup.setCapacityType(request.getCapacityType() != null ? request.getCapacityType() : "ON_DEMAND"); + nodeGroup.setScalingConfig(request.getScalingConfig() != null ? request.getScalingConfig() : defaultScalingConfig()); + nodeGroup.setInstanceTypes(request.getInstanceTypes() != null ? request.getInstanceTypes() : List.of("t3.medium")); + nodeGroup.setSubnets(request.getSubnets() != null ? request.getSubnets() : List.of()); + nodeGroup.setAmiType(request.getAmiType() != null ? request.getAmiType() : "AL2_x86_64"); + nodeGroup.setNodeRole(request.getNodeRole()); + nodeGroup.setDiskSize(request.getDiskSize() != null ? request.getDiskSize() : 20); + nodeGroup.setResources(defaultNodeGroupResources(nodegroupName)); + nodeGroup.setHealth(defaultNodeGroupHealth()); + nodeGroup.setUpdateConfig(request.getUpdateConfig() != null ? request.getUpdateConfig() : defaultUpdateConfig()); + nodeGroup.setLabels(request.getLabels() != null ? new HashMap<>(request.getLabels()) : null); + nodeGroup.setTags(request.getTags() != null ? new HashMap<>(request.getTags()) : new HashMap<>()); + + nodeGroupStorage.put(storageKey, nodeGroup); + return nodeGroup; + } - Nodegroup ng = request; - ng.setClusterName(clusterName); - ng.setAccountId(accountId); - ng.setNodegroupArn(AwsArnUtils.Arn.of("eks", region, accountId, - "nodegroup/" + clusterName + "/" + nodegroupName + "/" + UUID.randomUUID()).toString()); - ng.setCreatedAt(now); - ng.setModifiedAt(now); - // Mock node groups become ACTIVE immediately, mirroring mock-mode cluster creation; - // real worker nodes are not provisioned — k3s schedules pods on its built-in node. - ng.setStatus(NodegroupStatus.ACTIVE); - if (ng.getAmiType() == null) { - ng.setAmiType("AL2_x86_64"); - } - if (ng.getCapacityType() == null) { - ng.setCapacityType("ON_DEMAND"); - } - if (ng.getDiskSize() == null) { - ng.setDiskSize(20); - } - if (ng.getVersion() == null) { - ng.setVersion(cluster.getVersion()); - } - if (ng.getReleaseVersion() == null) { - ng.setReleaseVersion(ng.getVersion() + "-eks-1"); + public Nodegroup describeNodeGroup(String clusterName, String nodegroupName) { + describeCluster(clusterName); + return nodeGroupStorage.get(nodeGroupKey(clusterName, nodegroupName)) + .orElseThrow(() -> new AwsException("ResourceNotFoundException", + "No nodegroup found for name: " + nodegroupName, 404)); + } + + public List listNodeGroups(String clusterName) { + describeCluster(clusterName); + String prefix = clusterName + "/"; + return nodeGroupStorage.scan(key -> key.startsWith(prefix)).stream() + .map(Nodegroup::getNodegroupName) + .collect(Collectors.toList()); + } + + public Nodegroup deleteNodeGroup(String clusterName, String nodegroupName) { + Nodegroup nodeGroup = describeNodeGroup(clusterName, nodegroupName); + nodeGroup.setStatus(NodegroupStatus.DELETING); + nodeGroup.setModifiedAt(Instant.now()); + nodeGroupStorage.delete(nodeGroupKey(clusterName, nodegroupName)); + return nodeGroup; + } + + public FargateProfile createFargateProfile(String clusterName, CreateFargateProfileRequest request) { + describeCluster(clusterName); + + String fargateProfileName = request.getFargateProfileName(); + if (fargateProfileName == null || fargateProfileName.isBlank()) { + throw new AwsException("InvalidParameterException", "Fargate profile name is required", 400); } - if (ng.getInstanceTypes() == null || ng.getInstanceTypes().isEmpty()) { - ng.setInstanceTypes(List.of("t3.medium")); + if (request.getPodExecutionRoleArn() == null || request.getPodExecutionRoleArn().isBlank()) { + throw new AwsException("InvalidParameterException", "podExecutionRoleArn is required", 400); } - if (ng.getScalingConfig() == null) { - NodegroupScalingConfig scaling = new NodegroupScalingConfig(); - scaling.setMinSize(1); - scaling.setMaxSize(2); - scaling.setDesiredSize(2); - ng.setScalingConfig(scaling); + + String storageKey = fargateProfileKey(clusterName, fargateProfileName); + if (fargateProfileStorage.get(storageKey).isPresent()) { + throw new AwsException("ResourceInUseException", + "Fargate profile already exists: " + fargateProfileName, 409); } - Map resources = new LinkedHashMap<>(); - Map asg = new LinkedHashMap<>(); - asg.put("name", "eks-" + nodegroupName + "-" + UUID.randomUUID().toString().substring(0, 8)); - resources.put("autoScalingGroups", List.of(asg)); - ng.setResources(resources); - Map health = new LinkedHashMap<>(); - health.put("issues", new ArrayList<>()); - ng.setHealth(health); - nodegroupStorage.put(key, ng); - return ng; + String region = config.defaultRegion(); + String accountId = regionResolver.getAccountId(); + String id = UUID.randomUUID().toString(); + String arn = AwsArnUtils.Arn.of("eks", region, accountId, + "fargateprofile/" + clusterName + "/" + fargateProfileName + "/" + id).toString(); + + FargateProfile profile = new FargateProfile(); + profile.setFargateProfileName(fargateProfileName); + profile.setFargateProfileArn(arn); + profile.setClusterName(clusterName); + profile.setAccountId(accountId); + profile.setCreatedAt(Instant.now()); + profile.setPodExecutionRoleArn(request.getPodExecutionRoleArn()); + profile.setSubnets(request.getSubnets() != null ? request.getSubnets() : List.of()); + profile.setSelectors(request.getSelectors() != null ? request.getSelectors() : List.of()); + profile.setStatus(FargateProfileStatus.ACTIVE); + profile.setHealth(defaultFargateProfileHealth()); + profile.setTags(request.getTags() != null ? new HashMap<>(request.getTags()) : new HashMap<>()); + + fargateProfileStorage.put(storageKey, profile); + return profile; } - public Nodegroup describeNodegroup(String clusterName, String nodegroupName) { - return nodegroupStorage.get(nodegroupKey(clusterName, nodegroupName)) + public FargateProfile describeFargateProfile(String clusterName, String fargateProfileName) { + describeCluster(clusterName); + return fargateProfileStorage.get(fargateProfileKey(clusterName, fargateProfileName)) .orElseThrow(() -> new AwsException("ResourceNotFoundException", - "No node group found for name: " + nodegroupName + " in cluster: " + clusterName, 404)); + "No fargate profile found for name: " + fargateProfileName, 404)); } - public List listNodegroups(String clusterName) { + public List listFargateProfiles(String clusterName) { describeCluster(clusterName); - return nodegroupStorage.scan(k -> true).stream() - .filter(ng -> clusterName.equals(ng.getClusterName())) - .map(Nodegroup::getNodegroupName) + String prefix = clusterName + "/"; + return fargateProfileStorage.scan(key -> key.startsWith(prefix)).stream() + .map(FargateProfile::getFargateProfileName) .collect(Collectors.toList()); } - public Nodegroup deleteNodegroup(String clusterName, String nodegroupName) { - Nodegroup ng = describeNodegroup(clusterName, nodegroupName); - ng.setStatus(NodegroupStatus.DELETING); - nodegroupStorage.delete(nodegroupKey(clusterName, nodegroupName)); - return ng; - } - - private String nodegroupKey(String clusterName, String nodegroupName) { - return clusterName + "/" + nodegroupName; + public FargateProfile deleteFargateProfile(String clusterName, String fargateProfileName) { + FargateProfile profile = describeFargateProfile(clusterName, fargateProfileName); + profile.setStatus(FargateProfileStatus.DELETING); + fargateProfileStorage.delete(fargateProfileKey(clusterName, fargateProfileName)); + return profile; } @Override @@ -313,9 +386,12 @@ private ResourcesVpcConfig buildVpcConfigResponse(ResourcesVpcConfig request) { response.setSubnetIds(request.getSubnetIds() != null ? request.getSubnetIds() : List.of()); response.setSecurityGroupIds(request.getSecurityGroupIds() != null ? request.getSecurityGroupIds() : List.of()); response.setVpcId(request.getVpcId() != null ? request.getVpcId() : ""); - response.setEndpointPublicAccess(request.getEndpointPublicAccess() != null ? request.getEndpointPublicAccess() : Boolean.TRUE); - response.setEndpointPrivateAccess(request.getEndpointPrivateAccess() != null ? request.getEndpointPrivateAccess() : Boolean.FALSE); - response.setPublicAccessCidrs(request.getPublicAccessCidrs() != null ? request.getPublicAccessCidrs() : List.of("0.0.0.0/0")); + response.setEndpointPublicAccess( + request.getEndpointPublicAccess() != null ? request.getEndpointPublicAccess() : Boolean.TRUE); + response.setEndpointPrivateAccess( + request.getEndpointPrivateAccess() != null ? request.getEndpointPrivateAccess() : Boolean.FALSE); + response.setPublicAccessCidrs( + request.getPublicAccessCidrs() != null ? request.getPublicAccessCidrs() : List.of("0.0.0.0/0")); } else { response.setSubnetIds(List.of()); response.setSecurityGroupIds(List.of()); @@ -339,6 +415,46 @@ private KubernetesNetworkConfig buildNetworkConfig(KubernetesNetworkConfig reque return config; } + private String nodeGroupKey(String clusterName, String nodegroupName) { + return clusterName + "/" + nodegroupName; + } + + private String fargateProfileKey(String clusterName, String fargateProfileName) { + return clusterName + "/" + fargateProfileName; + } + + private NodegroupScalingConfig defaultScalingConfig() { + NodegroupScalingConfig scalingConfig = new NodegroupScalingConfig(); + scalingConfig.setMinSize(1); + scalingConfig.setMaxSize(1); + scalingConfig.setDesiredSize(1); + return scalingConfig; + } + + private Map defaultUpdateConfig() { + return Map.of("maxUnavailable", 1); + } + + private Map defaultNodeGroupResources(String nodegroupName) { + Map resources = new LinkedHashMap<>(); + Map autoScalingGroup = new LinkedHashMap<>(); + autoScalingGroup.put("name", "eks-" + nodegroupName + "-" + UUID.randomUUID().toString().substring(0, 8)); + resources.put("autoScalingGroups", List.of(autoScalingGroup)); + return resources; + } + + private Map> defaultNodeGroupHealth() { + Map> health = new LinkedHashMap<>(); + health.put("issues", new ArrayList<>()); + return health; + } + + private FargateProfile.Health defaultFargateProfileHealth() { + FargateProfile.Health health = new FargateProfile.Health(); + health.setIssues(List.of()); + return health; + } + private void startReadinessPoller() { poller.scheduleAtFixedRate(() -> { try { diff --git a/src/main/java/io/github/hectorvent/floci/services/eks/model/CreateFargateProfileRequest.java b/src/main/java/io/github/hectorvent/floci/services/eks/model/CreateFargateProfileRequest.java new file mode 100644 index 0000000000..d6469642d4 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/eks/model/CreateFargateProfileRequest.java @@ -0,0 +1,51 @@ +package io.github.hectorvent.floci.services.eks.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.List; +import java.util.Map; + +@RegisterForReflection +@JsonIgnoreProperties(ignoreUnknown = true) +public class CreateFargateProfileRequest { + + @JsonProperty("fargateProfileName") + private String fargateProfileName; + + @JsonProperty("podExecutionRoleArn") + private String podExecutionRoleArn; + + @JsonProperty("subnets") + private List subnets; + + @JsonProperty("selectors") + private List selectors; + + @JsonProperty("clientRequestToken") + private String clientRequestToken; + + @JsonProperty("tags") + private Map tags; + + public CreateFargateProfileRequest() {} + + public String getFargateProfileName() { return fargateProfileName; } + public void setFargateProfileName(String fargateProfileName) { this.fargateProfileName = fargateProfileName; } + + public String getPodExecutionRoleArn() { return podExecutionRoleArn; } + public void setPodExecutionRoleArn(String podExecutionRoleArn) { this.podExecutionRoleArn = podExecutionRoleArn; } + + public List getSubnets() { return subnets; } + public void setSubnets(List subnets) { this.subnets = subnets; } + + public List getSelectors() { return selectors; } + public void setSelectors(List selectors) { this.selectors = selectors; } + + public String getClientRequestToken() { return clientRequestToken; } + public void setClientRequestToken(String clientRequestToken) { this.clientRequestToken = clientRequestToken; } + + public Map getTags() { return tags; } + public void setTags(Map tags) { this.tags = tags; } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/eks/model/CreateNodeGroupRequest.java b/src/main/java/io/github/hectorvent/floci/services/eks/model/CreateNodeGroupRequest.java new file mode 100644 index 0000000000..1ba0669c41 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/eks/model/CreateNodeGroupRequest.java @@ -0,0 +1,99 @@ +package io.github.hectorvent.floci.services.eks.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.List; +import java.util.Map; + +@RegisterForReflection +@JsonIgnoreProperties(ignoreUnknown = true) +public class CreateNodeGroupRequest { + + @JsonProperty("nodegroupName") + private String nodegroupName; + + @JsonProperty("version") + private String version; + + @JsonProperty("releaseVersion") + private String releaseVersion; + + @JsonProperty("subnets") + private List subnets; + + @JsonProperty("nodeRole") + private String nodeRole; + + @JsonProperty("amiType") + private String amiType; + + @JsonProperty("capacityType") + private String capacityType; + + @JsonProperty("diskSize") + private Integer diskSize; + + @JsonProperty("instanceTypes") + private List instanceTypes; + + @JsonProperty("scalingConfig") + private NodegroupScalingConfig scalingConfig; + + @JsonProperty("updateConfig") + private Object updateConfig; + + @JsonProperty("labels") + private Map labels; + + @JsonProperty("tags") + private Map tags; + + @JsonProperty("clientRequestToken") + private String clientRequestToken; + + public CreateNodeGroupRequest() {} + + public String getNodegroupName() { return nodegroupName; } + public void setNodegroupName(String nodegroupName) { this.nodegroupName = nodegroupName; } + + public String getVersion() { return version; } + public void setVersion(String version) { this.version = version; } + + public String getReleaseVersion() { return releaseVersion; } + public void setReleaseVersion(String releaseVersion) { this.releaseVersion = releaseVersion; } + + public List getSubnets() { return subnets; } + public void setSubnets(List subnets) { this.subnets = subnets; } + + public String getNodeRole() { return nodeRole; } + public void setNodeRole(String nodeRole) { this.nodeRole = nodeRole; } + + public String getAmiType() { return amiType; } + public void setAmiType(String amiType) { this.amiType = amiType; } + + public String getCapacityType() { return capacityType; } + public void setCapacityType(String capacityType) { this.capacityType = capacityType; } + + public Integer getDiskSize() { return diskSize; } + public void setDiskSize(Integer diskSize) { this.diskSize = diskSize; } + + public List getInstanceTypes() { return instanceTypes; } + public void setInstanceTypes(List instanceTypes) { this.instanceTypes = instanceTypes; } + + public NodegroupScalingConfig getScalingConfig() { return scalingConfig; } + public void setScalingConfig(NodegroupScalingConfig scalingConfig) { this.scalingConfig = scalingConfig; } + + public Object getUpdateConfig() { return updateConfig; } + public void setUpdateConfig(Object updateConfig) { this.updateConfig = updateConfig; } + + public Map getLabels() { return labels; } + public void setLabels(Map labels) { this.labels = labels; } + + public Map getTags() { return tags; } + public void setTags(Map tags) { this.tags = tags; } + + public String getClientRequestToken() { return clientRequestToken; } + public void setClientRequestToken(String clientRequestToken) { this.clientRequestToken = clientRequestToken; } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfile.java b/src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfile.java new file mode 100644 index 0000000000..9658e0eb8d --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfile.java @@ -0,0 +1,139 @@ +package io.github.hectorvent.floci.services.eks.model; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@RegisterForReflection +@JsonIgnoreProperties(ignoreUnknown = true) +public class FargateProfile { + + @JsonProperty("fargateProfileName") + private String fargateProfileName; + + @JsonProperty("fargateProfileArn") + private String fargateProfileArn; + + @JsonProperty("clusterName") + private String clusterName; + + @JsonProperty("createdAt") + @JsonFormat(shape = JsonFormat.Shape.NUMBER) + private Instant createdAt; + + @JsonProperty("podExecutionRoleArn") + private String podExecutionRoleArn; + + @JsonProperty("subnets") + private List subnets; + + @JsonProperty("selectors") + private List selectors; + + @JsonProperty("status") + private FargateProfileStatus status; + + @JsonProperty("health") + private Health health; + + @JsonProperty("tags") + private Map tags; + + @JsonIgnore + private String accountId; + + public FargateProfile() {} + + public String getFargateProfileName() { return fargateProfileName; } + public void setFargateProfileName(String fargateProfileName) { this.fargateProfileName = fargateProfileName; } + + public String getFargateProfileArn() { return fargateProfileArn; } + public void setFargateProfileArn(String fargateProfileArn) { this.fargateProfileArn = fargateProfileArn; } + + public String getClusterName() { return clusterName; } + public void setClusterName(String clusterName) { this.clusterName = clusterName; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public String getPodExecutionRoleArn() { return podExecutionRoleArn; } + public void setPodExecutionRoleArn(String podExecutionRoleArn) { this.podExecutionRoleArn = podExecutionRoleArn; } + + public List getSubnets() { return subnets; } + public void setSubnets(List subnets) { this.subnets = subnets; } + + public List getSelectors() { return selectors; } + public void setSelectors(List selectors) { this.selectors = selectors; } + + public FargateProfileStatus getStatus() { return status; } + public void setStatus(FargateProfileStatus status) { this.status = status; } + + public Health getHealth() { return health; } + public void setHealth(Health health) { this.health = health; } + + public Map getTags() { return tags; } + public void setTags(Map tags) { this.tags = tags; } + + public String getAccountId() { return accountId; } + public void setAccountId(String accountId) { this.accountId = accountId; } + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Selector { + @JsonProperty("namespace") + private String namespace; + + @JsonProperty("labels") + private Map labels; + + public Selector() {} + + public String getNamespace() { return namespace; } + public void setNamespace(String namespace) { this.namespace = namespace; } + + public Map getLabels() { return labels; } + public void setLabels(Map labels) { this.labels = labels; } + } + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Health { + @JsonProperty("issues") + private List issues; + + public Health() {} + + public List getIssues() { return issues; } + public void setIssues(List issues) { this.issues = issues; } + } + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Issue { + @JsonProperty("code") + private String code; + + @JsonProperty("message") + private String message; + + @JsonProperty("resourceIds") + private List resourceIds; + + public Issue() {} + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public List getResourceIds() { return resourceIds; } + public void setResourceIds(List resourceIds) { this.resourceIds = resourceIds; } + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfileStatus.java b/src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfileStatus.java new file mode 100644 index 0000000000..00fe6dd67f --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/eks/model/FargateProfileStatus.java @@ -0,0 +1,5 @@ +package io.github.hectorvent.floci.services.eks.model; + +public enum FargateProfileStatus { + CREATING, ACTIVE, DELETING, CREATE_FAILED, DELETE_FAILED +} diff --git a/src/test/java/io/github/hectorvent/floci/services/eks/EksNodegroupIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/eks/EksNodegroupIntegrationTest.java index ee6ac0ab14..df7e95b579 100644 --- a/src/test/java/io/github/hectorvent/floci/services/eks/EksNodegroupIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/eks/EksNodegroupIntegrationTest.java @@ -7,11 +7,9 @@ import org.junit.jupiter.api.TestMethodOrder; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; /** @@ -41,7 +39,7 @@ void createCluster() { @Test @Order(2) - void createNodegroupRoutesToEksNotS3() { + void createNodeGroupRoutesToEksNotS3() { given().contentType(JSON) .body("{\"nodegroupName\":\"ng1\",\"subnets\":[\"subnet-abc\"],\"nodeRole\":\"" + NODE_ROLE + "\"," + "\"scalingConfig\":{\"minSize\":1,\"maxSize\":3,\"desiredSize\":2}}") @@ -52,14 +50,14 @@ void createNodegroupRoutesToEksNotS3() { .body("nodegroup.nodegroupName", equalTo("ng1")) .body("nodegroup.clusterName", equalTo(CLUSTER)) .body("nodegroup.nodegroupArn", containsString("nodegroup/" + CLUSTER + "/ng1/")) - .body("nodegroup.status", anyOf(is("ACTIVE"), is("CREATING"))) + .body("nodegroup.status", equalTo("ACTIVE")) .body("nodegroup.scalingConfig.desiredSize", equalTo(2)) .body("nodegroup.amiType", notNullValue()); } @Test @Order(3) - void listNodegroups() { + void listNodeGroups() { given().contentType(JSON) .when().get("/clusters/" + CLUSTER + "/node-groups") .then().statusCode(200) @@ -68,7 +66,7 @@ void listNodegroups() { @Test @Order(4) - void describeNodegroup() { + void describeNodeGroup() { given().contentType(JSON) .when().get("/clusters/" + CLUSTER + "/node-groups/ng1") .then().statusCode(200) @@ -79,7 +77,7 @@ void describeNodegroup() { @Test @Order(5) - void deleteNodegroup() { + void deleteNodeGroup() { given().contentType(JSON) .when().delete("/clusters/" + CLUSTER + "/node-groups/ng1") .then().statusCode(200) diff --git a/src/test/java/io/github/hectorvent/floci/services/eks/EksServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/eks/EksServiceTest.java index 7f7ff44242..a937e8f32a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/eks/EksServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/eks/EksServiceTest.java @@ -4,51 +4,134 @@ import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.RegionResolver; import io.github.hectorvent.floci.core.storage.InMemoryStorage; +import io.github.hectorvent.floci.core.storage.StorageBackend; import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.eks.model.ClusterStatus; import io.github.hectorvent.floci.services.eks.model.CreateClusterRequest; +import io.github.hectorvent.floci.services.eks.model.CreateFargateProfileRequest; +import io.github.hectorvent.floci.services.eks.model.CreateNodeGroupRequest; +import io.github.hectorvent.floci.services.eks.model.FargateProfile; +import io.github.hectorvent.floci.services.eks.model.FargateProfileStatus; import io.github.hectorvent.floci.services.eks.model.Cluster; import io.github.hectorvent.floci.services.eks.model.Nodegroup; +import io.github.hectorvent.floci.services.eks.model.NodegroupScalingConfig; import io.github.hectorvent.floci.services.eks.model.NodegroupStatus; +import com.fasterxml.jackson.core.type.TypeReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.when; class EksServiceTest { private EksService eksService; - private EmulatorConfig config; - private EksClusterManager clusterManager; @BeforeEach void setUp() { - StorageFactory storageFactory = Mockito.mock(StorageFactory.class); - // Return a distinct backend per create() call so the cluster and node-group stores - // don't share one instance (which would mix types and break node-group scans). - when(storageFactory.create(Mockito.anyString(), Mockito.anyString(), Mockito.any())) - .thenAnswer(invocation -> new InMemoryStorage<>()); - - config = Mockito.mock(EmulatorConfig.class); - var servicesConfig = Mockito.mock(EmulatorConfig.ServicesConfig.class); - var eksConfig = Mockito.mock(EmulatorConfig.EksServiceConfig.class); - - when(config.services()).thenReturn(servicesConfig); - when(servicesConfig.eks()).thenReturn(eksConfig); - when(eksConfig.mock()).thenReturn(true); - when(eksConfig.apiServerBasePort()).thenReturn(6500); - when(config.defaultRegion()).thenReturn("us-east-1"); - - clusterManager = Mockito.mock(EksClusterManager.class); + StorageFactory storageFactory = new StorageFactory(null, null) { + @Override + public StorageBackend create(String serviceName, String fileName, + TypeReference> typeReference) { + return new InMemoryStorage<>(); + } + }; + + EmulatorConfig config = testConfig(); + EksClusterManager clusterManager = null; RegionResolver regionResolver = new RegionResolver("us-east-1", "000000000000"); eksService = new EksService(storageFactory, config, regionResolver, clusterManager); } + private EmulatorConfig testConfig() { + EmulatorConfig.EksServiceConfig eksConfig = proxy(EmulatorConfig.EksServiceConfig.class, + (proxy, method, args) -> switch (method.getName()) { + case "enabled", "mock" -> true; + case "apiServerBasePort" -> 6500; + default -> defaultValue(method); + }); + EmulatorConfig.ServicesConfig servicesConfig = proxy(EmulatorConfig.ServicesConfig.class, + (proxy, method, args) -> switch (method.getName()) { + case "eks" -> eksConfig; + default -> defaultValue(method); + }); + return proxy(EmulatorConfig.class, (proxy, method, args) -> switch (method.getName()) { + case "services" -> servicesConfig; + case "defaultRegion" -> "us-east-1"; + case "defaultAccountId" -> "000000000000"; + default -> defaultValue(method); + }); + } + + @SuppressWarnings("unchecked") + private T proxy(Class type, InvocationHandler handler) { + return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[] { type }, (proxy, method, args) -> { + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "toString" -> type.getSimpleName() + "TestProxy"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> method.invoke(this, args); + }; + } + return handler.invoke(proxy, method, args); + }); + } + + private Object defaultValue(Method method) { + Class returnType = method.getReturnType(); + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + if (returnType == Optional.class) { + return Optional.empty(); + } + if (returnType == String.class) { + return ""; + } + return null; + } + + private void createTestCluster(String name) { + CreateClusterRequest clusterRequest = new CreateClusterRequest(); + clusterRequest.setName(name); + clusterRequest.setRoleArn("arn:aws:iam::000000000000:role/eks-role"); + eksService.createCluster(clusterRequest); + } + + private CreateNodeGroupRequest nodeGroupRequest(String name) { + CreateNodeGroupRequest request = new CreateNodeGroupRequest(); + request.setNodegroupName(name); + request.setNodeRole("arn:aws:iam::000000000000:role/role-name"); + request.setSubnets(List.of("subnet-0e2907431c9988b72", "subnet-04ad87f71c6e5ab4d")); + return request; + } + + private CreateFargateProfileRequest fargateProfileRequest(String name) { + FargateProfile.Selector selector = new FargateProfile.Selector(); + selector.setNamespace("default"); + selector.setLabels(Map.of("app", "api")); + + CreateFargateProfileRequest request = new CreateFargateProfileRequest(); + request.setFargateProfileName(name); + request.setPodExecutionRoleArn("arn:aws:iam::000000000000:role/eks-fargate-role"); + request.setSubnets(List.of("subnet-0e2907431c9988b72", "subnet-04ad87f71c6e5ab4d")); + request.setSelectors(List.of(selector)); + return request; + } + @Test void createCluster() { CreateClusterRequest req = new CreateClusterRequest(); @@ -148,79 +231,235 @@ void taggingOperations() { assertEquals("platform", tags.get("team")); } - // ──────────────────────────── Managed node groups (#1137) ──────────────────────────── + @Test + void createNodeGroupIncludesAwsShapeFields() { + createTestCluster("my-eks-cluster"); + + NodegroupScalingConfig scalingConfig = new NodegroupScalingConfig(); + scalingConfig.setMinSize(1); + scalingConfig.setMaxSize(3); + scalingConfig.setDesiredSize(1); + + CreateNodeGroupRequest nodeGroupRequest = new CreateNodeGroupRequest(); + nodeGroupRequest.setNodegroupName("my-eks-nodegroup"); + nodeGroupRequest.setNodeRole("arn:aws:iam::000000000000:role/role-name"); + nodeGroupRequest.setVersion("1.26"); + nodeGroupRequest.setReleaseVersion("1.26.12-20240329"); + nodeGroupRequest.setScalingConfig(scalingConfig); + nodeGroupRequest.setSubnets(List.of("subnet-0e2907431c9988b72", "subnet-04ad87f71c6e5ab4d")); + nodeGroupRequest.setInstanceTypes(List.of("t3.medium")); + + Nodegroup nodeGroup = eksService.createNodeGroup("my-eks-cluster", nodeGroupRequest); + + assertEquals("my-eks-nodegroup", nodeGroup.getNodegroupName()); + assertTrue(nodeGroup.getNodegroupArn().contains("nodegroup/my-eks-cluster/my-eks-nodegroup")); + assertEquals("my-eks-cluster", nodeGroup.getClusterName()); + assertEquals(NodegroupStatus.ACTIVE, nodeGroup.getStatus()); + assertEquals("ON_DEMAND", nodeGroup.getCapacityType()); + assertEquals(3, nodeGroup.getScalingConfig().getMaxSize()); + assertEquals(List.of("t3.medium"), nodeGroup.getInstanceTypes()); + assertEquals("AL2_x86_64", nodeGroup.getAmiType()); + assertEquals("arn:aws:iam::000000000000:role/role-name", nodeGroup.getNodeRole()); + assertEquals(20, nodeGroup.getDiskSize()); + Map resources = (Map) nodeGroup.getResources(); + List autoScalingGroups = (List) resources.get("autoScalingGroups"); + assertEquals(1, autoScalingGroups.size()); + assertTrue(((Map) autoScalingGroups.getFirst()).get("name").toString() + .startsWith("eks-my-eks-nodegroup-")); + assertEquals(List.of(), ((Map) nodeGroup.getHealth()).get("issues")); + assertEquals(1, ((Map) nodeGroup.getUpdateConfig()).get("maxUnavailable")); + assertEquals("my-eks-nodegroup", eksService.listNodeGroups("my-eks-cluster").getFirst()); + } - private Cluster newCluster(String name) { - CreateClusterRequest req = new CreateClusterRequest(); - req.setName(name); - req.setRoleArn("arn:aws:iam::000000000000:role/eks-role"); - req.setVersion("1.29"); - return eksService.createCluster(req); + @Test + void createNodeGroupDefaultsVersionFromCluster() { + CreateClusterRequest clusterRequest = new CreateClusterRequest(); + clusterRequest.setName("my-eks-cluster"); + clusterRequest.setRoleArn("arn:aws:iam::000000000000:role/eks-role"); + clusterRequest.setVersion("1.30"); + eksService.createCluster(clusterRequest); + + Nodegroup nodeGroup = eksService.createNodeGroup("my-eks-cluster", nodeGroupRequest("my-eks-nodegroup")); + + assertEquals("1.30", nodeGroup.getVersion()); + assertEquals("1.30-eks-1", nodeGroup.getReleaseVersion()); } - private Nodegroup newNodegroupRequest(String name) { - Nodegroup ng = new Nodegroup(); - ng.setNodegroupName(name); - ng.setSubnets(List.of("subnet-123")); - ng.setNodeRole("arn:aws:iam::000000000000:role/eks-node-role"); - return ng; + @Test + void nodeGroupLifecycleDescribeListDelete() { + createTestCluster("my-eks-cluster"); + eksService.createNodeGroup("my-eks-cluster", nodeGroupRequest("nodegroup-a")); + eksService.createNodeGroup("my-eks-cluster", nodeGroupRequest("nodegroup-b")); + + List names = eksService.listNodeGroups("my-eks-cluster"); + assertEquals(2, names.size()); + assertTrue(names.contains("nodegroup-a")); + assertTrue(names.contains("nodegroup-b")); + + Nodegroup described = eksService.describeNodeGroup("my-eks-cluster", "nodegroup-a"); + assertEquals("nodegroup-a", described.getNodegroupName()); + + Nodegroup deleted = eksService.deleteNodeGroup("my-eks-cluster", "nodegroup-a"); + assertEquals(NodegroupStatus.DELETING, deleted.getStatus()); + assertThrows(AwsException.class, () -> eksService.describeNodeGroup("my-eks-cluster", "nodegroup-a")); + assertEquals(List.of("nodegroup-b"), eksService.listNodeGroups("my-eks-cluster")); } @Test - void createNodegroupBecomesActiveWithDefaults() { - newCluster("ng-cluster"); - Nodegroup ng = eksService.createNodegroup("ng-cluster", newNodegroupRequest("ng1")); + void createNodeGroupDuplicateFails() { + createTestCluster("my-eks-cluster"); + CreateNodeGroupRequest request = nodeGroupRequest("my-eks-nodegroup"); + eksService.createNodeGroup("my-eks-cluster", request); - assertEquals("ng1", ng.getNodegroupName()); - assertEquals("ng-cluster", ng.getClusterName()); - assertEquals(NodegroupStatus.ACTIVE, ng.getStatus()); - assertTrue(ng.getNodegroupArn().contains("nodegroup/ng-cluster/ng1/")); - assertEquals("AL2_x86_64", ng.getAmiType()); - assertEquals("ON_DEMAND", ng.getCapacityType()); - assertEquals(20, ng.getDiskSize()); - assertEquals("1.29", ng.getVersion()); - assertEquals(List.of("t3.medium"), ng.getInstanceTypes()); - assertNotNull(ng.getScalingConfig()); - assertEquals(2, ng.getScalingConfig().getDesiredSize()); - assertNotNull(ng.getCreatedAt()); + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createNodeGroup("my-eks-cluster", request)); + assertEquals(409, ex.getHttpStatus()); } @Test - void createNodegroupOnMissingClusterFails() { - AwsException e = assertThrows(AwsException.class, - () -> eksService.createNodegroup("no-such-cluster", newNodegroupRequest("ng1"))); - assertEquals("ResourceNotFoundException", e.getErrorCode()); + void createNodeGroupWithoutClusterFails() { + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createNodeGroup("missing-cluster", nodeGroupRequest("my-eks-nodegroup"))); + assertEquals(404, ex.getHttpStatus()); } @Test - void createNodegroupDuplicateFails() { - newCluster("ng-cluster"); - eksService.createNodegroup("ng-cluster", newNodegroupRequest("ng1")); - AwsException e = assertThrows(AwsException.class, - () -> eksService.createNodegroup("ng-cluster", newNodegroupRequest("ng1"))); - assertEquals("ResourceInUseException", e.getErrorCode()); + void createNodeGroupWithoutNameFails() { + createTestCluster("my-eks-cluster"); + CreateNodeGroupRequest request = nodeGroupRequest(""); + + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createNodeGroup("my-eks-cluster", request)); + assertEquals(400, ex.getHttpStatus()); } @Test - void describeListAndDeleteNodegroup() { - newCluster("ng-cluster"); - eksService.createNodegroup("ng-cluster", newNodegroupRequest("ng1")); - eksService.createNodegroup("ng-cluster", newNodegroupRequest("ng2")); + void createNodeGroupWithoutNodeRoleFails() { + createTestCluster("my-eks-cluster"); + CreateNodeGroupRequest request = nodeGroupRequest("my-eks-nodegroup"); + request.setNodeRole(""); - assertEquals("ng1", eksService.describeNodegroup("ng-cluster", "ng1").getNodegroupName()); - assertEquals(List.of("ng1", "ng2"), - eksService.listNodegroups("ng-cluster").stream().sorted().toList()); + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createNodeGroup("my-eks-cluster", request)); + assertEquals(400, ex.getHttpStatus()); + } - Nodegroup deleted = eksService.deleteNodegroup("ng-cluster", "ng1"); - assertEquals(NodegroupStatus.DELETING, deleted.getStatus()); - assertThrows(AwsException.class, () -> eksService.describeNodegroup("ng-cluster", "ng1")); - assertEquals(List.of("ng2"), eksService.listNodegroups("ng-cluster")); + @Test + void createNodeGroupWithoutSubnetsFails() { + createTestCluster("my-eks-cluster"); + CreateNodeGroupRequest request = nodeGroupRequest("my-eks-nodegroup"); + request.setSubnets(List.of()); + + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createNodeGroup("my-eks-cluster", request)); + assertEquals(400, ex.getHttpStatus()); } @Test - void listNodegroupsOnMissingClusterFails() { - AwsException e = assertThrows(AwsException.class, - () -> eksService.listNodegroups("no-such-cluster")); - assertEquals("ResourceNotFoundException", e.getErrorCode()); + void describeAndDeleteNodeGroupNotFoundFail() { + createTestCluster("my-eks-cluster"); + + AwsException describe = assertThrows(AwsException.class, + () -> eksService.describeNodeGroup("my-eks-cluster", "missing-nodegroup")); + assertEquals(404, describe.getHttpStatus()); + + AwsException delete = assertThrows(AwsException.class, + () -> eksService.deleteNodeGroup("my-eks-cluster", "missing-nodegroup")); + assertEquals(404, delete.getHttpStatus()); + } + + @Test + void createFargateProfileIncludesAwsShapeFields() { + createTestCluster("my-eks-cluster"); + + CreateFargateProfileRequest profileRequest = fargateProfileRequest("my-fargate-profile"); + profileRequest.setTags(Map.of("env", "test")); + + FargateProfile profile = eksService.createFargateProfile("my-eks-cluster", profileRequest); + + assertEquals("my-fargate-profile", profile.getFargateProfileName()); + assertTrue(profile.getFargateProfileArn() + .matches("arn:aws:eks:[^:]+:[0-9]+:fargateprofile/my-eks-cluster/my-fargate-profile/.+")); + assertEquals("my-eks-cluster", profile.getClusterName()); + assertEquals(FargateProfileStatus.ACTIVE, profile.getStatus()); + assertEquals("arn:aws:iam::000000000000:role/eks-fargate-role", profile.getPodExecutionRoleArn()); + assertEquals(List.of("subnet-0e2907431c9988b72", "subnet-04ad87f71c6e5ab4d"), profile.getSubnets()); + assertEquals("default", profile.getSelectors().getFirst().getNamespace()); + assertEquals("api", profile.getSelectors().getFirst().getLabels().get("app")); + assertTrue(profile.getHealth().getIssues().isEmpty()); + assertEquals("test", profile.getTags().get("env")); + assertEquals("my-fargate-profile", eksService.listFargateProfiles("my-eks-cluster").getFirst()); + } + + @Test + void fargateProfileLifecycleDescribeListDelete() { + createTestCluster("my-eks-cluster"); + eksService.createFargateProfile("my-eks-cluster", fargateProfileRequest("profile-a")); + eksService.createFargateProfile("my-eks-cluster", fargateProfileRequest("profile-b")); + + List names = eksService.listFargateProfiles("my-eks-cluster"); + assertEquals(2, names.size()); + assertTrue(names.contains("profile-a")); + assertTrue(names.contains("profile-b")); + + FargateProfile described = eksService.describeFargateProfile("my-eks-cluster", "profile-a"); + assertEquals("profile-a", described.getFargateProfileName()); + + FargateProfile deleted = eksService.deleteFargateProfile("my-eks-cluster", "profile-a"); + assertEquals(FargateProfileStatus.DELETING, deleted.getStatus()); + assertThrows(AwsException.class, () -> eksService.describeFargateProfile("my-eks-cluster", "profile-a")); + assertEquals(List.of("profile-b"), eksService.listFargateProfiles("my-eks-cluster")); + } + + @Test + void createFargateProfileDuplicateFails() { + createTestCluster("my-eks-cluster"); + CreateFargateProfileRequest request = fargateProfileRequest("my-fargate-profile"); + eksService.createFargateProfile("my-eks-cluster", request); + + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createFargateProfile("my-eks-cluster", request)); + assertEquals(409, ex.getHttpStatus()); + } + + @Test + void createFargateProfileWithoutClusterFails() { + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createFargateProfile("missing-cluster", fargateProfileRequest("my-fargate-profile"))); + assertEquals(404, ex.getHttpStatus()); + } + + @Test + void createFargateProfileWithoutNameFails() { + createTestCluster("my-eks-cluster"); + CreateFargateProfileRequest request = fargateProfileRequest(""); + + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createFargateProfile("my-eks-cluster", request)); + assertEquals(400, ex.getHttpStatus()); + } + + @Test + void createFargateProfileWithoutPodExecutionRoleFails() { + createTestCluster("my-eks-cluster"); + CreateFargateProfileRequest request = fargateProfileRequest("my-fargate-profile"); + request.setPodExecutionRoleArn(""); + + AwsException ex = assertThrows(AwsException.class, + () -> eksService.createFargateProfile("my-eks-cluster", request)); + assertEquals(400, ex.getHttpStatus()); + } + + @Test + void describeAndDeleteFargateProfileNotFoundFail() { + createTestCluster("my-eks-cluster"); + + AwsException describe = assertThrows(AwsException.class, + () -> eksService.describeFargateProfile("my-eks-cluster", "missing-profile")); + assertEquals(404, describe.getHttpStatus()); + + AwsException delete = assertThrows(AwsException.class, + () -> eksService.deleteFargateProfile("my-eks-cluster", "missing-profile")); + assertEquals(404, delete.getHttpStatus()); } } From 350c50e3faf5e1e0cf235858a0e7dd1785ab24b9 Mon Sep 17 00:00:00 2001 From: Ioannis Lafiotis Date: Wed, 8 Jul 2026 16:10:15 +0200 Subject: [PATCH 2/2] docs: sync generated action tables --- docs/services/docdb.md | 1 + docs/services/elasticache.md | 2 ++ docs/services/rds.md | 3 +++ 3 files changed, 6 insertions(+) diff --git a/docs/services/docdb.md b/docs/services/docdb.md index b61480fb05..468ed7b59e 100644 --- a/docs/services/docdb.md +++ b/docs/services/docdb.md @@ -21,6 +21,7 @@ The management API shares the RDS Query endpoint (`POST /` with an `Action=` par | --- | --- | | `CreateDBCluster` | Create a DocumentDB cluster and start a MongoDB container | | `DescribeDBClusters` | List clusters and their connection details | +| `DescribeDBClusterSnapshots` | - | | `DeleteDBCluster` | Stop and remove a cluster (must have no instances) | | `ModifyDBCluster` | Update engine version or IAM auth setting | | `CreateDBInstance` | Add an instance to a cluster | diff --git a/docs/services/elasticache.md b/docs/services/elasticache.md index d56f20716c..3c8ffeb753 100644 --- a/docs/services/elasticache.md +++ b/docs/services/elasticache.md @@ -23,6 +23,8 @@ Floci manages real Valkey/Redis Docker containers and proxies TCP connections to | `CreateCacheCluster` | - | | `DescribeCacheClusters` | - | | `DeleteCacheCluster` | - | +| `DescribeCacheSubnetGroups` | - | +| `DescribeCacheParameterGroups` | - | ## Configuration diff --git a/docs/services/rds.md b/docs/services/rds.md index 22f224838d..5a9f5fff42 100644 --- a/docs/services/rds.md +++ b/docs/services/rds.md @@ -37,6 +37,9 @@ RDS Data API (`rds-data`) is documented separately because it uses REST JSON rou | `DeleteDBClusterParameterGroup` | - | | `ModifyDBClusterParameterGroup` | - | | `DescribeDBClusterParameters` | - | +| `DescribeDBSnapshots` | - | +| `DescribeDBProxies` | - | +| `DescribeDBClusterSnapshots` | - | | `AddTagsToResource` | Add tags to a DB resource | | `ListTagsForResource` | List tags for a DB resource | | `RemoveTagsFromResource` | Remove tags from a DB resource |