use crate::aws::aws_request_utils::{AwsRequestBuilderExt, AwsSignConfig};
use crate::aws::credential_provider::AwsCredentialProvider;
use alien_client_core::{ErrorData, Result};
use alien_error::ContextError;
use reqwest::{Client, Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use std::collections::HashMap;
#[cfg(feature = "test-utils")]
use mockall::automock;
const RDS_API_VERSION: &str = "2014-10-31";
pub const AURORA_POSTGRESQL_ENGINE: &str = "aurora-postgresql";
pub const SERVERLESS_INSTANCE_CLASS: &str = "db.serverless";
#[cfg_attr(feature = "test-utils", automock)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait RdsApi: Send + Sync + std::fmt::Debug {
async fn create_db_subnet_group(&self, request: CreateDbSubnetGroupRequest) -> Result<()>;
async fn delete_db_subnet_group(&self, name: &str) -> Result<()>;
async fn create_db_cluster(&self, request: CreateDbClusterRequest) -> Result<DbCluster>;
async fn modify_db_cluster(&self, request: ModifyDbClusterRequest) -> Result<()>;
async fn delete_db_cluster(&self, request: DeleteDbClusterRequest) -> Result<()>;
async fn describe_db_clusters(&self, identifier: &str) -> Result<Vec<DbCluster>>;
async fn create_db_instance(&self, request: CreateDbInstanceRequest) -> Result<()>;
async fn delete_db_instance(&self, request: DeleteDbInstanceRequest) -> Result<()>;
async fn describe_db_instances(&self, cluster_identifier: &str) -> Result<Vec<DbInstance>>;
}
#[derive(Debug, Clone)]
pub struct CreateDbSubnetGroupRequest {
pub name: String,
pub description: String,
pub subnet_ids: Vec<String>,
pub tags: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct CreateDbClusterRequest {
pub identifier: String,
pub engine_version: String,
pub master_username: String,
pub master_user_password: String,
pub database_name: String,
pub db_subnet_group_name: String,
pub vpc_security_group_ids: Vec<String>,
pub max_capacity: f64,
pub backup_retention_days: u16,
pub tags: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ModifyDbClusterRequest {
pub identifier: String,
pub engine_version: Option<String>,
pub max_capacity: Option<f64>,
pub master_user_password: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DeleteDbClusterRequest {
pub identifier: String,
}
#[derive(Debug, Clone)]
pub struct CreateDbInstanceRequest {
pub identifier: String,
pub cluster_identifier: String,
pub engine_version: String,
}
#[derive(Debug, Clone)]
pub struct DeleteDbInstanceRequest {
pub identifier: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub struct DbCluster {
#[serde(rename = "DBClusterIdentifier")]
pub identifier: String,
pub status: String,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub reader_endpoint: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub engine_version: Option<String>,
#[serde(default, rename = "ServerlessV2ScalingConfiguration")]
pub serverless_v2_scaling_configuration: Option<ServerlessV2ScalingConfiguration>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub struct ServerlessV2ScalingConfiguration {
pub max_capacity: f64,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub struct DbInstance {
#[serde(rename = "DBInstanceIdentifier")]
pub identifier: String,
#[serde(rename = "DBInstanceStatus")]
pub status: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CreateDbClusterEnvelope {
#[serde(rename = "CreateDBClusterResult")]
result: CreateDbClusterResult,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CreateDbClusterResult {
#[serde(rename = "DBCluster")]
db_cluster: DbCluster,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DescribeDbClustersEnvelope {
#[serde(rename = "DescribeDBClustersResult")]
result: DescribeDbClustersResult,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DescribeDbClustersResult {
#[serde(rename = "DBClusters", default)]
db_clusters: DbClusterList,
}
#[derive(Debug, Default, Deserialize)]
struct DbClusterList {
#[serde(rename = "DBCluster", default)]
members: Vec<DbCluster>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DescribeDbInstancesEnvelope {
#[serde(rename = "DescribeDBInstancesResult")]
result: DescribeDbInstancesResult,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DescribeDbInstancesResult {
#[serde(rename = "DBInstances", default)]
db_instances: DbInstanceList,
}
#[derive(Debug, Default, Deserialize)]
struct DbInstanceList {
#[serde(rename = "DBInstance", default)]
members: Vec<DbInstance>,
}
#[derive(Debug, Deserialize)]
struct RdsErrorEnvelope {
#[serde(rename = "Error")]
error: RdsError,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RdsError {
code: String,
message: String,
}
fn indexed_members(
form: &mut HashMap<String, String>,
prefix: &str,
member: &str,
values: &[String],
) {
for (i, value) in values.iter().enumerate() {
form.insert(format!("{prefix}.{member}.{}", i + 1), value.clone());
}
}
fn tag_members(form: &mut HashMap<String, String>, tags: &HashMap<String, String>) {
for (i, (key, value)) in tags.iter().enumerate() {
form.insert(format!("Tags.Tag.{}.Key", i + 1), key.clone());
form.insert(format!("Tags.Tag.{}.Value", i + 1), value.clone());
}
}
fn base_form(action: &str) -> HashMap<String, String> {
HashMap::from([
("Action".to_string(), action.to_string()),
("Version".to_string(), RDS_API_VERSION.to_string()),
])
}
fn create_db_subnet_group_form(r: &CreateDbSubnetGroupRequest) -> HashMap<String, String> {
let mut form = base_form("CreateDBSubnetGroup");
form.insert("DBSubnetGroupName".into(), r.name.clone());
form.insert("DBSubnetGroupDescription".into(), r.description.clone());
indexed_members(&mut form, "SubnetIds", "SubnetIdentifier", &r.subnet_ids);
tag_members(&mut form, &r.tags);
form
}
fn create_db_cluster_form(r: &CreateDbClusterRequest) -> HashMap<String, String> {
let mut form = base_form("CreateDBCluster");
form.insert("DBClusterIdentifier".into(), r.identifier.clone());
form.insert("Engine".into(), AURORA_POSTGRESQL_ENGINE.to_string());
form.insert("EngineVersion".into(), r.engine_version.clone());
form.insert("MasterUsername".into(), r.master_username.clone());
form.insert("MasterUserPassword".into(), r.master_user_password.clone());
form.insert("DatabaseName".into(), r.database_name.clone());
form.insert("DBSubnetGroupName".into(), r.db_subnet_group_name.clone());
indexed_members(
&mut form,
"VpcSecurityGroupIds",
"VpcSecurityGroupId",
&r.vpc_security_group_ids,
);
form.insert(
"ServerlessV2ScalingConfiguration.MinCapacity".into(),
"0".into(),
);
form.insert(
"ServerlessV2ScalingConfiguration.MaxCapacity".into(),
format!("{}", r.max_capacity),
);
form.insert(
"BackupRetentionPeriod".into(),
r.backup_retention_days.to_string(),
);
form.insert("StorageEncrypted".into(), "true".into());
tag_members(&mut form, &r.tags);
form
}
fn modify_db_cluster_form(r: &ModifyDbClusterRequest) -> HashMap<String, String> {
let mut form = base_form("ModifyDBCluster");
form.insert("DBClusterIdentifier".into(), r.identifier.clone());
form.insert("ApplyImmediately".into(), "true".into());
if let Some(version) = &r.engine_version {
form.insert("EngineVersion".into(), version.clone());
form.insert("AllowMajorVersionUpgrade".into(), "true".into());
}
if let Some(max_capacity) = r.max_capacity {
form.insert(
"ServerlessV2ScalingConfiguration.MinCapacity".into(),
"0".into(),
);
form.insert(
"ServerlessV2ScalingConfiguration.MaxCapacity".into(),
format!("{max_capacity}"),
);
}
if let Some(password) = &r.master_user_password {
form.insert("MasterUserPassword".into(), password.clone());
}
form
}
fn create_db_instance_form(r: &CreateDbInstanceRequest) -> HashMap<String, String> {
let mut form = base_form("CreateDBInstance");
form.insert("DBInstanceIdentifier".into(), r.identifier.clone());
form.insert("DBClusterIdentifier".into(), r.cluster_identifier.clone());
form.insert("Engine".into(), AURORA_POSTGRESQL_ENGINE.to_string());
form.insert("EngineVersion".into(), r.engine_version.clone());
form.insert(
"DBInstanceClass".into(),
SERVERLESS_INSTANCE_CLASS.to_string(),
);
form.insert("PubliclyAccessible".into(), "false".into());
form
}
fn delete_db_cluster_form(r: &DeleteDbClusterRequest) -> HashMap<String, String> {
let mut form = base_form("DeleteDBCluster");
form.insert("DBClusterIdentifier".into(), r.identifier.clone());
form.insert("SkipFinalSnapshot".into(), "true".into());
form
}
fn delete_db_instance_form(r: &DeleteDbInstanceRequest) -> HashMap<String, String> {
let mut form = base_form("DeleteDBInstance");
form.insert("DBInstanceIdentifier".into(), r.identifier.clone());
form.insert("SkipFinalSnapshot".into(), "true".into());
form
}
#[derive(Debug, Clone)]
pub struct RdsClient {
client: Client,
credentials: AwsCredentialProvider,
}
impl RdsClient {
pub fn new(client: Client, credentials: AwsCredentialProvider) -> Self {
Self {
client,
credentials,
}
}
fn sign_config(&self) -> AwsSignConfig {
AwsSignConfig {
service_name: "rds".into(),
region: self.credentials.region().to_string(),
credentials: self.credentials.get_credentials(),
signing_region: None,
}
}
fn get_base_url(&self) -> String {
if let Some(url) = self.credentials.get_service_endpoint_option("rds") {
url.to_string()
} else {
format!("https://rds.{}.amazonaws.com", self.credentials.region())
}
}
fn get_host(&self) -> String {
format!("rds.{}.amazonaws.com", self.credentials.region())
}
async fn send_form<T: DeserializeOwned + Send + 'static>(
&self,
form_data: HashMap<String, String>,
operation: &str,
resource: &str,
) -> Result<T> {
self.credentials.ensure_fresh().await?;
let body = form_urlencoded::Serializer::new(String::new())
.extend_pairs(form_data.iter())
.finish();
let builder = self
.client
.request(Method::POST, &self.get_base_url())
.host(&self.get_host())
.content_type_form()
.content_sha256(&body)
.body(body);
let result =
crate::aws::aws_request_utils::sign_send_xml(builder, &self.sign_config()).await;
Self::map_result(result, operation, resource)
}
async fn send_form_no_body(
&self,
form_data: HashMap<String, String>,
operation: &str,
resource: &str,
) -> Result<()> {
self.credentials.ensure_fresh().await?;
let body = form_urlencoded::Serializer::new(String::new())
.extend_pairs(form_data.iter())
.finish();
let builder = self
.client
.request(Method::POST, &self.get_base_url())
.host(&self.get_host())
.content_type_form()
.content_sha256(&body)
.body(body);
let result =
crate::aws::aws_request_utils::sign_send_no_response(builder, &self.sign_config())
.await;
Self::map_result(result, operation, resource)
}
fn map_result<T>(result: Result<T>, operation: &str, resource: &str) -> Result<T> {
let result = alien_client_core::redact_request_body(result);
let Err(e) = result else {
return result;
};
let Some(ErrorData::HttpResponseError {
http_status,
http_response_text: Some(text),
..
}) = &e.error
else {
return Err(e);
};
let status =
StatusCode::from_u16(*http_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
match Self::map_rds_error(status, text, resource) {
Some(mapped) => Err(e.context(mapped)),
None => Err(e.context(ErrorData::GenericError {
message: format!("RDS {operation} failed"),
})),
}
}
fn map_rds_error(status: StatusCode, body: &str, resource: &str) -> Option<ErrorData> {
if let Ok(parsed) = quick_xml::de::from_str::<RdsErrorEnvelope>(body) {
let code = parsed.error.code.as_str();
if code.ends_with("NotFoundFault") {
return Some(ErrorData::RemoteResourceNotFound {
resource_type: "RDS Resource".into(),
resource_name: resource.into(),
});
}
if code.ends_with("StateFault")
|| code.ends_with("InUseFault")
|| code.ends_with("AlreadyExistsFault")
{
return Some(ErrorData::RemoteResourceConflict {
resource_type: "RDS Resource".into(),
resource_name: resource.into(),
message: format!("{}: {}", code, parsed.error.message),
});
}
if matches!(
code,
"Throttling"
| "ThrottlingException"
| "RequestLimitExceeded"
| "ProvisionedThroughputExceededException"
) || code.contains("Throttl")
|| code.ends_with("LimitExceeded")
{
return Some(ErrorData::RateLimitExceeded {
message: format!("{}: {}", code, parsed.error.message),
});
}
if code.starts_with("Invalid")
|| code.contains("Validation")
|| code.contains("MalformedQuery")
{
return Some(ErrorData::InvalidInput {
message: format!("{}: {}", code, parsed.error.message),
field_name: None,
});
}
if code.ends_with("QuotaExceededFault") {
return Some(ErrorData::QuotaExceeded {
message: format!("{}: {}", code, parsed.error.message),
});
}
if code.starts_with("Insufficient") || code.ends_with("CapacityFault") {
return Some(ErrorData::RemoteServiceUnavailable {
message: format!("{}: {}", code, parsed.error.message),
});
}
return Some(ErrorData::GenericError {
message: format!("{}: {}", code, parsed.error.message),
});
}
match status {
StatusCode::NOT_FOUND => Some(ErrorData::RemoteResourceNotFound {
resource_type: "RDS Resource".into(),
resource_name: resource.into(),
}),
StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => {
Some(ErrorData::RemoteAccessDenied {
resource_type: "RDS Resource".into(),
resource_name: resource.into(),
})
}
_ => None,
}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl RdsApi for RdsClient {
async fn create_db_subnet_group(&self, request: CreateDbSubnetGroupRequest) -> Result<()> {
let name = request.name.clone();
self.send_form_no_body(
create_db_subnet_group_form(&request),
"CreateDBSubnetGroup",
&name,
)
.await
}
async fn delete_db_subnet_group(&self, name: &str) -> Result<()> {
let mut form = base_form("DeleteDBSubnetGroup");
form.insert("DBSubnetGroupName".into(), name.to_string());
self.send_form_no_body(form, "DeleteDBSubnetGroup", name)
.await
}
async fn create_db_cluster(&self, request: CreateDbClusterRequest) -> Result<DbCluster> {
let id = request.identifier.clone();
let envelope: CreateDbClusterEnvelope = self
.send_form(create_db_cluster_form(&request), "CreateDBCluster", &id)
.await?;
Ok(envelope.result.db_cluster)
}
async fn modify_db_cluster(&self, request: ModifyDbClusterRequest) -> Result<()> {
self.send_form_no_body(
modify_db_cluster_form(&request),
"ModifyDBCluster",
&request.identifier,
)
.await
}
async fn delete_db_cluster(&self, request: DeleteDbClusterRequest) -> Result<()> {
let id = request.identifier.clone();
self.send_form_no_body(delete_db_cluster_form(&request), "DeleteDBCluster", &id)
.await
}
async fn describe_db_clusters(&self, identifier: &str) -> Result<Vec<DbCluster>> {
let mut form = base_form("DescribeDBClusters");
form.insert("DBClusterIdentifier".into(), identifier.to_string());
let envelope: DescribeDbClustersEnvelope = self
.send_form(form, "DescribeDBClusters", identifier)
.await?;
Ok(envelope.result.db_clusters.members)
}
async fn create_db_instance(&self, request: CreateDbInstanceRequest) -> Result<()> {
let id = request.identifier.clone();
self.send_form_no_body(create_db_instance_form(&request), "CreateDBInstance", &id)
.await
}
async fn delete_db_instance(&self, request: DeleteDbInstanceRequest) -> Result<()> {
let id = request.identifier.clone();
self.send_form_no_body(delete_db_instance_form(&request), "DeleteDBInstance", &id)
.await
}
async fn describe_db_instances(&self, cluster_identifier: &str) -> Result<Vec<DbInstance>> {
let mut form = base_form("DescribeDBInstances");
form.insert("Filters.Filter.1.Name".into(), "db-cluster-id".into());
form.insert(
"Filters.Filter.1.Values.Value.1".into(),
cluster_identifier.to_string(),
);
let envelope: DescribeDbInstancesEnvelope = self
.send_form(form, "DescribeDBInstances", cluster_identifier)
.await?;
Ok(envelope.result.db_instances.members)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cluster_request() -> CreateDbClusterRequest {
CreateDbClusterRequest {
identifier: "stack-db".into(),
engine_version: "17.4".into(),
master_username: "alien".into(),
master_user_password: "secret".into(),
database_name: "db".into(),
db_subnet_group_name: "stack-subnets".into(),
vpc_security_group_ids: vec!["sg-1".into(), "sg-2".into()],
max_capacity: 4.0,
backup_retention_days: 7,
tags: HashMap::new(),
}
}
#[test]
fn create_cluster_form_pins_min_capacity_zero_and_engine() {
let form = create_db_cluster_form(&cluster_request());
assert_eq!(form["Action"], "CreateDBCluster");
assert_eq!(form["Engine"], "aurora-postgresql");
assert_eq!(form["ServerlessV2ScalingConfiguration.MinCapacity"], "0");
assert_eq!(form["ServerlessV2ScalingConfiguration.MaxCapacity"], "4");
assert_eq!(form["BackupRetentionPeriod"], "7");
assert_eq!(form["VpcSecurityGroupIds.VpcSecurityGroupId.1"], "sg-1");
assert_eq!(form["VpcSecurityGroupIds.VpcSecurityGroupId.2"], "sg-2");
}
#[test]
fn modify_cluster_form_moves_acu_ceiling_and_pins_min_zero() {
let form = modify_db_cluster_form(&ModifyDbClusterRequest {
identifier: "stack-db".into(),
engine_version: None,
max_capacity: Some(8.0),
master_user_password: None,
});
assert_eq!(form["Action"], "ModifyDBCluster");
assert_eq!(form["ApplyImmediately"], "true");
assert_eq!(form["ServerlessV2ScalingConfiguration.MinCapacity"], "0");
assert_eq!(form["ServerlessV2ScalingConfiguration.MaxCapacity"], "8");
assert!(!form.contains_key("EngineVersion"));
assert!(!form.contains_key("MasterUserPassword"));
}
#[test]
fn modify_cluster_form_carries_master_password_applied_immediately() {
let form = modify_db_cluster_form(&ModifyDbClusterRequest {
identifier: "stack-db".into(),
engine_version: None,
max_capacity: None,
master_user_password: Some("s3cret-pw".into()),
});
assert_eq!(form["ApplyImmediately"], "true");
assert_eq!(form["MasterUserPassword"], "s3cret-pw");
}
#[test]
fn modify_cluster_form_omits_scaling_when_no_memory_change() {
let form = modify_db_cluster_form(&ModifyDbClusterRequest {
identifier: "stack-db".into(),
engine_version: None,
max_capacity: None,
master_user_password: None,
});
assert!(!form.contains_key("ServerlessV2ScalingConfiguration.MaxCapacity"));
}
#[test]
fn delete_cluster_skips_final_snapshot() {
let form = delete_db_cluster_form(&DeleteDbClusterRequest {
identifier: "stack-db".into(),
});
assert_eq!(form["Action"], "DeleteDBCluster");
assert_eq!(form["SkipFinalSnapshot"], "true");
}
#[test]
fn create_instance_uses_serverless_class_and_is_never_public() {
let form = create_db_instance_form(&CreateDbInstanceRequest {
identifier: "stack-db-1".into(),
cluster_identifier: "stack-db".into(),
engine_version: "17.4".into(),
});
assert_eq!(form["DBInstanceClass"], "db.serverless");
assert_eq!(form["Engine"], "aurora-postgresql");
assert_eq!(form["PubliclyAccessible"], "false");
}
#[test]
fn map_result_strips_master_password_from_error_chain() {
use alien_error::AlienError;
const PW: &str = "Sup3rSecret-MasterPassword!";
let raw = AlienError::new(ErrorData::HttpResponseError {
message: "Request failed with HTTP 400".into(),
url: "https://rds.us-east-1.amazonaws.com/".into(),
http_status: 400,
http_request_text: Some(format!("Action=CreateDBCluster&MasterUserPassword={PW}")),
http_response_text: Some("<Error><Code>InvalidParameterValue</Code></Error>".into()),
});
let mapped = RdsClient::map_result::<()>(Err(raw), "CreateDBCluster", "stack-db")
.expect_err("an error result stays an error");
let json = serde_json::to_string(&mapped).expect("serialize");
assert!(
!json.contains(PW),
"master password leaked through map_result: {json}"
);
}
#[test]
fn describe_clusters_parses_xml() {
let xml = r#"<DescribeDBClustersResponse><DescribeDBClustersResult><DBClusters>
<DBCluster><DBClusterIdentifier>stack-db</DBClusterIdentifier><Status>available</Status>
<Endpoint>stack-db.cluster-x.us-east-1.rds.amazonaws.com</Endpoint><Port>5432</Port>
<EngineVersion>17.4</EngineVersion>
<ServerlessV2ScalingConfiguration><MinCapacity>0</MinCapacity><MaxCapacity>8</MaxCapacity></ServerlessV2ScalingConfiguration></DBCluster>
</DBClusters></DescribeDBClustersResult></DescribeDBClustersResponse>"#;
let env: DescribeDbClustersEnvelope = quick_xml::de::from_str(xml).expect("parses");
let clusters = env.result.db_clusters.members;
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].identifier, "stack-db");
assert_eq!(clusters[0].status, "available");
assert_eq!(clusters[0].port, Some(5432));
assert_eq!(
clusters[0]
.serverless_v2_scaling_configuration
.as_ref()
.map(|s| s.max_capacity),
Some(8.0)
);
}
}