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()); + } }