You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
171 lines
7.0 KiB
171 lines
7.0 KiB
use std::{
|
|
collections::HashMap,
|
|
sync::{Arc, LazyLock, Mutex},
|
|
time::Duration,
|
|
};
|
|
|
|
use diesel::ConnectionError;
|
|
use url::Url;
|
|
|
|
struct PsqlUrl {
|
|
timestamp: std::time::Instant,
|
|
url: String,
|
|
}
|
|
|
|
type PsqlUrlLock = Arc<Mutex<Option<PsqlUrl>>>;
|
|
|
|
static PSQL_URLS: LazyLock<Mutex<HashMap<String, PsqlUrlLock>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
static DSQL_REGION_FROM_HOST_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
|
|
regex::Regex::new(r"^[a-z0-9]+\.dsql\.(?P<region>[a-z0-9-]+)\.on\.aws$")
|
|
.expect("Failed to compile DSQL region regex")
|
|
});
|
|
|
|
// Generate a Postgres libpq connection string. The input connection string has
|
|
// the following format:
|
|
//
|
|
// dsql://<dsql-id>.dsql.<aws-region>.on.aws
|
|
//
|
|
// The generated connection string has the form:
|
|
//
|
|
// postgresql://<dsql-id>.dsql.<aws-region>.on.aws/postgres?sslmode=require&user=admin&password=<auth-token>
|
|
//
|
|
// The auth token is generated by the AWS SDK for DSQL and is valid for up to
|
|
// 15 minutes. Cache each unique DSQL URL for 14 minutes to avoid regenerating
|
|
// a token for every pooled connection.
|
|
pub(crate) fn psql_url(url: &str) -> Result<String, ConnectionError> {
|
|
let mut psql_urls =
|
|
PSQL_URLS.lock().map_err(|e| ConnectionError::BadConnection(format!("Failed to lock DSQL URLs: {e}")))?;
|
|
|
|
let psql_url_lock = if let Some(existing_psql_url_lock) = psql_urls.get(url) {
|
|
Arc::clone(existing_psql_url_lock)
|
|
} else {
|
|
let psql_url_lock = Arc::new(Mutex::new(None));
|
|
psql_urls.insert(url.to_owned(), Arc::clone(&psql_url_lock));
|
|
psql_url_lock
|
|
};
|
|
|
|
let mut psql_url_lock_guard =
|
|
psql_url_lock.lock().map_err(|e| ConnectionError::BadConnection(format!("Failed to lock DSQL URL: {e}")))?;
|
|
|
|
drop(psql_urls);
|
|
|
|
if let Some(ref psql_url) = *psql_url_lock_guard {
|
|
if psql_url.timestamp.elapsed() < Duration::from_mins(14) {
|
|
debug!("Reusing DSQL auth token for connection '{url}'");
|
|
return Ok(psql_url.url.clone());
|
|
}
|
|
|
|
info!("Refreshing DSQL auth token for connection '{url}'");
|
|
} else {
|
|
info!("Generating new DSQL auth token for connection '{url}'");
|
|
}
|
|
|
|
let mut psql_url = Url::parse(url).map_err(|e| ConnectionError::InvalidConnectionUrl(e.to_string()))?;
|
|
|
|
let host = psql_url
|
|
.host_str()
|
|
.ok_or_else(|| ConnectionError::InvalidConnectionUrl("Missing hostname in DSQL URL".to_owned()))?
|
|
.to_owned();
|
|
|
|
let region = DSQL_REGION_FROM_HOST_RE
|
|
.captures(&host)
|
|
.and_then(|captures| captures.name("region"))
|
|
.ok_or_else(|| ConnectionError::InvalidConnectionUrl("Failed to find AWS region in DSQL hostname".to_owned()))?
|
|
.as_str()
|
|
.to_owned();
|
|
|
|
let auth_config = aws_sdk_dsql::auth_token::Config::builder()
|
|
.hostname(host)
|
|
.region(aws_config::Region::new(region))
|
|
.build()
|
|
.map_err(|e| ConnectionError::BadConnection(format!("Failed to build DSQL auth token signer config: {e}")))?;
|
|
|
|
let signer = aws_sdk_dsql::auth_token::AuthTokenGenerator::new(auth_config);
|
|
let sdk_config = crate::aws::aws_sdk_config_blocking()
|
|
.map_err(|e| ConnectionError::BadConnection(format!("Failed to load AWS SDK config: {e}")))?;
|
|
let now = std::time::Instant::now();
|
|
|
|
let auth_token = std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
|
|
rt.block_on(signer.db_connect_admin_auth_token(sdk_config))
|
|
})
|
|
.join()
|
|
.map_err(|e| ConnectionError::BadConnection(format!("Failed to generate DSQL auth token: {e:?}")))?
|
|
.map_err(|e| ConnectionError::BadConnection(format!("Failed to generate DSQL auth token: {e}")))?;
|
|
|
|
psql_url.set_scheme("postgresql").expect("Failed to set 'postgresql' as scheme for DSQL connection URL");
|
|
psql_url.set_path("postgres");
|
|
psql_url
|
|
.query_pairs_mut()
|
|
.append_pair("sslmode", "require")
|
|
.append_pair("user", "admin")
|
|
.append_pair("password", auth_token.as_str());
|
|
|
|
psql_url_lock_guard.replace(PsqlUrl {
|
|
timestamp: now,
|
|
url: psql_url.to_string(),
|
|
});
|
|
|
|
Ok(psql_url.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use chrono::Utc;
|
|
use diesel::{Connection, QueryDsl, RunQueryDsl, connection::SimpleConnection};
|
|
|
|
use crate::db::schema::{org_policies, users};
|
|
|
|
#[test]
|
|
#[ignore = "requires DSQL_MIGRATION_TEST_DATABASE_URL pointing to PostgreSQL"]
|
|
fn release_1_37_5_migrations_preserve_existing_rows() {
|
|
let url = std::env::var("DSQL_MIGRATION_TEST_DATABASE_URL").expect("Missing test database URL");
|
|
let mut conn = diesel::pg::PgConnection::establish(&url).expect("Connect to test database");
|
|
// Temporary tables shadow any real tables and disappear with this connection.
|
|
conn.batch_execute(
|
|
"SET TIME ZONE 'Pacific/Honolulu';
|
|
CREATE TEMP TABLE users (uuid TEXT PRIMARY KEY);
|
|
CREATE TEMP TABLE org_policies (uuid TEXT PRIMARY KEY);
|
|
INSERT INTO users VALUES ('existing-user');
|
|
INSERT INTO org_policies VALUES ('existing-policy-1'), ('existing-policy-2'), ('existing-policy-3');",
|
|
)
|
|
.unwrap();
|
|
|
|
conn.batch_execute(include_str!("../../migrations/dsql/2026-09-02-120000_add_key_id/up.sql")).unwrap();
|
|
let keys = users::table.select(users::key_id).load::<Option<String>>(&mut conn).unwrap();
|
|
assert_eq!(keys, vec![None]);
|
|
|
|
conn.batch_execute(include_str!("../../migrations/dsql/2026-10-07-120000_add_org_policy_revision_date/up.sql"))
|
|
.unwrap();
|
|
conn.batch_execute(include_str!(
|
|
"../../migrations/dsql/2026-10-07-120050_set_org_policy_revision_default/up.sql"
|
|
))
|
|
.unwrap();
|
|
conn.batch_execute("INSERT INTO org_policies VALUES ('preserved-policy', TIMESTAMP '2020-01-01 00:00:00');")
|
|
.unwrap();
|
|
|
|
let before = Utc::now().naive_utc();
|
|
let backfill = include_str!("../../migrations/dsql/2026-10-07-120100_backfill_org_policy_revision_date/up.sql");
|
|
conn.batch_execute(backfill).unwrap();
|
|
let mut dates = org_policies::table
|
|
.select(org_policies::revision_date)
|
|
.order(org_policies::uuid)
|
|
.load::<chrono::NaiveDateTime>(&mut conn)
|
|
.unwrap();
|
|
let after = Utc::now().naive_utc();
|
|
assert_eq!(dates.len(), 4);
|
|
assert!(dates[..3].iter().all(|date| *date >= before && *date <= after));
|
|
assert_eq!(dates[3], "2020-01-01T00:00:00".parse::<chrono::NaiveDateTime>().unwrap());
|
|
|
|
// A still-running old instance must not introduce NULL after the backfill.
|
|
conn.batch_execute("INSERT INTO org_policies (uuid) VALUES ('rolled-back-policy');").unwrap();
|
|
dates.push("1970-01-01T00:00:00".parse::<chrono::NaiveDateTime>().unwrap());
|
|
conn.batch_execute(backfill).unwrap();
|
|
let retried = org_policies::table
|
|
.select(org_policies::revision_date)
|
|
.order(org_policies::uuid)
|
|
.load::<chrono::NaiveDateTime>(&mut conn)
|
|
.unwrap();
|
|
assert_eq!(retried, dates);
|
|
}
|
|
}
|
|
|