From 288551d51d54166e69a568b84eceff590d76d5cc Mon Sep 17 00:00:00 2001 From: John Reynolds Date: Thu, 14 May 2026 13:34:48 +0530 Subject: [PATCH] feat: add RDS cluster parameter group actions Implement the missing Aurora cluster parameter group APIs that the Pulumi/Terraform AWS provider invokes via aws.rds.ClusterParameterGroup: - CreateDBClusterParameterGroup - DescribeDBClusterParameterGroups - DeleteDBClusterParameterGroup - ModifyDBClusterParameterGroup - DescribeDBClusterParameters Storage and handler logic mirrors the existing instance-level parameter group implementation. Without these actions, any IaC stack that defines an Aurora ClusterParameterGroup fails with UnsupportedOperation and never reaches Aurora cluster creation. Tests cover the new XML envelope shape, required-field validation, service round-trip, duplicate rejection, and parameter modification. --- .../floci/services/rds/RdsQueryHandler.java | 113 ++++++++++++++++++ .../floci/services/rds/RdsService.java | 46 +++++++ .../rds/model/DbClusterParameterGroup.java | 36 ++++++ .../services/rds/RdsQueryHandlerTest.java | 72 +++++++++++ .../floci/services/rds/RdsServiceTest.java | 53 ++++++++ 5 files changed, 320 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/rds/model/DbClusterParameterGroup.java diff --git a/src/main/java/io/github/hectorvent/floci/services/rds/RdsQueryHandler.java b/src/main/java/io/github/hectorvent/floci/services/rds/RdsQueryHandler.java index aff7318ee..c426e5194 100644 --- a/src/main/java/io/github/hectorvent/floci/services/rds/RdsQueryHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/rds/RdsQueryHandler.java @@ -6,6 +6,7 @@ import io.github.hectorvent.floci.core.common.AwsQueryResponse; import io.github.hectorvent.floci.core.common.XmlBuilder; import io.github.hectorvent.floci.services.rds.model.DbCluster; +import io.github.hectorvent.floci.services.rds.model.DbClusterParameterGroup; import io.github.hectorvent.floci.services.rds.model.DbEndpoint; import io.github.hectorvent.floci.services.rds.model.DbInstance; import io.github.hectorvent.floci.services.rds.model.DbInstanceStatus; @@ -55,6 +56,11 @@ public Response handle(String action, MultivaluedMap params) { case "DeleteDBParameterGroup" -> handleDeleteDbParameterGroup(params); case "ModifyDBParameterGroup" -> handleModifyDbParameterGroup(params); case "DescribeDBParameters" -> handleDescribeDbParameters(params); + case "CreateDBClusterParameterGroup" -> handleCreateDbClusterParameterGroup(params); + case "DescribeDBClusterParameterGroups" -> handleDescribeDbClusterParameterGroups(params); + case "DeleteDBClusterParameterGroup" -> handleDeleteDbClusterParameterGroup(params); + case "ModifyDBClusterParameterGroup" -> handleModifyDbClusterParameterGroup(params); + case "DescribeDBClusterParameters" -> handleDescribeDbClusterParameters(params); default -> AwsQueryResponse.error("UnsupportedOperation", "Operation " + action + " is not supported.", AwsNamespaces.RDS, 400); }; @@ -344,6 +350,101 @@ private Response handleDescribeDbParameters(MultivaluedMap param } } + // ── Cluster Parameter Groups ────────────────────────────────────────────── + + private Response handleCreateDbClusterParameterGroup(MultivaluedMap params) { + String name = params.getFirst("DBClusterParameterGroupName"); + String family = params.getFirst("DBParameterGroupFamily"); + String description = params.getFirst("Description"); + if (name == null || name.isBlank()) { + return AwsQueryResponse.error("InvalidParameterValue", "DBClusterParameterGroupName is required.", AwsNamespaces.RDS, 400); + } + try { + DbClusterParameterGroup group = service.createDbClusterParameterGroup(name, family, description); + String result = clusterParamGroupXml(group); + return Response.ok(AwsQueryResponse.envelope("CreateDBClusterParameterGroup", AwsNamespaces.RDS, result)).build(); + } catch (AwsException e) { + return AwsQueryResponse.error(e.getErrorCode(), e.getMessage(), AwsNamespaces.RDS, e.getHttpStatus()); + } + } + + private Response handleDescribeDbClusterParameterGroups(MultivaluedMap params) { + String filterName = params.getFirst("DBClusterParameterGroupName"); + try { + Collection result = service.listDbClusterParameterGroups(filterName); + XmlBuilder xml = new XmlBuilder().start("DBClusterParameterGroups"); + for (DbClusterParameterGroup g : result) { + xml.start("DBClusterParameterGroup").raw(clusterParamGroupInnerXml(g)).end("DBClusterParameterGroup"); + } + xml.end("DBClusterParameterGroups").start("Marker").end("Marker"); + return Response.ok(AwsQueryResponse.envelope("DescribeDBClusterParameterGroups", AwsNamespaces.RDS, xml.build())).build(); + } catch (AwsException e) { + return AwsQueryResponse.error(e.getErrorCode(), e.getMessage(), AwsNamespaces.RDS, e.getHttpStatus()); + } + } + + private Response handleDeleteDbClusterParameterGroup(MultivaluedMap params) { + String name = params.getFirst("DBClusterParameterGroupName"); + if (name == null || name.isBlank()) { + return AwsQueryResponse.error("InvalidParameterValue", "DBClusterParameterGroupName is required.", AwsNamespaces.RDS, 400); + } + try { + service.deleteDbClusterParameterGroup(name); + return Response.ok(AwsQueryResponse.envelopeNoResult("DeleteDBClusterParameterGroup", AwsNamespaces.RDS)).build(); + } catch (AwsException e) { + return AwsQueryResponse.error(e.getErrorCode(), e.getMessage(), AwsNamespaces.RDS, e.getHttpStatus()); + } + } + + private Response handleModifyDbClusterParameterGroup(MultivaluedMap params) { + String name = params.getFirst("DBClusterParameterGroupName"); + if (name == null || name.isBlank()) { + return AwsQueryResponse.error("InvalidParameterValue", "DBClusterParameterGroupName is required.", AwsNamespaces.RDS, 400); + } + Map parameters = new HashMap<>(); + for (int n = 1; ; n++) { + String paramName = params.getFirst("Parameters.member." + n + ".ParameterName"); + if (paramName == null) { + break; + } + String paramValue = params.getFirst("Parameters.member." + n + ".ParameterValue"); + if (paramValue != null) { + parameters.put(paramName, paramValue); + } + } + try { + DbClusterParameterGroup group = service.modifyDbClusterParameterGroup(name, parameters); + String result = new XmlBuilder() + .elem("DBClusterParameterGroupName", group.getDbClusterParameterGroupName()) + .build(); + return Response.ok(AwsQueryResponse.envelope("ModifyDBClusterParameterGroup", AwsNamespaces.RDS, result)).build(); + } catch (AwsException e) { + return AwsQueryResponse.error(e.getErrorCode(), e.getMessage(), AwsNamespaces.RDS, e.getHttpStatus()); + } + } + + private Response handleDescribeDbClusterParameters(MultivaluedMap params) { + String name = params.getFirst("DBClusterParameterGroupName"); + if (name == null || name.isBlank()) { + return AwsQueryResponse.error("InvalidParameterValue", "DBClusterParameterGroupName is required.", AwsNamespaces.RDS, 400); + } + try { + DbClusterParameterGroup group = service.getDbClusterParameterGroup(name); + XmlBuilder xml = new XmlBuilder().start("Parameters"); + for (Map.Entry entry : group.getParameters().entrySet()) { + xml.start("member") + .elem("ParameterName", entry.getKey()) + .elem("ParameterValue", entry.getValue()) + .elem("IsModifiable", true) + .end("member"); + } + xml.end("Parameters").start("Marker").end("Marker"); + return Response.ok(AwsQueryResponse.envelope("DescribeDBClusterParameters", AwsNamespaces.RDS, xml.build())).build(); + } catch (AwsException e) { + return AwsQueryResponse.error(e.getErrorCode(), e.getMessage(), AwsNamespaces.RDS, e.getHttpStatus()); + } + } + // ── XML builders ────────────────────────────────────────────────────────── private String dbInstanceXml(DbInstance i) { @@ -472,6 +573,18 @@ private String paramGroupInnerXml(DbParameterGroup g) { .build(); } + private String clusterParamGroupXml(DbClusterParameterGroup g) { + return new XmlBuilder().start("DBClusterParameterGroup").raw(clusterParamGroupInnerXml(g)).end("DBClusterParameterGroup").build(); + } + + private String clusterParamGroupInnerXml(DbClusterParameterGroup g) { + return new XmlBuilder() + .elem("DBClusterParameterGroupName", g.getDbClusterParameterGroupName()) + .elem("DBParameterGroupFamily", g.getDbParameterGroupFamily()) + .elem("Description", g.getDescription()) + .build(); + } + private String statusLabel(DbInstanceStatus status) { return switch (status) { case CREATING -> "creating"; diff --git a/src/main/java/io/github/hectorvent/floci/services/rds/RdsService.java b/src/main/java/io/github/hectorvent/floci/services/rds/RdsService.java index f9ac7e48c..095e26ca8 100644 --- a/src/main/java/io/github/hectorvent/floci/services/rds/RdsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/rds/RdsService.java @@ -9,6 +9,7 @@ import io.github.hectorvent.floci.services.rds.container.RdsContainerManager; import io.github.hectorvent.floci.services.rds.model.DatabaseEngine; import io.github.hectorvent.floci.services.rds.model.DbCluster; +import io.github.hectorvent.floci.services.rds.model.DbClusterParameterGroup; import io.github.hectorvent.floci.services.rds.model.DbEndpoint; import io.github.hectorvent.floci.services.rds.model.DbInstance; import io.github.hectorvent.floci.services.rds.model.DbInstanceStatus; @@ -38,6 +39,7 @@ public class RdsService { private final StorageBackend instances; private final StorageBackend clusters; private final StorageBackend parameterGroups; + private final StorageBackend clusterParameterGroups; private final RdsContainerManager containerManager; private final RdsProxyManager proxyManager; private final RegionResolver regionResolver; @@ -56,6 +58,7 @@ public RdsService(RdsContainerManager containerManager, this.instances = new InMemoryStorage<>(); this.clusters = new InMemoryStorage<>(); this.parameterGroups = new InMemoryStorage<>(); + this.clusterParameterGroups = new InMemoryStorage<>(); } // ── DB Instances ────────────────────────────────────────────────────────── @@ -369,6 +372,49 @@ public DbParameterGroup modifyDbParameterGroup(String name, return group; } + // ── Cluster Parameter Groups ────────────────────────────────────────────── + + public DbClusterParameterGroup createDbClusterParameterGroup(String name, String family, String description) { + if (clusterParameterGroups.get(name).isPresent()) { + throw new AwsException("DBParameterGroupAlreadyExists", + "DB cluster parameter group " + name + " already exists.", 400); + } + DbClusterParameterGroup group = new DbClusterParameterGroup(name, family, description); + clusterParameterGroups.put(name, group); + return group; + } + + public DbClusterParameterGroup getDbClusterParameterGroup(String name) { + return clusterParameterGroups.get(name).orElseThrow(() -> + new AwsException("DBParameterGroupNotFound", + "DB cluster parameter group " + name + " not found.", 404)); + } + + public Collection listDbClusterParameterGroups(String filterName) { + if (filterName != null && !filterName.isBlank()) { + return clusterParameterGroups.get(filterName).map(List::of).orElse(List.of()); + } + return clusterParameterGroups.scan(k -> true); + } + + public void deleteDbClusterParameterGroup(String name) { + if (clusterParameterGroups.get(name).isEmpty()) { + throw new AwsException("DBParameterGroupNotFound", + "DB cluster parameter group " + name + " not found.", 404); + } + clusterParameterGroups.delete(name); + } + + public DbClusterParameterGroup modifyDbClusterParameterGroup(String name, + java.util.Map parameters) { + DbClusterParameterGroup group = getDbClusterParameterGroup(name); + if (parameters != null) { + group.getParameters().putAll(parameters); + } + clusterParameterGroups.put(name, group); + return group; + } + // ── Password validation callbacks ───────────────────────────────────────── public boolean validateDbPassword(String instanceId, String clientUser, String password) { diff --git a/src/main/java/io/github/hectorvent/floci/services/rds/model/DbClusterParameterGroup.java b/src/main/java/io/github/hectorvent/floci/services/rds/model/DbClusterParameterGroup.java new file mode 100644 index 000000000..f4b2847f9 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/rds/model/DbClusterParameterGroup.java @@ -0,0 +1,36 @@ +package io.github.hectorvent.floci.services.rds.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.HashMap; +import java.util.Map; + +@RegisterForReflection +public class DbClusterParameterGroup { + + private String dbClusterParameterGroupName; + private String dbParameterGroupFamily; + private String description; + private Map parameters = new HashMap<>(); + + public DbClusterParameterGroup() {} + + public DbClusterParameterGroup(String dbClusterParameterGroupName, String dbParameterGroupFamily, + String description) { + this.dbClusterParameterGroupName = dbClusterParameterGroupName; + this.dbParameterGroupFamily = dbParameterGroupFamily; + this.description = description; + } + + public String getDbClusterParameterGroupName() { return dbClusterParameterGroupName; } + public void setDbClusterParameterGroupName(String dbClusterParameterGroupName) { this.dbClusterParameterGroupName = dbClusterParameterGroupName; } + + public String getDbParameterGroupFamily() { return dbParameterGroupFamily; } + public void setDbParameterGroupFamily(String dbParameterGroupFamily) { this.dbParameterGroupFamily = dbParameterGroupFamily; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Map getParameters() { return parameters; } + public void setParameters(Map parameters) { this.parameters = parameters; } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/rds/RdsQueryHandlerTest.java b/src/test/java/io/github/hectorvent/floci/services/rds/RdsQueryHandlerTest.java index 9718204cf..bd7e9591b 100644 --- a/src/test/java/io/github/hectorvent/floci/services/rds/RdsQueryHandlerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/rds/RdsQueryHandlerTest.java @@ -3,6 +3,7 @@ import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.services.rds.model.DbCluster; +import io.github.hectorvent.floci.services.rds.model.DbClusterParameterGroup; import io.github.hectorvent.floci.services.rds.model.DbInstance; import io.github.hectorvent.floci.services.rds.model.DbInstanceStatus; import io.github.hectorvent.floci.services.rds.model.DbParameterGroup; @@ -215,6 +216,77 @@ void unsupportedOperationReturnsQueryError() { assertTrue(((String) response.getEntity()).contains("UnsupportedOperation")); } + // ──────────────────────────── DBClusterParameterGroups ────────────────────── + + @Test + void describeDbClusterParameterGroups_usesDBClusterParameterGroupTag() { + DbClusterParameterGroup group = new DbClusterParameterGroup("cpg1", "aurora-postgresql16", "test cluster group"); + when(service.listDbClusterParameterGroups(null)).thenReturn(List.of(group)); + + Response response = handler.handle("DescribeDBClusterParameterGroups", params()); + + String body = (String) response.getEntity(); + assertTrue(body.contains(""), "Expected element in response"); + assertFalse(body.contains(""), "Did not expect wrapping DBClusterParameterGroup"); + } + + @Test + void createDbClusterParameterGroup_requiresName() { + Response response = handler.handle("CreateDBClusterParameterGroup", params()); + + assertEquals(400, response.getStatus()); + assertTrue(((String) response.getEntity()).contains("DBClusterParameterGroupName is required.")); + } + + @Test + void createDbClusterParameterGroup_passesArgumentsToService() { + DbClusterParameterGroup group = new DbClusterParameterGroup("cpg1", "aurora-postgresql16", "desc"); + when(service.createDbClusterParameterGroup("cpg1", "aurora-postgresql16", "desc")).thenReturn(group); + + MultivaluedMap p = params(); + p.add("DBClusterParameterGroupName", "cpg1"); + p.add("DBParameterGroupFamily", "aurora-postgresql16"); + p.add("Description", "desc"); + Response response = handler.handle("CreateDBClusterParameterGroup", p); + + verify(service).createDbClusterParameterGroup("cpg1", "aurora-postgresql16", "desc"); + String body = (String) response.getEntity(); + assertTrue(body.contains("cpg1")); + assertTrue(body.contains("aurora-postgresql16")); + } + + @Test + void modifyDbClusterParameterGroup_ignoresParametersWithoutValue() { + DbClusterParameterGroup group = new DbClusterParameterGroup("cpg1", "aurora-postgresql16", "test group"); + when(service.modifyDbClusterParameterGroup(eq("cpg1"), eq(java.util.Map.of("log_statement", "all")))) + .thenReturn(group); + + MultivaluedMap p = params(); + p.add("DBClusterParameterGroupName", "cpg1"); + p.add("Parameters.member.1.ParameterName", "log_statement"); + p.add("Parameters.member.1.ParameterValue", "all"); + p.add("Parameters.member.2.ParameterName", "ignored_without_value"); + handler.handle("ModifyDBClusterParameterGroup", p); + + verify(service).modifyDbClusterParameterGroup("cpg1", java.util.Map.of("log_statement", "all")); + } + + @Test + void describeDbClusterParameters_requiresParameterGroupName() { + Response response = handler.handle("DescribeDBClusterParameters", params()); + + assertEquals(400, response.getStatus()); + assertTrue(((String) response.getEntity()).contains("DBClusterParameterGroupName is required.")); + } + + @Test + void deleteDbClusterParameterGroup_requiresName() { + Response response = handler.handle("DeleteDBClusterParameterGroup", params()); + + assertEquals(400, response.getStatus()); + assertTrue(((String) response.getEntity()).contains("DBClusterParameterGroupName is required.")); + } + // ──────────────────────────── DBSubnetGroup shape ─────────────────────────── @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/rds/RdsServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/rds/RdsServiceTest.java index c81ba3d5d..5a1eef097 100644 --- a/src/test/java/io/github/hectorvent/floci/services/rds/RdsServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/rds/RdsServiceTest.java @@ -5,6 +5,7 @@ import io.github.hectorvent.floci.core.common.RegionResolver; import io.github.hectorvent.floci.services.rds.model.DatabaseEngine; import io.github.hectorvent.floci.services.rds.model.DbCluster; +import io.github.hectorvent.floci.services.rds.model.DbClusterParameterGroup; import io.github.hectorvent.floci.services.rds.container.RdsContainerHandle; import io.github.hectorvent.floci.services.rds.container.RdsContainerManager; import io.github.hectorvent.floci.services.rds.model.DbInstance; @@ -116,4 +117,56 @@ void deleteDbClusterFailsWhenMembersRemain() { assertEquals("InvalidDBClusterStateFault", exception.getErrorCode()); assertTrue(exception.getMessage().contains("still has DB instances")); } + + @Test + void createDbClusterParameterGroupRoundTrip() { + DbClusterParameterGroup created = rdsService.createDbClusterParameterGroup( + "cpg1", "aurora-postgresql16", "test cluster group"); + + assertEquals("cpg1", created.getDbClusterParameterGroupName()); + assertEquals("aurora-postgresql16", created.getDbParameterGroupFamily()); + + DbClusterParameterGroup fetched = rdsService.getDbClusterParameterGroup("cpg1"); + assertEquals("cpg1", fetched.getDbClusterParameterGroupName()); + + Collection listed = rdsService.listDbClusterParameterGroups(null); + assertEquals(1, listed.size()); + } + + @Test + void createDbClusterParameterGroupRejectsDuplicate() { + rdsService.createDbClusterParameterGroup("cpg1", "aurora-postgresql16", "desc"); + + AwsException exception = assertThrows(AwsException.class, () -> + rdsService.createDbClusterParameterGroup("cpg1", "aurora-postgresql16", "desc")); + + assertEquals("DBParameterGroupAlreadyExists", exception.getErrorCode()); + } + + @Test + void modifyDbClusterParameterGroupAppliesParameters() { + rdsService.createDbClusterParameterGroup("cpg1", "aurora-postgresql16", "desc"); + + DbClusterParameterGroup modified = rdsService.modifyDbClusterParameterGroup( + "cpg1", java.util.Map.of("log_statement", "all", "shared_preload_libraries", "pg_stat_statements")); + + assertEquals("all", modified.getParameters().get("log_statement")); + assertEquals("pg_stat_statements", modified.getParameters().get("shared_preload_libraries")); + } + + @Test + void deleteDbClusterParameterGroupMissingThrows() { + AwsException exception = assertThrows(AwsException.class, () -> + rdsService.deleteDbClusterParameterGroup("nonexistent")); + + assertEquals("DBParameterGroupNotFound", exception.getErrorCode()); + } + + @Test + void getDbClusterParameterGroupMissingThrows() { + AwsException exception = assertThrows(AwsException.class, () -> + rdsService.getDbClusterParameterGroup("nonexistent")); + + assertEquals("DBParameterGroupNotFound", exception.getErrorCode()); + } }