use std::path::PathBuf;
use std::time::Duration;
use redis_cloud::fixed::databases::{FixedDatabase, FixedDatabaseCreateRequest};
use redis_cloud::fixed::subscriptions::{FixedSubscription, FixedSubscriptionCreateRequest};
use redis_cloud::{CloudClient, CloudError};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::error::CoreError;
use crate::progress::poll_task;
const SUBSCRIPTION_PREFIX: &str = "redisctl-";
const DEFAULT_USER: &str = "default";
#[derive(Debug, Error)]
pub enum QuickDatabaseError {
#[error("{0}")]
InvalidName(String),
#[error("{0}")]
NameConflict(String),
#[error("{0}")]
FreeDbExists(String),
#[error("{0}")]
QuotaExceeded(String),
#[error("{0}")]
NotAuthenticated(String),
#[error("{0}")]
Transient(String),
#[error("{0}")]
RateLimited(String),
#[error("{0}")]
Other(String),
}
type QResult<T> = std::result::Result<T, QuickDatabaseError>;
#[derive(Debug, Clone)]
pub struct QuickDatabaseParams {
pub name: String,
pub output_credentials: PathBuf,
pub variable: String,
pub wait_timeout: u32,
pub wait_interval: u32,
}
impl QuickDatabaseParams {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
output_credentials: PathBuf::from("./.env"),
variable: "REDIS_URL".to_string(),
wait_timeout: 600,
wait_interval: 5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuickDatabaseReport {
pub status: String,
pub database: DatabaseSummary,
pub credentials_written_to: String,
pub credentials_variable: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseSummary {
pub id: String,
pub name: String,
pub region: Option<String>,
pub plan: String,
pub tls: bool,
}
pub async fn provision(
client: &CloudClient,
params: &QuickDatabaseParams,
) -> QResult<QuickDatabaseReport> {
validate_name(¶ms.name)?;
validate_variable(¶ms.variable)?;
let sub_name = format!("{SUBSCRIPTION_PREFIX}{}", params.name);
let (subscription_id, database_id, status) = match find_subscription(client, &sub_name).await? {
Some((sub_id, sub)) => {
ensure_free_plan(client, &sub, &sub_name).await?;
match database_to_reuse(client, sub_id, ¶ms.name).await? {
Some(db_id) => (sub_id, db_id, "reused"),
None => (sub_id, create_database(client, sub_id, params).await?, "ok"),
}
}
None => {
let sub_id = create_subscription(client, &sub_name, params).await?;
(sub_id, create_database(client, sub_id, params).await?, "ok")
}
};
let db = fetch_ready_database(client, subscription_id, database_id, params).await?;
deliver_and_report(&db, params, database_id, status, "free")
}
pub async fn existing_database_report(
client: &CloudClient,
subscription_id: i32,
database_id: i32,
params: &QuickDatabaseParams,
) -> QResult<QuickDatabaseReport> {
validate_variable(¶ms.variable)?;
let db = fetch_ready_database(client, subscription_id, database_id, params).await?;
deliver_and_report(&db, params, database_id, "existing", "essentials")
}
fn deliver_and_report(
db: &FixedDatabase,
params: &QuickDatabaseParams,
database_id: i32,
status: &str,
plan: &str,
) -> QResult<QuickDatabaseReport> {
let parts = connection_parts(db)?;
let prefix = params
.variable
.strip_suffix("_URL")
.unwrap_or(¶ms.variable);
let host_key = format!("{prefix}_HOST");
let port_key = format!("{prefix}_PORT");
let password_key = format!("{prefix}_PASSWORD");
let username_key = format!("{prefix}_USERNAME");
let tls_key = format!("{prefix}_TLS");
let tls_val = parts.tls.to_string();
let vars: Vec<(&str, &str)> = vec![
(params.variable.as_str(), parts.url.as_str()),
(host_key.as_str(), parts.host.as_str()),
(port_key.as_str(), parts.port.as_str()),
(password_key.as_str(), parts.password.as_str()),
(username_key.as_str(), parts.username.as_str()),
(tls_key.as_str(), tls_val.as_str()),
];
let outcome = super::env_delivery::deliver_vars(¶ms.output_credentials, &vars)
.map_err(|e| QuickDatabaseError::Other(format!("failed to write credentials file: {e}")))?;
let _ = super::env_delivery::ensure_gitignored(¶ms.output_credentials);
Ok(QuickDatabaseReport {
status: status.to_string(),
database: DatabaseSummary {
id: database_id.to_string(),
name: db.name.clone().unwrap_or_else(|| params.name.clone()),
region: db.region.clone(),
plan: plan.to_string(),
tls: parts.tls,
},
credentials_written_to: outcome.path.display().to_string(),
credentials_variable: outcome.variable,
})
}
fn validate_variable(variable: &str) -> QResult<()> {
if super::env_delivery::is_env_var_name(variable) {
Ok(())
} else {
Err(QuickDatabaseError::InvalidName(format!(
"'{variable}' is not a usable environment variable name: use letters, digits and \
underscores, starting with a letter or underscore"
)))
}
}
fn validate_name(name: &str) -> QResult<()> {
let ok = (3..=40).contains(&name.len())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& name
.chars()
.last()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& !name.contains("--");
if !ok {
return Err(QuickDatabaseError::InvalidName(format!(
"invalid database name '{name}': must be 3-40 chars, lowercase letters/digits/hyphens, \
start with a letter, end with a letter or digit, and contain no '--'"
)));
}
Ok(())
}
async fn find_subscription(
client: &CloudClient,
sub_name: &str,
) -> QResult<Option<(i32, FixedSubscription)>> {
let subs = client
.fixed_subscriptions()
.list()
.await
.map_err(|e| classify_cloud_error("list subscriptions", e))?;
Ok(subs
.subscriptions
.unwrap_or_default()
.into_iter()
.find(|s| {
s.name
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(sub_name))
})
.and_then(|s| s.id.map(|id| (id, s))))
}
async fn ensure_free_plan(
client: &CloudClient,
sub: &FixedSubscription,
sub_name: &str,
) -> QResult<()> {
if let Some(price) = sub.price {
return if price == 0 {
Ok(())
} else {
Err(not_free(sub, sub_name))
};
}
let Some(plan_id) = sub.plan_id else {
return Ok(());
};
match paid_plan_ids(client).await {
Ok(paid) if paid.contains(&plan_id) => Err(not_free(sub, sub_name)),
_ => Ok(()),
}
}
fn not_free(sub: &FixedSubscription, sub_name: &str) -> QuickDatabaseError {
QuickDatabaseError::NameConflict(format!(
"subscription '{sub_name}' already exists on the {} plan, which is not free. Choose a \
different --name, or read that database's credentials with \
`redisctl cloud workflow database-credentials`.",
sub.plan_name.as_deref().unwrap_or("current")
))
}
async fn database_to_reuse(
client: &CloudClient,
subscription_id: i32,
name: &str,
) -> QResult<Option<i32>> {
let list = client
.fixed_databases()
.list(subscription_id, None, None)
.await
.map_err(|e| classify_cloud_error("list databases", e))?;
let databases = list
.subscription
.map(|info| info.databases)
.unwrap_or_default();
if let Some(named) = databases.iter().find(|d| {
d.name
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(name))
}) {
let id = named.database_id.ok_or_else(|| {
QuickDatabaseError::Transient(format!(
"the database named '{name}' is listed without an id, so it cannot be read yet; \
it may still be provisioning — retry in a moment"
))
})?;
return Ok(Some(id));
}
match databases.as_slice() {
[] => Ok(None),
[only] => Ok(only.database_id),
many => Err(QuickDatabaseError::NameConflict(format!(
"the subscription for '{name}' holds {} databases and none is named '{name}': {}. \
Read the one you want with `redisctl cloud workflow database-credentials`.",
many.len(),
many.iter()
.map(|d| d.name.as_deref().unwrap_or("<unnamed>"))
.collect::<Vec<_>>()
.join(", ")
))),
}
}
async fn create_subscription(
client: &CloudClient,
sub_name: &str,
params: &QuickDatabaseParams,
) -> QResult<i32> {
let plan_id = pick_free_plan(client).await?;
let request = FixedSubscriptionCreateRequest::builder()
.name(sub_name.to_string())
.plan_id(plan_id)
.build();
let task = client
.fixed_subscriptions()
.create(&request)
.await
.map_err(classify_create_error)?;
run_task(client, task.task_id, params).await
}
async fn create_database(
client: &CloudClient,
subscription_id: i32,
params: &QuickDatabaseParams,
) -> QResult<i32> {
let request = FixedDatabaseCreateRequest::builder()
.name(params.name.clone())
.build();
let task = client
.fixed_databases()
.create(subscription_id, &request)
.await
.map_err(|e| classify_cloud_error("create database", e))?;
run_task(client, task.task_id, params).await
}
async fn run_task(
client: &CloudClient,
task_id: Option<String>,
params: &QuickDatabaseParams,
) -> QResult<i32> {
let task_id = task_id.ok_or_else(|| {
QuickDatabaseError::Other("create response did not include a task id".to_string())
})?;
let timeout = Duration::from_secs(params.wait_timeout as u64);
let interval = Duration::from_secs(params.wait_interval.max(1) as u64);
match poll_task(client, &task_id, timeout, interval, None).await {
Ok(completed) => completed
.response
.and_then(|r| r.resource_id)
.ok_or_else(|| {
QuickDatabaseError::Other("completed task did not return a resource id".to_string())
}),
Err(CoreError::TaskFailed(msg)) => Err(classify_task_error(&msg)),
Err(CoreError::TaskTimeout(_)) => Err(QuickDatabaseError::Transient(format!(
"operation timed out after {}s; retry in a moment",
params.wait_timeout
))),
Err(CoreError::Cloud(e)) => Err(classify_cloud_error("poll task", e)),
Err(other) => Err(QuickDatabaseError::Other(format!("task failed: {other}"))),
}
}
async fn pick_free_plan(client: &CloudClient) -> QResult<i32> {
free_plan_ids(client)
.await?
.into_iter()
.next()
.ok_or_else(|| {
QuickDatabaseError::Other(
"no free Essentials plan is available on this account".to_string(),
)
})
}
async fn free_plan_ids(client: &CloudClient) -> QResult<Vec<i32>> {
plan_ids_priced(client, |price| price == 0).await
}
async fn paid_plan_ids(client: &CloudClient) -> QResult<Vec<i32>> {
plan_ids_priced(client, |price| price != 0).await
}
async fn plan_ids_priced(client: &CloudClient, wanted: fn(i32) -> bool) -> QResult<Vec<i32>> {
let plans = client
.fixed_subscriptions()
.list_plans(None, None)
.await
.map_err(|e| classify_cloud_error("list plans", e))?;
Ok(plans
.plans
.unwrap_or_default()
.into_iter()
.filter(|p| p.price.is_some_and(wanted))
.filter_map(|p| p.id)
.collect())
}
async fn fetch_ready_database(
client: &CloudClient,
subscription_id: i32,
database_id: i32,
params: &QuickDatabaseParams,
) -> QResult<FixedDatabase> {
let start = std::time::Instant::now();
let timeout = Duration::from_secs(params.wait_timeout as u64);
let interval = Duration::from_secs(params.wait_interval.max(1) as u64);
loop {
let db = client
.fixed_databases()
.get_by_id(subscription_id, database_id)
.await
.map_err(|e| classify_cloud_error("read database details", e))?;
if db.public_endpoint.as_deref().is_some_and(|s| !s.is_empty()) {
return Ok(db);
}
if start.elapsed() > timeout {
return Err(QuickDatabaseError::Transient(format!(
"database {database_id} has no public endpoint after {}s; it may still be \
provisioning — retry in a moment",
params.wait_timeout
)));
}
tokio::time::sleep(interval).await;
}
}
struct ConnParts {
url: String,
host: String,
port: String,
password: String,
username: String,
tls: bool,
}
fn connection_parts(db: &FixedDatabase) -> QResult<ConnParts> {
let endpoint = db
.public_endpoint
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
QuickDatabaseError::Other("database has no public endpoint yet".to_string())
})?;
let security = db.security.as_ref();
let tls = security.and_then(|s| s.enable_tls).unwrap_or(false);
let password = security
.and_then(|s| s.password.as_deref())
.ok_or_else(|| {
QuickDatabaseError::Other(
"database password was not returned by the API (the account may have \
'enable-capi-return-empty-bdb-passwords' set); cannot build a connection string"
.to_string(),
)
})?;
let (host, port) = endpoint.rsplit_once(':').unwrap_or((endpoint, ""));
let scheme = if tls { "rediss" } else { "redis" };
let encoded_password = urlencoding::encode(password);
Ok(ConnParts {
url: format!("{scheme}://{DEFAULT_USER}:{encoded_password}@{endpoint}"),
host: host.to_string(),
port: port.to_string(),
password: password.to_string(),
username: DEFAULT_USER.to_string(),
tls,
})
}
fn classify_create_error(err: CloudError) -> QuickDatabaseError {
if err.to_string().to_uppercase().contains("PAYMENT") {
QuickDatabaseError::FreeDbExists(
"free database creation was rejected: the account either already has a free \
database, or is not eligible for the free tier (a payment method may be \
required). Check the Redis Cloud console."
.to_string(),
)
} else {
classify_cloud_error("create subscription", err)
}
}
fn classify_task_error(msg: &str) -> QuickDatabaseError {
let up = msg.to_uppercase();
if up.contains("FREE PLAN") || up.contains("FREE-PLAN") || up.contains("PAYMENT") {
QuickDatabaseError::FreeDbExists(format!("task failed: {msg}"))
} else if is_quota_message(msg) {
QuickDatabaseError::QuotaExceeded(format!("task failed: {msg}"))
} else {
QuickDatabaseError::Other(format!("task failed: {msg}"))
}
}
fn classify_cloud_error(action: &str, err: CloudError) -> QuickDatabaseError {
let relay = |text: &str| format!("{action}: {}", crate::bound_upstream_text(text));
match err {
CloudError::RateLimited { message } => QuickDatabaseError::RateLimited(relay(&message)),
CloudError::ServiceUnavailable { message }
| CloudError::InternalServerError { message } => {
QuickDatabaseError::Transient(relay(&message))
}
CloudError::Request(m) | CloudError::ConnectionError(m) => {
QuickDatabaseError::Transient(relay(&m))
}
CloudError::ApiError { code, message } if (500..=599).contains(&code) => {
QuickDatabaseError::Transient(relay(&message))
}
CloudError::ApiError { code: 429, message } => {
QuickDatabaseError::RateLimited(relay(&message))
}
CloudError::BadRequest { message } if is_quota_message(&message) => {
QuickDatabaseError::QuotaExceeded(relay(&message))
}
other => QuickDatabaseError::Other(relay(&other.to_string())),
}
}
fn is_quota_message(message: &str) -> bool {
let m = message.to_uppercase();
m.contains("QUOTA") || m.contains("LIMIT") || m.contains("EXCEED")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_names() {
for n in ["abc", "my-db", "test-123", "a1b2c3"] {
assert!(validate_name(n).is_ok(), "{n} should be valid");
}
}
#[test]
fn rejects_invalid_names() {
for n in [
"ab",
"-abc",
"abc-",
"ab--cd",
"Abc",
"my_db",
"1abc",
&"a".repeat(41),
] {
assert!(validate_name(n).is_err(), "{n} should be rejected");
}
}
#[test]
fn connection_parts_splits_host_and_port() {
let db: FixedDatabase = serde_json::from_value(serde_json::json!({
"publicEndpoint": "host.example.com:12000",
"security": { "enableTls": true, "password": "s3cr3t" }
}))
.unwrap();
let p = connection_parts(&db).unwrap();
assert_eq!(p.host, "host.example.com");
assert_eq!(p.port, "12000");
assert_eq!(p.username, "default");
assert!(p.tls);
assert_eq!(p.url, "rediss://default:s3cr3t@host.example.com:12000");
}
#[test]
fn variable_name_must_be_an_env_var_name() {
for ok in ["REDIS_URL", "_X", "a1", "MY_APP_REDIS_URL"] {
assert!(validate_variable(ok).is_ok(), "{ok} should be accepted");
}
for bad in [
"",
"1LEADING",
"HAS-DASH",
"HAS SPACE",
"REDIS_URL=x",
"A\nINJECTED=1",
"A\r\nINJECTED=1",
] {
assert!(
validate_variable(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn connection_parts_does_not_assume_tls_when_the_field_is_absent() {
let db: FixedDatabase = serde_json::from_value(serde_json::json!({
"publicEndpoint": "host.example.com:12000",
"security": { "password": "s3cr3t" }
}))
.unwrap();
let p = connection_parts(&db).unwrap();
assert!(!p.tls);
assert_eq!(p.url, "redis://default:s3cr3t@host.example.com:12000");
}
#[test]
fn connection_parts_reports_tls_when_the_api_says_so() {
let db: FixedDatabase = serde_json::from_value(serde_json::json!({
"publicEndpoint": "host.example.com:12000",
"security": { "enableTls": false, "password": "s3cr3t" }
}))
.unwrap();
assert!(!connection_parts(&db).unwrap().tls);
}
#[test]
fn password_is_percent_encoded_in_url_but_raw_in_field() {
let db: FixedDatabase = serde_json::from_value(serde_json::json!({
"publicEndpoint": "h:1",
"security": { "enableTls": true, "password": "p@ss/w#rd%20x" }
}))
.unwrap();
let p = connection_parts(&db).unwrap();
assert_eq!(p.url, "rediss://default:p%40ss%2Fw%23rd%2520x@h:1");
assert_eq!(
urlencoding::decode("p%40ss%2Fw%23rd%2520x").unwrap(),
"p@ss/w#rd%20x"
);
assert_eq!(p.password, "p@ss/w#rd%20x");
}
#[test]
fn plain_url_when_tls_off() {
let db: FixedDatabase = serde_json::from_value(serde_json::json!({
"publicEndpoint": "h:1",
"security": { "enableTls": false, "password": "p" }
}))
.unwrap();
assert_eq!(connection_parts(&db).unwrap().url, "redis://default:p@h:1");
}
#[test]
fn errors_when_password_missing() {
let db: FixedDatabase = serde_json::from_value(serde_json::json!({
"publicEndpoint": "h:1",
"security": { "enableTls": true }
}))
.unwrap();
assert!(connection_parts(&db).is_err());
}
#[test]
fn free_gate_error_classifies_as_free_db_exists() {
let err = CloudError::BadRequest {
message: "FREE_PLAN_IS_ALLOWED_ONLY_FOR_ACCOUNTS_WITH_VALID_PAYMENT_INFO".to_string(),
};
assert!(matches!(
classify_create_error(err),
QuickDatabaseError::FreeDbExists(_)
));
}
#[test]
fn task_free_plan_error_classifies_as_free_db_exists() {
let e = classify_task_error("The account already has a free plan Essentials subscription.");
assert!(matches!(e, QuickDatabaseError::FreeDbExists(_)));
}
#[test]
fn report_serialization_carries_no_secrets() {
let report = QuickDatabaseReport {
status: "ok".to_string(),
database: DatabaseSummary {
id: "9001".to_string(),
name: "my-app".to_string(),
region: Some("us-east-1".to_string()),
plan: "free".to_string(),
tls: true,
},
credentials_written_to: "./.env".to_string(),
credentials_variable: "REDIS_URL".to_string(),
};
let s = serde_json::to_string(&report).unwrap();
assert!(!s.contains("password"));
assert!(!s.contains("rediss://"));
assert!(!s.contains('@'));
}
#[test]
fn transient_5xx_classifies_as_transient() {
let err = CloudError::ServiceUnavailable {
message: "try later".to_string(),
};
assert!(matches!(
classify_cloud_error("x", err),
QuickDatabaseError::Transient(_)
));
}
}