Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions aws-rds-dbcluster/aws-rds-dbcluster.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@
"description": "The identifier of the source DB cluster from which to restore.",
"type": "string"
},
"SourceDbClusterResourceId": {
"description": "The resource ID of the source DB cluster from which to restore.",
"type": "string"
},
"SourceRegion": {
"description": "The AWS Region which contains the source DB cluster when replicating a DB cluster. For example, us-east-1.",
"type": "string"
Expand Down Expand Up @@ -462,6 +466,7 @@
"/properties/RestoreType",
"/properties/SnapshotIdentifier",
"/properties/SourceDBClusterIdentifier",
"/properties/SourceDbClusterResourceId",
"/properties/SourceRegion",
"/properties/StorageEncrypted",
"/properties/UseLatestRestorableTime"
Expand Down Expand Up @@ -496,6 +501,7 @@
"rds:CreateDBCluster",
"rds:CreateDBInstance",
"rds:DescribeDBClusters",
"rds:DescribeDBClusterAutomatedBackups",
"rds:DescribeEvents",
"rds:EnableHttpEndpoint",
"rds:ModifyDBCluster",
Expand Down
1 change: 1 addition & 0 deletions aws-rds-dbcluster/resource-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Resources:
- "rds:CreateDBInstance"
- "rds:DeleteDBCluster"
- "rds:DeleteDBInstance"
- "rds:DescribeDBClusterAutomatedBackups"
- "rds:DescribeDBClusterSnapshots"
- "rds:DescribeDBClusters"
- "rds:DescribeDBSubnetGroups"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import software.amazon.awssdk.services.rds.RdsClient;
import software.amazon.awssdk.services.rds.model.ClusterPendingModifiedValues;
import software.amazon.awssdk.services.rds.model.DBCluster;
import software.amazon.awssdk.services.rds.model.DBClusterAutomatedBackup;
import software.amazon.awssdk.services.rds.model.DBClusterSnapshot;
import software.amazon.awssdk.services.rds.model.DBSubnetGroup;
import software.amazon.awssdk.services.rds.model.DbClusterAlreadyExistsException;
Expand All @@ -41,6 +42,7 @@
import software.amazon.awssdk.services.rds.model.DbInstanceNotFoundException;
import software.amazon.awssdk.services.rds.model.DbSubnetGroupDoesNotCoverEnoughAZsException;
import software.amazon.awssdk.services.rds.model.DbSubnetGroupNotFoundException;
import software.amazon.awssdk.services.rds.model.DescribeDbClusterAutomatedBackupsResponse;
import software.amazon.awssdk.services.rds.model.DescribeDbClusterSnapshotsResponse;
import software.amazon.awssdk.services.rds.model.DescribeDbClustersResponse;
import software.amazon.awssdk.services.rds.model.DescribeDbSubnetGroupsResponse;
Expand Down Expand Up @@ -346,6 +348,23 @@ protected DBCluster fetchSourceDBCluster(
return response.dbClusters().get(0);
}

protected DBClusterAutomatedBackup fetchSourceDBClusterAutomatedBackup(
final ProxyClient<RdsClient> proxyClient,
final ResourceModel model
) {
final DescribeDbClusterAutomatedBackupsResponse response = proxyClient.injectCredentialsAndInvokeV2(
Translator.describeDbClusterAutomatedBackupsRequest(model),
proxyClient.client()::describeDBClusterAutomatedBackups);
if (response.dbClusterAutomatedBackups().isEmpty()) {
throw DbClusterNotFoundException.builder()
.message(String.format(
"SourceDbCluster %s doesn't refer to an existing DB cluster or retained automated backup",
model.getSourceDbClusterResourceId()))
.build();
}
return response.dbClusterAutomatedBackups().get(0);
}

private boolean isCrossAccountSourceDBCluster(String awsCustomer, String sourceDBCluster) {
if (ArnHelper.isValidArn(sourceDBCluster)) {
return !StringUtils.equals(awsCustomer, ArnHelper.getAccountIdFromArn(sourceDBCluster));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
import software.amazon.awssdk.services.rds.RdsClient;
import software.amazon.awssdk.services.rds.model.ClusterScalabilityType;
import software.amazon.awssdk.services.rds.model.DBCluster;
import software.amazon.awssdk.services.rds.model.DBClusterAutomatedBackup;
import software.amazon.awssdk.services.rds.model.DBClusterSnapshot;
import software.amazon.awssdk.services.rds.model.ModifyDbClusterRequest;
import software.amazon.awssdk.services.rds.model.RestoreDbClusterFromSnapshotRequest;
import software.amazon.awssdk.services.rds.model.RestoreDbClusterToPointInTimeRequest;
import software.amazon.awssdk.services.rds.model.SourceType;
import software.amazon.cloudformation.exceptions.CfnInvalidRequestException;
import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
import software.amazon.cloudformation.proxy.CallChain;
import software.amazon.cloudformation.proxy.ProgressEvent;
Expand All @@ -37,6 +39,15 @@

public class CreateHandler extends BaseHandlerStd {

private static String DB_CLUSTER_STORAGE_ENC_NO_KMS_VALIDATION_MSG = "You can't create an encrypted DB cluster from an unencrypted DB snapshot without an AWS KMS key. " +
"Specify a valid KMS key ID for encryption, then try again. To use the default key, specify 'alias/aws/rds'.";
private static String DB_CLUSTER_RESTORE_ENC_NO_STORAGE_ENC_PROPERTY_VALIDATION_MSG = "Encryption must be enabled when restoring a DB cluster snapshot. " +
"Either enable encryption or remove the encryption parameter from the template.";
private static String DB_CLUSTER_KMS_KEY_NO_STORAGE_ENC_VALIDATION_MSG = "If you specify an AWS KMS key when restoring a DB snapshot, encryption must enabled. " +
"Either enable encryption or remove the encryption parameter from the template.";
private static String DB_CLUSTER_SOURCE_IDENTIFIERS_VALIDATION_MSG = "You must enter either the SourceDbClusterIdentifier or SourceDbClusterResourceId.";
private static String LIMITLESS_DB_CLUSTER_AUTOMATED_BACKUP_RESTORE_VALIDATION_MSG = "Automated backup retention isn't supported with Aurora Limitless Database clusters. " +
"If you set this property to limitless, you cannot set DeleteAutomatedBackups to false. To create a backup, use manual snapshots instead.";
public final static String DB_CLUSTER_VALIDATION_MISSING_PERMISSIONS_METRIC = "DBClusterValidationMissingPermissions";
public final static String LIMITLESS_ENGINE_VERSION_SUFFIX = "limitless";
public final static String ENGINE_VERSION_SEPERATOR = "-";
Expand Down Expand Up @@ -93,6 +104,9 @@ protected ProgressEvent<ResourceModel, CallbackContext> handleRequest(
callbackContext.setClusterScalabilityType(clusterScalabilityType);
}
if(ResourceModelHelper.isRestoreToPointInTime(model)) {
if (StringUtils.hasValue(model.getSourceDBClusterIdentifier()) && StringUtils.hasValue(model.getSourceDbClusterResourceId())) {
throw new CfnInvalidRequestException(DB_CLUSTER_SOURCE_IDENTIFIERS_VALIDATION_MSG);
}
ClusterScalabilityType clusterScalabilityType = getClusterScalabilityTypeFromSourceDBCluster(extractAwsAccountId(request), rdsProxyClient, model);
callbackContext.setClusterScalabilityType(clusterScalabilityType);
}
Expand All @@ -103,6 +117,7 @@ protected ProgressEvent<ResourceModel, CallbackContext> handleRequest(
m -> fetchDBCluster(rdsProxyClient, m),
p -> {
if (ResourceModelHelper.isRestoreToPointInTime(model)) {
validateRestoreDBClusterToPointInTime(extractAwsAccountId(request), rdsProxyClient, model);
return Tagging.createWithTaggingFallback(proxy, rdsProxyClient, this::restoreDbClusterToPointInTime, p, allTags);
} else if (ResourceModelHelper.isRestoreFromSnapshot(model)) {
return Tagging.createWithTaggingFallback(proxy, rdsProxyClient, this::restoreDbClusterFromSnapshot, p, allTags);
Expand Down Expand Up @@ -284,6 +299,33 @@ private String extractAwsAccountId(ValidatedRequest<ResourceModel> request) {
return null;
}

private void validateRestoreDBClusterToPointInTime(final String awsAccountId,
final ProxyClient<RdsClient> rdsProxyClient,
final ResourceModel desiredModel
) {
if (!StringUtils.hasValue(desiredModel.getSourceDBClusterIdentifier()) && !StringUtils.hasValue(desiredModel.getSourceDbClusterResourceId())) {
throw new CfnInvalidRequestException(DB_CLUSTER_SOURCE_IDENTIFIERS_VALIDATION_MSG);
}
try {
Boolean physicalStorageEncrypted = null;
if (StringUtils.hasValue(desiredModel.getSourceDBClusterIdentifier())) {
DBCluster dbCluster = ValidationUtils.fetchResourceForValidation(
() -> fetchSourceDBCluster(awsAccountId, rdsProxyClient, desiredModel),
"DescribeDBClusters");
physicalStorageEncrypted = dbCluster.storageEncrypted();
} else if (StringUtils.hasValue(desiredModel.getSourceDbClusterResourceId())) {
DBClusterAutomatedBackup dbClusterAutomatedBackup = ValidationUtils.fetchResourceForValidation(
() -> fetchSourceDBClusterAutomatedBackup(rdsProxyClient, desiredModel),
"DescribeDBClusterAutomatedBackups");
physicalStorageEncrypted = dbClusterAutomatedBackup.storageEncrypted();
}
final Boolean desiredStorageEncrypted = desiredModel.getStorageEncrypted();
final String desiredKMSKey = desiredModel.getKmsKeyId();
validateStorageEncryption(physicalStorageEncrypted, desiredStorageEncrypted, desiredKMSKey);
} catch (ValidationAccessException ex) {
ValidationUtils.emitMetric(requestLogger, DB_CLUSTER_VALIDATION_MISSING_PERMISSIONS_METRIC, ex);
}
}

protected ClusterScalabilityType getClusterScalabilityTypeFromSnapshot(final ProxyClient<RdsClient> rdsProxyClient, final ResourceModel resourceModel) {
// Source SnapshotIdentifier might belong to either DBClusterSnapshot or DBSnapshot.
Expand Down Expand Up @@ -318,8 +360,49 @@ protected ClusterScalabilityType getClusterScalabilityTypeFromEngineVersion(fina
return ClusterScalabilityType.STANDARD;
}

private static void validateStorageEncryption(final Boolean physicalStorageEncrypted,
final Boolean desiredStorageEncrypted,
final String desiredKMSKey) {
if (isOptingIntoEncryption(physicalStorageEncrypted, desiredStorageEncrypted)) {
if (StringUtils.isNullOrEmpty(desiredKMSKey)) {
throw new CfnInvalidRequestException(DB_CLUSTER_STORAGE_ENC_NO_KMS_VALIDATION_MSG);
}
}

if (isOptingOutOfEncryption(physicalStorageEncrypted, desiredStorageEncrypted)) {
throw new CfnInvalidRequestException(DB_CLUSTER_RESTORE_ENC_NO_STORAGE_ENC_PROPERTY_VALIDATION_MSG);
}

//Added validation as RestoreDbClusterToPointInTime/RestoreFromDBClusterSnapshot are supporting KMS Key
//encryption but not supporting StorageEncrypted flag.
if (BooleanUtils.isFalse(desiredStorageEncrypted) && StringUtils.hasValue(desiredKMSKey)) {
throw new CfnInvalidRequestException(DB_CLUSTER_KMS_KEY_NO_STORAGE_ENC_VALIDATION_MSG);
}
}

private static boolean isOptingIntoEncryption(final Boolean physicalStorageEncrypted,
final Boolean desiredStorageEncrypted) {
return BooleanUtils.isFalse(physicalStorageEncrypted) && BooleanUtils.isTrue(desiredStorageEncrypted);
}

private static boolean isOptingOutOfEncryption(final Boolean physicalStorageEncrypted,
final Boolean desiredStorageEncrypted) {
return BooleanUtils.isTrue(physicalStorageEncrypted) &&
BooleanUtils.isFalse(desiredStorageEncrypted);
}

protected ClusterScalabilityType getClusterScalabilityTypeFromSourceDBCluster(final String AwsAccountId, final ProxyClient<RdsClient> rdsProxyClient, final ResourceModel resourceModel) {
DBCluster cluster = fetchSourceDBCluster(AwsAccountId, rdsProxyClient, resourceModel);
return cluster.clusterScalabilityType() != null ? cluster.clusterScalabilityType() : ClusterScalabilityType.STANDARD;
if (StringUtils.hasValue(resourceModel.getSourceDBClusterIdentifier())) {
DBCluster cluster = fetchSourceDBCluster(AwsAccountId, rdsProxyClient, resourceModel);
return cluster.clusterScalabilityType() != null ? cluster.clusterScalabilityType() : ClusterScalabilityType.STANDARD;
} else if (StringUtils.hasValue(resourceModel.getSourceDbClusterResourceId())) {
DBClusterAutomatedBackup backup = fetchSourceDBClusterAutomatedBackup(rdsProxyClient, resourceModel);
ClusterScalabilityType scalabilityType = getClusterScalabilityTypeFromEngineVersion(backup.engineVersion());
if ("retained".equals(backup.status()) && scalabilityType.equals(ClusterScalabilityType.LIMITLESS)) {
throw new CfnInvalidRequestException(LIMITLESS_DB_CLUSTER_AUTOMATED_BACKUP_RESTORE_VALIDATION_MSG);
}
return scalabilityType;
}
throw new CfnInvalidRequestException(DB_CLUSTER_SOURCE_IDENTIFIERS_VALIDATION_MSG);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import software.amazon.awssdk.services.rds.model.CloudwatchLogsExportConfiguration;
import software.amazon.awssdk.services.rds.model.CreateDbClusterRequest;
import software.amazon.awssdk.services.rds.model.DeleteDbClusterRequest;
import software.amazon.awssdk.services.rds.model.DescribeDbClusterAutomatedBackupsRequest;
import software.amazon.awssdk.services.rds.model.DescribeDbClusterSnapshotsRequest;
import software.amazon.awssdk.services.rds.model.DescribeDbClustersRequest;
import software.amazon.awssdk.services.rds.model.DescribeDbInstancesRequest;
Expand Down Expand Up @@ -125,6 +126,7 @@ static RestoreDbClusterToPointInTimeRequest restoreDbClusterToPointInTimeRequest
.scalingConfiguration(translateScalingConfigurationToSdk(model.getScalingConfiguration()))
.serverlessV2ScalingConfiguration(translateServerlessV2ScalingConfiguration(model.getServerlessV2ScalingConfiguration()))
.sourceDBClusterIdentifier(model.getSourceDBClusterIdentifier())
.sourceDbClusterResourceId(model.getSourceDbClusterResourceId())
.storageType(model.getStorageType())
.restoreType(model.getRestoreType())
.tags(Tagging.translateTagsToSdk(tagSet))
Expand Down Expand Up @@ -475,6 +477,13 @@ static DescribeDbClustersRequest describeSourceDbClustersCrossAccountRequest(
.build();
}

static DescribeDbClusterAutomatedBackupsRequest describeDbClusterAutomatedBackupsRequest(
final ResourceModel model
) {
return DescribeDbClusterAutomatedBackupsRequest.builder()
.dbClusterResourceId(model.getDBClusterResourceId())
.build();
}

static EnableHttpEndpointRequest enableHttpEndpointRequest(
final String clusterArn
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
public class ResourceModelHelper {

public static boolean isRestoreToPointInTime(final ResourceModel model) {
return StringUtils.hasValue(model.getSourceDBClusterIdentifier());
return StringUtils.hasValue(model.getSourceDBClusterIdentifier()) || StringUtils.hasValue(model.getSourceDbClusterResourceId());
}

public static boolean isRestoreFromSnapshot(final ResourceModel model) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ public abstract class AbstractHandlerTest extends AbstractTestBase<DBCluster, Re
protected static final String SNAPSHOT_IDENTIFIER;
protected static final String INSTANCE_SNAPSHOT_IDENTIFIER;
protected static final String SOURCE_IDENTIFIER;
protected static final String SOURCE_DB_CLUSTER_RESOURCE_ID;
protected static final String RESTORE_TO_TIME;
protected static final String ENGINE;
protected static final String ENGINE_AURORA_POSTGRESQL;
protected static final String ENGINE_MODE;
Expand Down Expand Up @@ -141,6 +143,8 @@ public abstract class AbstractHandlerTest extends AbstractTestBase<DBCluster, Re
SNAPSHOT_IDENTIFIER = "my-sample-dbcluster-snapshot";
INSTANCE_SNAPSHOT_IDENTIFIER = "arn:aws:rds:us-east-1:123456789012:snapshot:my-db-snapshot";
SOURCE_IDENTIFIER = "my-source-dbcluster-identifier";
SOURCE_DB_CLUSTER_RESOURCE_ID = "my-source-dbcluster-resource-id";
RESTORE_TO_TIME = "2025-01-01T12:00:00.000Z";
ENGINE = "aurora";
ENGINE_AURORA_POSTGRESQL = "aurora-postgresql";
ENGINE_MODE = "serverless";
Expand Down
Loading
Loading