Skip to content
Open
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
15 changes: 14 additions & 1 deletion crates/alien-bindings-node/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! `alien-bindings-node` — a napi-rs addon exposing `alien-bindings`
//! storage / kv / queue / vault / container to JavaScript.
//! storage / kv / queue / vault / container / postgres to JavaScript.
//!
//! This crate is a pure argument/error translation layer. It contains no
//! provider logic: every method marshals arguments across the JS boundary,
Expand All @@ -11,6 +11,7 @@
mod container;
mod error;
mod kv;
mod postgres;
mod queue;
mod storage;
mod vault;
Expand All @@ -22,6 +23,7 @@ use std::sync::Arc;

pub use container::ContainerHandle;
pub use kv::KvHandle;
pub use postgres::{PostgresConnectionJs, PostgresHandle};
pub use queue::QueueHandle;
pub use storage::StorageHandle;
pub use vault::VaultHandle;
Expand Down Expand Up @@ -91,4 +93,15 @@ impl BindingsHandle {
let container = inner.container(&name).await.map_err(map_alien_error)?;
Ok(ContainerHandle::new(container))
}

/// Resolve the Postgres binding named `name`.
///
/// A cloud backend's password is read from its secret store here, so this is where a
/// secret-resolution failure surfaces — not on a later method call.
#[napi]
pub async fn postgres(&self, name: String) -> napi::Result<PostgresHandle> {
let inner = self.inner.clone();
let postgres = inner.postgres(&name).await.map_err(map_alien_error)?;
Ok(PostgresHandle::new(postgres))
}
}
137 changes: 137 additions & 0 deletions crates/alien-bindings-node/src/postgres.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//! Postgres binding handle. Thin argument/error translation over the `Postgres`
//! trait.
//!
//! Postgres is connection-only: there are no operations to forward, just the connection
//! details the Rust provider already resolved (including reading a cloud backend's
//! password from its secret store). The handle is therefore synchronous — every field is
//! in memory by the time `BindingsHandle::postgres` returns.

use alien_bindings::{Postgres, PostgresConnectionParams};
use napi_derive::napi;
use std::sync::Arc;

/// Everything a Postgres driver needs to connect.
#[napi(object)]
pub struct PostgresConnectionJs {
/// `postgres://user:password@host:port/database?sslmode=<mode>`, with the username,
/// password, and database percent-encoded.
pub connection_string: String,
/// Address to dial (the cluster writer endpoint for Aurora).
pub host: String,
/// TCP port.
pub port: u32,
/// Database name.
pub database: String,
/// Role to connect as.
pub username: String,
/// Connection password, already resolved from the cloud secret store where applicable.
pub password: String,
/// `disable` (local or explicit BYO plaintext), `verify-ca` (Cloud SQL), or
/// `verify-full` (BYO, Aurora, and Flexible Server).
pub sslmode: String,
/// PEM-encoded root CAs used by the verified TLS modes.
pub ca_certificates: Vec<String>,
}

/// Handle to a resolved Postgres binding.
#[napi]
pub struct PostgresHandle {
inner: Arc<dyn Postgres>,
}

impl PostgresHandle {
pub(crate) fn new(inner: Arc<dyn Postgres>) -> Self {
Self { inner }
}
}

/// Translate resolved connection parameters into their JS shape.
fn connection_to_js(params: &PostgresConnectionParams) -> PostgresConnectionJs {
PostgresConnectionJs {
connection_string: params.connection_string(),
host: params.host.clone(),
port: u32::from(params.port),
database: params.database.clone(),
username: params.username.clone(),
password: params.password.clone(),
sslmode: params.sslmode().as_str().to_string(),
ca_certificates: params.ca_certificates().to_vec(),
}
}

#[napi]
impl PostgresHandle {
/// Return the resolved connection details.
#[napi]
pub fn connection(&self) -> PostgresConnectionJs {
connection_to_js(self.inner.connection_params())
}
}

#[cfg(test)]
mod tests {
use super::*;
use alien_bindings::PostgresTlsPolicy;

/// The JS shape must carry every field verbatim, with the connection string derived
/// from the same params (not stored separately) and `sslmode` as its wire string.
#[test]
fn connection_to_js_maps_every_field() {
let params = PostgresConnectionParams::new(
"cluster.rds.amazonaws.com".to_string(),
5432,
"app".to_string(),
"alien".to_string(),
"p@ss/word".to_string(),
PostgresTlsPolicy::verify_full(vec![
"-----BEGIN CERTIFICATE-----\nroot\n-----END CERTIFICATE-----".to_string(),
])
.unwrap(),
);

let js = connection_to_js(&params);

assert_eq!(js.host, "cluster.rds.amazonaws.com");
assert_eq!(js.port, 5432);
assert_eq!(js.database, "app");
assert_eq!(js.username, "alien");
assert_eq!(js.password, "p@ss/word");
assert_eq!(js.sslmode, "verify-full");
assert_eq!(js.ca_certificates, params.ca_certificates());
assert_eq!(
js.connection_string,
"postgres://alien:p%40ss%2Fword@cluster.rds.amazonaws.com:5432/app?sslmode=verify-full"
);
}

/// Each `SslMode` must reach JS as the exact string the `sslmode` query parameter
/// uses, so the TS layer can map it to a driver TLS setting without guessing.
#[test]
fn connection_to_js_reports_each_sslmode_as_its_wire_string() {
for (tls, expected) in [
(PostgresTlsPolicy::disabled(), "disable"),
(
PostgresTlsPolicy::verify_ca(vec![
"-----BEGIN CERTIFICATE-----\nroot\n-----END CERTIFICATE-----".to_string(),
])
.unwrap(),
"verify-ca",
),
(
PostgresTlsPolicy::verify_full_with_system_roots(),
"verify-full",
),
] {
let params = PostgresConnectionParams::new(
"h".to_string(),
5432,
"db".to_string(),
"u".to_string(),
"p".to_string(),
tls,
);

assert_eq!(connection_to_js(&params).sslmode, expected);
}
}
}
4 changes: 4 additions & 0 deletions crates/alien-bindings/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ base64 = { workspace = true, optional = true }
k8s-openapi = { version = "0.25.0", features = ["v1_33"], optional = true }

[dev-dependencies]
# `test-utils` on each cloud client crate enables its mockall `Mock*Api` doubles, which
# the cloud Postgres providers' unit tests use to fake the secret store.
alien-aws-clients = { workspace = true, features = ["test-utils"] }
alien-gcp-clients = { workspace = true, features = ["test-utils"] }
alien-azure-clients = { workspace = true, features = ["test-utils"] }
axum = { workspace = true, features = ["tokio", "http1", "json"] }
dotenvy = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
Expand Down
27 changes: 24 additions & 3 deletions crates/alien-bindings/src/bindings.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
//! App-facing convenience API for accessing bindings.
//!
//! [`Bindings`] wraps a [`crate::provider::LazyEnvBindingsProvider`], giving application
//! code a small, stable surface — `storage`, `kv`, `queue`, `vault`, `container` — instead of the full
//! [`crate::traits::BindingsProviderApi`] used internally by the manager and controllers.
//! code a small, stable surface — `storage`, `kv`, `queue`, `vault`, `container`,
//! `postgres` — instead of the full [`crate::traits::BindingsProviderApi`] used internally
//! by the manager and controllers.

use crate::error::Result;
use crate::provider::{BindingsProvider, LazyEnvBindingsProvider};
use crate::refreshing::{RefreshingKv, RefreshingQueue, RefreshingStorage, RefreshingVault};
use crate::traits::{
BindingsProviderApi, Container, Kv, MessagePayload, Queue, QueueMessage, Storage, Vault,
BindingsProviderApi, Container, Kv, MessagePayload, Postgres, Queue, QueueMessage, Storage,
Vault,
};
use std::collections::HashMap;
use std::sync::Arc;
Expand Down Expand Up @@ -164,6 +166,25 @@ impl Bindings {
pub async fn container(&self, binding_name: &str) -> Result<Arc<dyn Container>> {
self.provider.load_container(binding_name).await
}

/// Loads the connection details for a linked Postgres database.
///
/// Unlike the other kinds this returns no operations — Postgres has no gRPC service
/// and every backend speaks the same wire protocol, so the handle carries connection
/// details and the application connects with its own driver.
///
/// The Local and External backends carry their password inline in the binding
/// environment variable, so their handle is resolved once and then cached.
///
/// The three cloud backends carry only a locator for their password and read it from
/// the cloud secret store on **every** call — their handle is never cached. There is
/// no refreshing wrapper, so a handle keeps the password that was current when it was
/// created and calling this again is what picks up a rotated one. Each call is
/// therefore one secret-store read: hold the returned handle for the lifetime of a
/// connection pool rather than calling this per query.
pub async fn postgres(&self, binding_name: &str) -> Result<Arc<dyn Postgres>> {
self.provider.load_postgres(binding_name).await
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

#[cfg(test)]
Expand Down
43 changes: 43 additions & 0 deletions crates/alien-bindings/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,49 @@ pub enum ErrorData {
resource_id: Option<String>,
},

/// Reading a cloud Postgres binding's password from its secret store failed.
///
/// The retry and visibility metadata comes from the provider error being wrapped:
/// throttling remains retryable, while permanent provider failures do not become
/// retryable merely because they happened during secret resolution.
///
/// `secret` is the locator, never the secret value.
#[error(
code = "POSTGRES_SECRET_RESOLUTION_FAILED",
message = "Failed to resolve the password for Postgres binding '{binding_name}' from secret '{secret}': {reason}",
retryable = "inherit",
internal = "inherit",
http_status_code = 502
)]
PostgresSecretResolutionFailed {
/// Name of the Postgres binding whose password could not be resolved
binding_name: String,
/// The secret locator that was read (an ARN / name / URI — never the value)
secret: String,
/// What went wrong while reading the secret
reason: String,
},

/// A cloud Postgres secret was read, but did not contain a usable password.
///
/// Provider responses with missing, empty, or malformed values cannot become valid
/// by retrying the same version. `secret` is the locator, never the secret value.
#[error(
code = "POSTGRES_SECRET_VALUE_INVALID",
message = "Secret '{secret}' for Postgres binding '{binding_name}' does not contain a valid password: {reason}",
retryable = "false",
internal = "false",
http_status_code = 502
)]
PostgresSecretValueInvalid {
/// Name of the Postgres binding whose password value was invalid
binding_name: String,
/// The secret locator that was read (an ARN / name / URI — never the value)
secret: String,
/// Why the returned value cannot be used as a password
reason: String,
},

/// Resource not found in the cloud platform.
#[error(
code = "RESOURCE_NOT_FOUND",
Expand Down
6 changes: 3 additions & 3 deletions crates/alien-bindings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ pub use provider::BindingsProvider;
pub use traits::{
ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions,
AwsServiceAccountInfo, AzureServiceAccountInfo, Binding, BindingsProviderApi, Build, Container,
GcpServiceAccountInfo, ImpersonationRequest, Kv, Postgres, PostgresConnectionParams, Queue,
RegistryAuthMethod, RepositoryResponse, ServiceAccount, ServiceAccountInfo, SslMode, Storage,
Vault, Worker,
GcpServiceAccountInfo, ImpersonationRequest, InvalidPostgresCaCertificates, Kv, Postgres,
PostgresConnectionParams, PostgresTlsPolicy, Queue, RegistryAuthMethod, RepositoryResponse,
ServiceAccount, ServiceAccountInfo, SslMode, Storage, Vault, Worker,
};

pub mod bindings;
Expand Down
25 changes: 9 additions & 16 deletions crates/alien-bindings/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use crate::{
error::{binding_env_var, ErrorData, Result},
providers::postgres::runtime::PostgresRuntime,
traits::{
ArtifactRegistry, BindingsProviderApi, Build, Container, Kv, Postgres, Queue,
ServiceAccount, Storage, Vault, Worker,
Expand All @@ -20,10 +21,13 @@ use tokio::sync::{OnceCell, RwLock};
/// Direct platform-specific bindings provider.
/// Routes to appropriate platform implementations based on binding configuration.
///
/// Caches all loaded bindings by name. Each `load_*` call creates cloud clients
/// Caches loaded bindings by name. Each `load_*` call creates cloud clients
/// (HTTP connection pools, token caches) which are expensive to initialize. Since
/// the binding configuration is immutable for the provider's lifetime, the same
/// binding name always produces the same client — so we cache on first load.
///
/// Postgres has different caching semantics because cloud handles contain a resolved
/// password. Its dedicated runtime owns that policy and its secret-store clients.
#[derive(Debug, Clone)]
pub struct BindingsProvider {
client_config: ClientConfig,
Expand All @@ -32,6 +36,7 @@ pub struct BindingsProvider {
/// `"{trait_name}:{binding_name}"` to avoid collisions across types.
/// Each value is a `Box<Arc<dyn Trait>>` erased via `Any`.
cache: Arc<RwLock<HashMap<String, Box<dyn Any + Send + Sync>>>>,
postgres: Arc<PostgresRuntime>,
}

/// Environment-backed provider that defers cloud client configuration until the
Expand Down Expand Up @@ -110,10 +115,12 @@ impl BindingsProvider {
client_config: ClientConfig,
bindings: HashMap<String, serde_json::Value>,
) -> Result<Self> {
let postgres = Arc::new(PostgresRuntime::new(client_config.clone()));
Ok(Self {
client_config,
bindings,
cache: Arc::new(RwLock::new(HashMap::new())),
postgres,
})
}

Expand Down Expand Up @@ -1406,22 +1413,8 @@ impl BindingsProviderApi for BindingsProvider {
}

async fn load_postgres(&self, binding_name: &str) -> Result<Arc<dyn Postgres>> {
if let Some(cached) = self
.get_cached::<Arc<dyn Postgres>>("postgres", binding_name)
.await
{
return Ok(cached);
}

let binding: PostgresBinding = self.parse_binding(binding_name, "Postgres")?;

let result: Arc<dyn Postgres> = Arc::new(
crate::providers::postgres::local::LocalPostgres::from_binding(binding_name, &binding)?,
);

self.put_cache("postgres", binding_name, result.clone())
.await;
Ok(result)
self.postgres.load(binding_name, &binding).await
}

async fn load_queue(&self, binding_name: &str) -> Result<Arc<dyn Queue>> {
Expand Down
Loading
Loading