use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use olai_stack_state::SecretRef;
use serde::{Deserialize, Serialize};
use super::{NeonControl, NeonError};
use crate::secrets::{SecretProviders, SecretValue};
use crate::source::{Overlay, Source, SourceCtx, SourceError};
pub const NEON_SOURCE: &str = "neon";
pub(super) const S3_VAR_PREFIX: &str = "NEON_S3_";
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NeonBinding {
pub project_id: String,
pub branch: String,
#[serde(default)]
pub branch_name: String,
#[serde(default)]
pub functions: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage_credential: Option<StorageCredentialRef>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StorageCredentialRef {
pub token_id: String,
pub secret: SecretRef,
}
pub struct NeonBranchSource {
neon: Arc<dyn NeonControl>,
secrets: SecretProviders,
}
impl NeonBranchSource {
pub fn new(neon: Arc<dyn NeonControl>, secrets: SecretProviders) -> Self {
NeonBranchSource { neon, secrets }
}
async fn overlay_for(&self, binding: &NeonBinding) -> Result<Overlay, NeonError> {
let (project, branch) = (&binding.project_id, &binding.branch);
let mut overlay = Overlay::default();
let direct = self.neon.connection(project, branch, None, false).await?;
let pooled_url = pooled(direct.url.expose(), &direct.host);
overlay.vars.extend([
("PGHOST".to_owned(), direct.host.clone()),
("PGUSER".to_owned(), direct.role.clone()),
("PGDATABASE".to_owned(), direct.database.clone()),
]);
overlay.credentials.extend([
("PGPASSWORD".to_owned(), direct.password.clone()),
("DATABASE_URL".to_owned(), SecretValue::new(pooled_url)),
("DATABASE_URL_UNPOOLED".to_owned(), direct.url.clone()),
]);
if let Some(credential) = &binding.storage_credential {
let storage = self.neon.storage(project, branch).await?;
if !storage.enabled {
return Err(NeonError::NoStorage(binding.branch_name.clone()));
}
overlay.endpoints.insert(
format!("{S3_VAR_PREFIX}ENDPOINT"),
storage.s3_endpoint.trim_end_matches('/').to_owned(),
);
overlay
.vars
.insert(format!("{S3_VAR_PREFIX}REGION"), storage.region);
let secret =
self.secrets
.resolve(&credential.secret)
.map_err(|e| NeonError::Failed {
command: "credentials (stored secret)".into(),
stderr: e.to_string(),
})?;
overlay.credentials.extend([
(
format!("{S3_VAR_PREFIX}ACCESS_KEY_ID"),
SecretValue::new(credential.token_id.clone()),
),
(format!("{S3_VAR_PREFIX}SECRET_ACCESS_KEY"), secret),
]);
}
if !binding.functions.is_empty() {
let deployed = self.neon.functions(project, branch).await?;
let mut missing = Vec::new();
for (slug, var) in &binding.functions {
match deployed.iter().find(|f| &f.slug == slug) {
Some(f) => {
overlay.endpoints.insert(
var.clone(),
f.invocation_url.trim_end_matches('/').to_owned(),
);
}
None => missing.push(slug.clone()),
}
}
if !missing.is_empty() {
return Err(NeonError::MissingFunctions {
branch: binding.branch_name.clone(),
slugs: missing,
});
}
}
Ok(overlay)
}
}
fn pooled(url: &str, host: &str) -> String {
match host.split_once('.') {
Some((endpoint, rest)) if !endpoint.ends_with("-pooler") => {
url.replacen(host, &format!("{endpoint}-pooler.{rest}"), 1)
}
_ => url.to_owned(),
}
}
#[async_trait]
impl Source for NeonBranchSource {
fn name(&self) -> &str {
NEON_SOURCE
}
async fn overlay(&self, ctx: &SourceCtx) -> Result<Overlay, SourceError> {
let fail = |reason: String| SourceError {
source_name: NEON_SOURCE.into(),
reason,
};
let binding: NeonBinding = serde_json::from_value(ctx.config.clone())
.map_err(|e| fail(format!("the instance's binding: {e}")))?;
if binding.project_id.is_empty() || binding.branch.is_empty() {
return Err(fail("the instance is bound to no Neon branch".into()));
}
self.overlay_for(&binding)
.await
.map_err(|e| fail(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_pooled_host_adds_pooler_to_the_endpoint() {
let host = "ep-snowy-mud-bar7hnwg.c-8.eu-central-1.aws.neon.tech";
let url = format!("postgresql://owner@{host}/neondb?sslmode=require");
assert_eq!(
pooled(&url, host),
"postgresql://owner@ep-snowy-mud-bar7hnwg-pooler.c-8.eu-central-1.aws.neon.tech/neondb?sslmode=require"
);
let already = "ep-x-pooler.example.neon.tech";
assert_eq!(pooled(already, already), already);
}
}