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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 74 additions & 9 deletions crates/alien-aws-clients/src/aws/rds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ pub struct ModifyDbClusterRequest {
pub identifier: String,
/// Only set for an in-place major upgrade.
pub engine_version: Option<String>,
/// Only set for an in-place ACU (memory) resize; mirrors the create scaling ceiling.
pub max_capacity: Option<f64>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -100,6 +102,17 @@ pub struct DbCluster {
pub port: Option<u16>,
#[serde(default)]
pub engine_version: Option<String>,
/// The serverless v2 scaling window the cluster reports — drives in-place ACU resize detection.
#[serde(default, rename = "ServerlessV2ScalingConfiguration")]
pub serverless_v2_scaling_configuration: Option<ServerlessV2ScalingConfiguration>,
}

/// The cluster's Serverless v2 ACU scaling window, as reported by DescribeDBClusters.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub struct ServerlessV2ScalingConfiguration {
/// The ACU ceiling (`memory` maps to this; the floor stays 0 for auto-pause).
pub max_capacity: f64,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
Expand Down Expand Up @@ -230,6 +243,25 @@ fn create_db_cluster_form(r: &CreateDbClusterRequest) -> HashMap<String, String>
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 {
// Mirror create: the floor stays 0 (auto-pause); only the ACU ceiling moves.
form.insert("ServerlessV2ScalingConfiguration.MinCapacity".into(), "0".into());
form.insert(
"ServerlessV2ScalingConfiguration.MaxCapacity".into(),
format!("{max_capacity}"),
);
}
form
}

fn create_db_instance_form(r: &CreateDbInstanceRequest) -> HashMap<String, String> {
let mut form = base_form("CreateDBInstance");
form.insert("DBInstanceIdentifier".into(), r.identifier.clone());
Expand Down Expand Up @@ -477,14 +509,12 @@ impl RdsApi for RdsClient {
}

async fn modify_db_cluster(&self, request: ModifyDbClusterRequest) -> Result<()> {
let mut form = base_form("ModifyDBCluster");
form.insert("DBClusterIdentifier".into(), request.identifier.clone());
form.insert("ApplyImmediately".into(), "true".into());
if let Some(version) = &request.engine_version {
form.insert("EngineVersion".into(), version.clone());
form.insert("AllowMajorVersionUpgrade".into(), "true".into());
}
self.send_form_no_body(form, "ModifyDBCluster", &request.identifier).await
self.send_form_no_body(
modify_db_cluster_form(&request),
"ModifyDBCluster",
&request.identifier,
)
.await
}

async fn delete_db_cluster(&self, request: DeleteDbClusterRequest) -> Result<()> {
Expand Down Expand Up @@ -553,6 +583,31 @@ mod tests {
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),
});
assert_eq!(form["Action"], "ModifyDBCluster");
assert_eq!(form["ApplyImmediately"], "true");
assert_eq!(form["ServerlessV2ScalingConfiguration.MinCapacity"], "0");
assert_eq!(form["ServerlessV2ScalingConfiguration.MaxCapacity"], "8");
// A memory-only resize must not touch the engine version.
assert!(!form.contains_key("EngineVersion"));
}

#[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,
});
assert!(!form.contains_key("ServerlessV2ScalingConfiguration.MaxCapacity"));
}

#[test]
fn delete_cluster_skips_final_snapshot() {
let form = delete_db_cluster_form(&DeleteDbClusterRequest {
Expand Down Expand Up @@ -601,13 +656,23 @@ mod tests {
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></DBCluster>
<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));
// The serverless scaling ceiling must parse: `ready`/refresh read it to detect a day-2 memory
// change, and an imported cluster (max_capacity None) relies on this XML to learn its ceiling.
assert_eq!(
clusters[0]
.serverless_v2_scaling_configuration
.as_ref()
.map(|s| s.max_capacity),
Some(8.0)
);
}
}
1 change: 1 addition & 0 deletions crates/alien-aws-clients/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub use aws::eks::{EksApi, EksClient};
pub use aws::eventbridge::{EventBridgeApi, EventBridgeClient};
pub use aws::iam::{IamApi, IamClient};
pub use aws::lambda::{LambdaApi, LambdaClient};
pub use aws::rds::{RdsApi, RdsClient};
pub use aws::s3::{S3Api, S3Client};
pub use aws::secrets_manager::{SecretsManagerApi, SecretsManagerClient};
pub use aws::sqs::{SqsApi, SqsClient};
Expand Down
12 changes: 11 additions & 1 deletion crates/alien-azure-clients/src/azure/flexible_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ pub struct FlexibleServerProperties {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct FlexibleServerStorage {
/// `rename_all = camelCase` would emit `storageSizeGb`, but ARM's canonical key is
/// `storageSizeGB` (capital GB). serde deserialize is case-sensitive, so the GET
/// read-back of this required field would fail without the explicit rename.
#[serde(rename = "storageSizeGB")]
pub storage_size_gb: u32,
}

Expand Down Expand Up @@ -349,6 +353,10 @@ mod tests {
assert_eq!(json["properties"]["network"]["publicNetworkAccess"], "Disabled");
assert_eq!(json["properties"]["highAvailability"]["mode"], "ZoneRedundant");
assert_eq!(json["properties"]["administratorLoginPassword"], "secret");
// ARM's canonical storage key is `storageSizeGB` (capital GB); camelCase would
// wrongly emit `storageSizeGb`. Pin both so a dropped rename regresses here.
assert_eq!(json["properties"]["storage"]["storageSizeGB"], 32);
assert!(json["properties"]["storage"].get("storageSizeGb").is_none());
// state is response-only; absent on the way out.
assert!(json["properties"].get("state").is_none());
}
Expand Down Expand Up @@ -376,12 +384,14 @@ mod tests {

#[test]
fn server_deserializes_get_response() {
// Storage key mirrors a real ARM response: `storageSizeGB` (capital GB).
let body = r#"{"location":"eastus","sku":{"name":"Standard_B1ms","tier":"Burstable"},
"properties":{"version":"17","storage":{"storageSizeGb":32},
"properties":{"version":"17","storage":{"storageSizeGB":32},
"backup":{"backupRetentionDays":7},"network":{"publicNetworkAccess":"Disabled"},
"state":"Ready"}}"#;
let server: FlexibleServer = serde_json::from_str(body).unwrap();
assert_eq!(server.properties.state.as_deref(), Some("Ready"));
assert_eq!(server.properties.network.public_network_access, "Disabled");
assert_eq!(server.properties.storage.storage_size_gb, 32);
}
}
1 change: 1 addition & 0 deletions crates/alien-azure-clients/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub use azure::compute::{AzureVmssClient, VirtualMachineScaleSetsApi};
pub use azure::container_apps::{AzureContainerAppsClient, ContainerAppsApi};
pub use azure::containerregistry::{AzureContainerRegistryClient, ContainerRegistryApi};
pub use azure::disks::{AzureManagedDisksClient, ManagedDisksApi};
pub use azure::flexible_server::{AzureFlexibleServerClient, FlexibleServerApi};
pub use azure::keyvault::{
AzureKeyVaultManagementClient, AzureKeyVaultSecretsClient, KeyVaultManagementApi,
KeyVaultSecretsApi,
Expand Down
2 changes: 2 additions & 0 deletions crates/alien-cli-common/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ fn parse_byo_settings(
public_subnet_name,
private_subnet_name,
application_gateway_subnet_name: None,
private_endpoint_subnet_name: None,
}))
}
_ => Err(format!(
Expand Down Expand Up @@ -406,6 +407,7 @@ mod tests {
public_subnet_name: "pub-subnet".to_string(),
private_subnet_name: "priv-subnet".to_string(),
application_gateway_subnet_name: None,
private_endpoint_subnet_name: None,
}
);
}
Expand Down
9 changes: 6 additions & 3 deletions crates/alien-client-core/src/request_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ const HTTP_REQUEST_TEXT_CONTEXT_KEY: &str = "http_request_text";
/// responses — so the body could leak.
///
/// This scrubs both representations across the head and full `source` chain, keeping status, response
/// text, URL, and chain intact. Order-independent: works whether the HTTP error is still the head
/// (AWS: redaction before mapping) or already wrapped into the source (GCP/Azure: transport maps
/// first). Apply to the raw transport result of any request whose body contains a secret.
/// text, URL, and chain intact. Response text is kept deliberately: RDS / Cloud SQL / Flexible Server
/// error bodies don't echo the submitted password back, so it stays as a diagnostic — a future caller
/// wiring this to an API that *does* reflect request fields in its error responses would need to scrub
/// that too. Order-independent: works whether the HTTP error is still the head (AWS: redaction before
/// mapping) or already wrapped into the source (GCP/Azure: transport maps first). Apply to the raw
/// transport result of any request whose body contains a secret.
pub fn redact_request_body<T>(result: Result<T>) -> Result<T> {
result.map_err(|mut e| {
// Head: drop the body from the typed payload when the head itself is the HTTP error...
Expand Down
3 changes: 3 additions & 0 deletions crates/alien-core/src/bin/schema_exporter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ use utoipa::OpenApi;
AwsBuildImportData,
AwsArtifactRegistryImportData,
AwsComputeClusterImportData,
AwsPostgresImportData,
GcpStorageImportData,
GcpWorkerImportData,
GcpQueueImportData,
Expand All @@ -158,6 +159,7 @@ use utoipa::OpenApi;
GcpBuildImportData,
GcpArtifactRegistryImportData,
GcpComputeClusterImportData,
GcpPostgresImportData,
AzureStorageImportData,
AzureWorkerImportData,
AzureQueueImportData,
Expand All @@ -174,6 +176,7 @@ use utoipa::OpenApi;
AzureContainerAppsEnvironmentImportData,
AzureServiceBusNamespaceImportData,
AzureStorageAccountImportData,
AzureFlexibleServerPostgresImportData,
)))]
struct ApiDoc;

Expand Down
1 change: 1 addition & 0 deletions crates/alien-core/src/heartbeat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,7 @@ pub struct AzureVnetNetworkHeartbeatData {
pub public_subnet_name: Option<String>,
pub private_subnet_name: Option<String>,
pub application_gateway_subnet_name: Option<String>,
pub private_endpoint_subnet_name: Option<String>,
pub nat_gateway_id: Option<String>,
pub public_ip_id: Option<String>,
pub nsg_id: Option<String>,
Expand Down
2 changes: 2 additions & 0 deletions crates/alien-core/src/import/data/aws/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod build;
pub mod compute_cluster;
pub mod kv;
pub mod network;
pub mod postgres;
pub mod queue;
pub mod remote_stack_management;
pub mod service_account;
Expand All @@ -15,6 +16,7 @@ pub use build::*;
pub use compute_cluster::*;
pub use kv::*;
pub use network::*;
pub use postgres::*;
pub use queue::*;
pub use remote_stack_management::*;
pub use service_account::*;
Expand Down
30 changes: 30 additions & 0 deletions crates/alien-core/src/import/data/aws/postgres.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};

/// AWS Postgres (Aurora Serverless v2) registration data: the handles by which a
/// setup-created Aurora cluster is registered as a Frozen Postgres resource. Setup owns
/// the cluster, Alien refreshes and heartbeats it. Carries the master password's Secrets
/// Manager ARN, never the password.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct AwsPostgresImportData {
/// Aurora DB cluster identifier.
pub cluster_identifier: String,
/// Writer instance identifier within the cluster.
pub instance_identifier: String,
/// DB subnet group spanning the private subnets.
pub subnet_group_name: String,
/// Dedicated security group admitting 5432 from the stack security group.
pub security_group_id: String,
/// Secrets Manager ARN of the master password (never the password itself).
pub password_secret_arn: String,
/// Cluster writer endpoint (the binding host).
pub cluster_endpoint: String,
/// Default database name.
pub database: String,
/// Master username.
pub username: String,
/// Engine version the cluster reports.
pub engine_version: String,
}
2 changes: 2 additions & 0 deletions crates/alien-core/src/import/data/azure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod compute_cluster;
pub mod container_apps_environment;
pub mod kv;
pub mod network;
pub mod postgres;
pub mod queue;
pub mod remote_stack_management;
pub mod resource_group;
Expand All @@ -21,6 +22,7 @@ pub use compute_cluster::*;
pub use container_apps_environment::*;
pub use kv::*;
pub use network::*;
pub use postgres::*;
pub use queue::*;
pub use remote_stack_management::*;
pub use resource_group::*;
Expand Down
36 changes: 36 additions & 0 deletions crates/alien-core/src/import/data/azure/postgres.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};

/// Azure Postgres (Flexible Server + Private Endpoint) registration data: the handles by
/// which a setup-created Flexible Server (with its Private Endpoint, private DNS zone, and
/// the Key Vault secret holding the master password) is registered as a Frozen Postgres
/// resource. Setup owns the server, Alien refreshes and heartbeats it. The raw password is
/// never carried, only the Key Vault secret URI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct AzureFlexibleServerPostgresImportData {
/// Flexible Server name on Azure.
pub server_name: String,
/// Full ARM resource id of the Flexible Server (the deletable handle).
pub server_resource_id: String,
/// Resource group the server lives in.
pub resource_group: String,
/// Default database name.
pub database: String,
/// Private DNS FQDN host workloads resolve to reach the server (the binding host).
pub host: String,
/// Key Vault base URL holding the password (e.g. `https://<vault>.vault.azure.net`).
pub key_vault_url: String,
/// Key Vault secret name of the master password (never the password).
pub password_secret_name: String,
/// Key Vault secret URI of the master password; the binding carries this and the
/// workload resolves the value at load time.
pub password_secret_uri: String,
/// Private Endpoint name fronting the server in the dedicated PE subnet.
pub private_endpoint_name: String,
/// Azure region the server lives in.
pub region: String,
/// Engine version the server reports.
pub version: String,
}
2 changes: 2 additions & 0 deletions crates/alien-core/src/import/data/gcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod build;
pub mod compute_cluster;
pub mod kv;
pub mod network;
pub mod postgres;
pub mod queue;
pub mod remote_stack_management;
pub mod service_account;
Expand All @@ -16,6 +17,7 @@ pub use build::*;
pub use compute_cluster::*;
pub use kv::*;
pub use network::*;
pub use postgres::*;
pub use queue::*;
pub use remote_stack_management::*;
pub use service_account::*;
Expand Down
Loading
Loading