1use std::path::PathBuf;
18use std::time::Duration;
19
20use redis_cloud::fixed::databases::{FixedDatabase, FixedDatabaseCreateRequest};
21use redis_cloud::fixed::subscriptions::{FixedSubscription, FixedSubscriptionCreateRequest};
22use redis_cloud::{CloudClient, CloudError};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::error::CoreError;
27use crate::progress::poll_task;
28
29const SUBSCRIPTION_PREFIX: &str = "redisctl-";
30const DEFAULT_USER: &str = "default";
31
32#[derive(Debug, Error)]
35pub enum QuickDatabaseError {
36 #[error("{0}")]
37 InvalidName(String),
38 #[error("{0}")]
39 NameConflict(String),
40 #[error("{0}")]
41 FreeDbExists(String),
42 #[error("{0}")]
43 QuotaExceeded(String),
44 #[error("{0}")]
45 NotAuthenticated(String),
46 #[error("{0}")]
47 Transient(String),
48 #[error("{0}")]
49 RateLimited(String),
50 #[error("{0}")]
51 Other(String),
52}
53
54type QResult<T> = std::result::Result<T, QuickDatabaseError>;
55
56#[derive(Debug, Clone)]
58pub struct QuickDatabaseParams {
59 pub name: String,
61 pub output_credentials: PathBuf,
63 pub variable: String,
65 pub wait_timeout: u32,
67 pub wait_interval: u32,
69}
70
71impl QuickDatabaseParams {
72 pub fn new(name: impl Into<String>) -> Self {
74 Self {
75 name: name.into(),
76 output_credentials: PathBuf::from("./.env"),
77 variable: "REDIS_URL".to_string(),
78 wait_timeout: 600,
79 wait_interval: 5,
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct QuickDatabaseReport {
87 pub status: String,
89 pub database: DatabaseSummary,
90 pub credentials_written_to: String,
91 pub credentials_variable: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct DatabaseSummary {
96 pub id: String,
97 pub name: String,
98 pub region: Option<String>,
99 pub plan: String,
100 pub tls: bool,
101}
102
103pub async fn provision(
108 client: &CloudClient,
109 params: &QuickDatabaseParams,
110) -> QResult<QuickDatabaseReport> {
111 validate_name(¶ms.name)?;
112 validate_variable(¶ms.variable)?;
113 let sub_name = format!("{SUBSCRIPTION_PREFIX}{}", params.name);
114
115 let (subscription_id, database_id, status) = match find_subscription(client, &sub_name).await? {
117 Some((sub_id, sub)) => {
118 ensure_free_plan(client, &sub, &sub_name).await?;
119 match database_to_reuse(client, sub_id, ¶ms.name).await? {
120 Some(db_id) => (sub_id, db_id, "reused"),
121 None => (sub_id, create_database(client, sub_id, params).await?, "ok"),
123 }
124 }
125 None => {
126 let sub_id = create_subscription(client, &sub_name, params).await?;
127 (sub_id, create_database(client, sub_id, params).await?, "ok")
128 }
129 };
130
131 let db = fetch_ready_database(client, subscription_id, database_id, params).await?;
134 deliver_and_report(&db, params, database_id, status, "free")
135}
136
137pub async fn existing_database_report(
141 client: &CloudClient,
142 subscription_id: i32,
143 database_id: i32,
144 params: &QuickDatabaseParams,
145) -> QResult<QuickDatabaseReport> {
146 validate_variable(¶ms.variable)?;
147 let db = fetch_ready_database(client, subscription_id, database_id, params).await?;
148 deliver_and_report(&db, params, database_id, "existing", "essentials")
149}
150
151fn deliver_and_report(
155 db: &FixedDatabase,
156 params: &QuickDatabaseParams,
157 database_id: i32,
158 status: &str,
159 plan: &str,
160) -> QResult<QuickDatabaseReport> {
161 let parts = connection_parts(db)?;
162
163 let prefix = params
166 .variable
167 .strip_suffix("_URL")
168 .unwrap_or(¶ms.variable);
169 let host_key = format!("{prefix}_HOST");
170 let port_key = format!("{prefix}_PORT");
171 let password_key = format!("{prefix}_PASSWORD");
172 let username_key = format!("{prefix}_USERNAME");
173 let tls_key = format!("{prefix}_TLS");
174 let tls_val = parts.tls.to_string();
175 let vars: Vec<(&str, &str)> = vec![
176 (params.variable.as_str(), parts.url.as_str()),
177 (host_key.as_str(), parts.host.as_str()),
178 (port_key.as_str(), parts.port.as_str()),
179 (password_key.as_str(), parts.password.as_str()),
180 (username_key.as_str(), parts.username.as_str()),
181 (tls_key.as_str(), tls_val.as_str()),
182 ];
183 let outcome = super::env_delivery::deliver_vars(¶ms.output_credentials, &vars)
184 .map_err(|e| QuickDatabaseError::Other(format!("failed to write credentials file: {e}")))?;
185 let _ = super::env_delivery::ensure_gitignored(¶ms.output_credentials);
186
187 Ok(QuickDatabaseReport {
188 status: status.to_string(),
189 database: DatabaseSummary {
190 id: database_id.to_string(),
191 name: db.name.clone().unwrap_or_else(|| params.name.clone()),
192 region: db.region.clone(),
193 plan: plan.to_string(),
194 tls: parts.tls,
195 },
196 credentials_written_to: outcome.path.display().to_string(),
197 credentials_variable: outcome.variable,
198 })
199}
200
201fn validate_variable(variable: &str) -> QResult<()> {
205 if super::env_delivery::is_env_var_name(variable) {
206 Ok(())
207 } else {
208 Err(QuickDatabaseError::InvalidName(format!(
209 "'{variable}' is not a usable environment variable name: use letters, digits and \
210 underscores, starting with a letter or underscore"
211 )))
212 }
213}
214
215fn validate_name(name: &str) -> QResult<()> {
216 let ok = (3..=40).contains(&name.len())
217 && name
218 .chars()
219 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
220 && name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
221 && name
222 .chars()
223 .last()
224 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
225 && !name.contains("--");
226 if !ok {
227 return Err(QuickDatabaseError::InvalidName(format!(
228 "invalid database name '{name}': must be 3-40 chars, lowercase letters/digits/hyphens, \
229 start with a letter, end with a letter or digit, and contain no '--'"
230 )));
231 }
232 Ok(())
233}
234
235async fn find_subscription(
236 client: &CloudClient,
237 sub_name: &str,
238) -> QResult<Option<(i32, FixedSubscription)>> {
239 let subs = client
240 .fixed_subscriptions()
241 .list()
242 .await
243 .map_err(|e| classify_cloud_error("list subscriptions", e))?;
244 Ok(subs
245 .subscriptions
246 .unwrap_or_default()
247 .into_iter()
248 .find(|s| {
249 s.name
250 .as_deref()
251 .is_some_and(|n| n.eq_ignore_ascii_case(sub_name))
252 })
253 .and_then(|s| s.id.map(|id| (id, s))))
254}
255
256async fn ensure_free_plan(
263 client: &CloudClient,
264 sub: &FixedSubscription,
265 sub_name: &str,
266) -> QResult<()> {
267 if let Some(price) = sub.price {
269 return if price == 0 {
270 Ok(())
271 } else {
272 Err(not_free(sub, sub_name))
273 };
274 }
275 let Some(plan_id) = sub.plan_id else {
283 return Ok(());
284 };
285 match paid_plan_ids(client).await {
286 Ok(paid) if paid.contains(&plan_id) => Err(not_free(sub, sub_name)),
287 _ => Ok(()),
288 }
289}
290
291fn not_free(sub: &FixedSubscription, sub_name: &str) -> QuickDatabaseError {
292 QuickDatabaseError::NameConflict(format!(
293 "subscription '{sub_name}' already exists on the {} plan, which is not free. Choose a \
294 different --name, or read that database's credentials with \
295 `redisctl cloud workflow database-credentials`.",
296 sub.plan_name.as_deref().unwrap_or("current")
297 ))
298}
299
300async fn database_to_reuse(
306 client: &CloudClient,
307 subscription_id: i32,
308 name: &str,
309) -> QResult<Option<i32>> {
310 let list = client
311 .fixed_databases()
312 .list(subscription_id, None, None)
313 .await
314 .map_err(|e| classify_cloud_error("list databases", e))?;
315 let databases = list
316 .subscription
317 .map(|info| info.databases)
318 .unwrap_or_default();
319
320 if let Some(named) = databases.iter().find(|d| {
324 d.name
325 .as_deref()
326 .is_some_and(|n| n.eq_ignore_ascii_case(name))
327 }) {
328 let id = named.database_id.ok_or_else(|| {
332 QuickDatabaseError::Transient(format!(
333 "the database named '{name}' is listed without an id, so it cannot be read yet; \
334 it may still be provisioning — retry in a moment"
335 ))
336 })?;
337 return Ok(Some(id));
338 }
339 match databases.as_slice() {
340 [] => Ok(None),
341 [only] => Ok(only.database_id),
342 many => Err(QuickDatabaseError::NameConflict(format!(
343 "the subscription for '{name}' holds {} databases and none is named '{name}': {}. \
344 Read the one you want with `redisctl cloud workflow database-credentials`.",
345 many.len(),
346 many.iter()
347 .map(|d| d.name.as_deref().unwrap_or("<unnamed>"))
348 .collect::<Vec<_>>()
349 .join(", ")
350 ))),
351 }
352}
353
354async fn create_subscription(
355 client: &CloudClient,
356 sub_name: &str,
357 params: &QuickDatabaseParams,
358) -> QResult<i32> {
359 let plan_id = pick_free_plan(client).await?;
360 let request = FixedSubscriptionCreateRequest::builder()
362 .name(sub_name.to_string())
363 .plan_id(plan_id)
364 .build();
365 let task = client
366 .fixed_subscriptions()
367 .create(&request)
368 .await
369 .map_err(classify_create_error)?;
370 run_task(client, task.task_id, params).await
371}
372
373async fn create_database(
374 client: &CloudClient,
375 subscription_id: i32,
376 params: &QuickDatabaseParams,
377) -> QResult<i32> {
378 let request = FixedDatabaseCreateRequest::builder()
379 .name(params.name.clone())
380 .build();
381 let task = client
382 .fixed_databases()
383 .create(subscription_id, &request)
384 .await
385 .map_err(|e| classify_cloud_error("create database", e))?;
386 run_task(client, task.task_id, params).await
387}
388
389async fn run_task(
392 client: &CloudClient,
393 task_id: Option<String>,
394 params: &QuickDatabaseParams,
395) -> QResult<i32> {
396 let task_id = task_id.ok_or_else(|| {
397 QuickDatabaseError::Other("create response did not include a task id".to_string())
398 })?;
399 let timeout = Duration::from_secs(params.wait_timeout as u64);
400 let interval = Duration::from_secs(params.wait_interval.max(1) as u64);
401
402 match poll_task(client, &task_id, timeout, interval, None).await {
403 Ok(completed) => completed
404 .response
405 .and_then(|r| r.resource_id)
406 .ok_or_else(|| {
407 QuickDatabaseError::Other("completed task did not return a resource id".to_string())
408 }),
409 Err(CoreError::TaskFailed(msg)) => Err(classify_task_error(&msg)),
410 Err(CoreError::TaskTimeout(_)) => Err(QuickDatabaseError::Transient(format!(
411 "operation timed out after {}s; retry in a moment",
412 params.wait_timeout
413 ))),
414 Err(CoreError::Cloud(e)) => Err(classify_cloud_error("poll task", e)),
415 Err(other) => Err(QuickDatabaseError::Other(format!("task failed: {other}"))),
416 }
417}
418
419async fn pick_free_plan(client: &CloudClient) -> QResult<i32> {
422 free_plan_ids(client)
423 .await?
424 .into_iter()
425 .next()
426 .ok_or_else(|| {
427 QuickDatabaseError::Other(
428 "no free Essentials plan is available on this account".to_string(),
429 )
430 })
431}
432
433async fn free_plan_ids(client: &CloudClient) -> QResult<Vec<i32>> {
436 plan_ids_priced(client, |price| price == 0).await
437}
438
439async fn paid_plan_ids(client: &CloudClient) -> QResult<Vec<i32>> {
441 plan_ids_priced(client, |price| price != 0).await
442}
443
444async fn plan_ids_priced(client: &CloudClient, wanted: fn(i32) -> bool) -> QResult<Vec<i32>> {
447 let plans = client
448 .fixed_subscriptions()
449 .list_plans(None, None)
450 .await
451 .map_err(|e| classify_cloud_error("list plans", e))?;
452 Ok(plans
453 .plans
454 .unwrap_or_default()
455 .into_iter()
456 .filter(|p| p.price.is_some_and(wanted))
457 .filter_map(|p| p.id)
458 .collect())
459}
460
461async fn fetch_ready_database(
464 client: &CloudClient,
465 subscription_id: i32,
466 database_id: i32,
467 params: &QuickDatabaseParams,
468) -> QResult<FixedDatabase> {
469 let start = std::time::Instant::now();
470 let timeout = Duration::from_secs(params.wait_timeout as u64);
471 let interval = Duration::from_secs(params.wait_interval.max(1) as u64);
472
473 loop {
474 let db = client
475 .fixed_databases()
476 .get_by_id(subscription_id, database_id)
477 .await
478 .map_err(|e| classify_cloud_error("read database details", e))?;
479 if db.public_endpoint.as_deref().is_some_and(|s| !s.is_empty()) {
480 return Ok(db);
481 }
482 if start.elapsed() > timeout {
483 return Err(QuickDatabaseError::Transient(format!(
484 "database {database_id} has no public endpoint after {}s; it may still be \
485 provisioning — retry in a moment",
486 params.wait_timeout
487 )));
488 }
489 tokio::time::sleep(interval).await;
490 }
491}
492
493struct ConnParts {
495 url: String,
496 host: String,
497 port: String,
498 password: String,
499 username: String,
500 tls: bool,
501}
502
503fn connection_parts(db: &FixedDatabase) -> QResult<ConnParts> {
506 let endpoint = db
507 .public_endpoint
508 .as_deref()
509 .filter(|s| !s.is_empty())
510 .ok_or_else(|| {
511 QuickDatabaseError::Other("database has no public endpoint yet".to_string())
512 })?;
513 let security = db.security.as_ref();
514 let tls = security.and_then(|s| s.enable_tls).unwrap_or(false);
515 let password = security
516 .and_then(|s| s.password.as_deref())
517 .ok_or_else(|| {
518 QuickDatabaseError::Other(
519 "database password was not returned by the API (the account may have \
520 'enable-capi-return-empty-bdb-passwords' set); cannot build a connection string"
521 .to_string(),
522 )
523 })?;
524 let (host, port) = endpoint.rsplit_once(':').unwrap_or((endpoint, ""));
525 let scheme = if tls { "rediss" } else { "redis" };
526 let encoded_password = urlencoding::encode(password);
530 Ok(ConnParts {
531 url: format!("{scheme}://{DEFAULT_USER}:{encoded_password}@{endpoint}"),
532 host: host.to_string(),
533 port: port.to_string(),
534 password: password.to_string(),
535 username: DEFAULT_USER.to_string(),
536 tls,
537 })
538}
539
540fn classify_create_error(err: CloudError) -> QuickDatabaseError {
544 if err.to_string().to_uppercase().contains("PAYMENT") {
545 QuickDatabaseError::FreeDbExists(
546 "free database creation was rejected: the account either already has a free \
547 database, or is not eligible for the free tier (a payment method may be \
548 required). Check the Redis Cloud console."
549 .to_string(),
550 )
551 } else {
552 classify_cloud_error("create subscription", err)
553 }
554}
555
556fn classify_task_error(msg: &str) -> QuickDatabaseError {
560 let up = msg.to_uppercase();
561 if up.contains("FREE PLAN") || up.contains("FREE-PLAN") || up.contains("PAYMENT") {
562 QuickDatabaseError::FreeDbExists(format!("task failed: {msg}"))
563 } else if is_quota_message(msg) {
564 QuickDatabaseError::QuotaExceeded(format!("task failed: {msg}"))
565 } else {
566 QuickDatabaseError::Other(format!("task failed: {msg}"))
567 }
568}
569
570fn classify_cloud_error(action: &str, err: CloudError) -> QuickDatabaseError {
573 let relay = |text: &str| format!("{action}: {}", crate::bound_upstream_text(text));
574 match err {
575 CloudError::RateLimited { message } => QuickDatabaseError::RateLimited(relay(&message)),
576 CloudError::ServiceUnavailable { message }
577 | CloudError::InternalServerError { message } => {
578 QuickDatabaseError::Transient(relay(&message))
579 }
580 CloudError::Request(m) | CloudError::ConnectionError(m) => {
581 QuickDatabaseError::Transient(relay(&m))
582 }
583 CloudError::ApiError { code, message } if (500..=599).contains(&code) => {
584 QuickDatabaseError::Transient(relay(&message))
585 }
586 CloudError::ApiError { code: 429, message } => {
587 QuickDatabaseError::RateLimited(relay(&message))
588 }
589 CloudError::BadRequest { message } if is_quota_message(&message) => {
590 QuickDatabaseError::QuotaExceeded(relay(&message))
591 }
592 other => QuickDatabaseError::Other(relay(&other.to_string())),
593 }
594}
595
596fn is_quota_message(message: &str) -> bool {
597 let m = message.to_uppercase();
598 m.contains("QUOTA") || m.contains("LIMIT") || m.contains("EXCEED")
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn accepts_valid_names() {
607 for n in ["abc", "my-db", "test-123", "a1b2c3"] {
608 assert!(validate_name(n).is_ok(), "{n} should be valid");
609 }
610 }
611
612 #[test]
613 fn rejects_invalid_names() {
614 for n in [
615 "ab",
616 "-abc",
617 "abc-",
618 "ab--cd",
619 "Abc",
620 "my_db",
621 "1abc",
622 &"a".repeat(41),
623 ] {
624 assert!(validate_name(n).is_err(), "{n} should be rejected");
625 }
626 }
627
628 #[test]
629 fn connection_parts_splits_host_and_port() {
630 let db: FixedDatabase = serde_json::from_value(serde_json::json!({
631 "publicEndpoint": "host.example.com:12000",
632 "security": { "enableTls": true, "password": "s3cr3t" }
633 }))
634 .unwrap();
635 let p = connection_parts(&db).unwrap();
636 assert_eq!(p.host, "host.example.com");
637 assert_eq!(p.port, "12000");
638 assert_eq!(p.username, "default");
639 assert!(p.tls);
640 assert_eq!(p.url, "rediss://default:s3cr3t@host.example.com:12000");
641 }
642
643 #[test]
649 fn variable_name_must_be_an_env_var_name() {
650 for ok in ["REDIS_URL", "_X", "a1", "MY_APP_REDIS_URL"] {
651 assert!(validate_variable(ok).is_ok(), "{ok} should be accepted");
652 }
653 for bad in [
654 "",
655 "1LEADING",
656 "HAS-DASH",
657 "HAS SPACE",
658 "REDIS_URL=x",
659 "A\nINJECTED=1",
660 "A\r\nINJECTED=1",
661 ] {
662 assert!(
663 validate_variable(bad).is_err(),
664 "{bad:?} should be rejected"
665 );
666 }
667 }
668
669 #[test]
670 fn connection_parts_does_not_assume_tls_when_the_field_is_absent() {
671 let db: FixedDatabase = serde_json::from_value(serde_json::json!({
672 "publicEndpoint": "host.example.com:12000",
673 "security": { "password": "s3cr3t" }
674 }))
675 .unwrap();
676 let p = connection_parts(&db).unwrap();
677 assert!(!p.tls);
678 assert_eq!(p.url, "redis://default:s3cr3t@host.example.com:12000");
679 }
680
681 #[test]
682 fn connection_parts_reports_tls_when_the_api_says_so() {
683 let db: FixedDatabase = serde_json::from_value(serde_json::json!({
684 "publicEndpoint": "host.example.com:12000",
685 "security": { "enableTls": false, "password": "s3cr3t" }
686 }))
687 .unwrap();
688 assert!(!connection_parts(&db).unwrap().tls);
689 }
690
691 #[test]
692 fn password_is_percent_encoded_in_url_but_raw_in_field() {
693 let db: FixedDatabase = serde_json::from_value(serde_json::json!({
695 "publicEndpoint": "h:1",
696 "security": { "enableTls": true, "password": "p@ss/w#rd%20x" }
697 }))
698 .unwrap();
699 let p = connection_parts(&db).unwrap();
700 assert_eq!(p.url, "rediss://default:p%40ss%2Fw%23rd%2520x@h:1");
703 assert_eq!(
705 urlencoding::decode("p%40ss%2Fw%23rd%2520x").unwrap(),
706 "p@ss/w#rd%20x"
707 );
708 assert_eq!(p.password, "p@ss/w#rd%20x");
710 }
711
712 #[test]
713 fn plain_url_when_tls_off() {
714 let db: FixedDatabase = serde_json::from_value(serde_json::json!({
715 "publicEndpoint": "h:1",
716 "security": { "enableTls": false, "password": "p" }
717 }))
718 .unwrap();
719 assert_eq!(connection_parts(&db).unwrap().url, "redis://default:p@h:1");
720 }
721
722 #[test]
723 fn errors_when_password_missing() {
724 let db: FixedDatabase = serde_json::from_value(serde_json::json!({
725 "publicEndpoint": "h:1",
726 "security": { "enableTls": true }
727 }))
728 .unwrap();
729 assert!(connection_parts(&db).is_err());
730 }
731
732 #[test]
733 fn free_gate_error_classifies_as_free_db_exists() {
734 let err = CloudError::BadRequest {
735 message: "FREE_PLAN_IS_ALLOWED_ONLY_FOR_ACCOUNTS_WITH_VALID_PAYMENT_INFO".to_string(),
736 };
737 assert!(matches!(
738 classify_create_error(err),
739 QuickDatabaseError::FreeDbExists(_)
740 ));
741 }
742
743 #[test]
744 fn task_free_plan_error_classifies_as_free_db_exists() {
745 let e = classify_task_error("The account already has a free plan Essentials subscription.");
746 assert!(matches!(e, QuickDatabaseError::FreeDbExists(_)));
747 }
748
749 #[test]
750 fn report_serialization_carries_no_secrets() {
751 let report = QuickDatabaseReport {
754 status: "ok".to_string(),
755 database: DatabaseSummary {
756 id: "9001".to_string(),
757 name: "my-app".to_string(),
758 region: Some("us-east-1".to_string()),
759 plan: "free".to_string(),
760 tls: true,
761 },
762 credentials_written_to: "./.env".to_string(),
763 credentials_variable: "REDIS_URL".to_string(),
764 };
765 let s = serde_json::to_string(&report).unwrap();
766 assert!(!s.contains("password"));
767 assert!(!s.contains("rediss://"));
768 assert!(!s.contains('@'));
769 }
770
771 #[test]
772 fn transient_5xx_classifies_as_transient() {
773 let err = CloudError::ServiceUnavailable {
774 message: "try later".to_string(),
775 };
776 assert!(matches!(
777 classify_cloud_error("x", err),
778 QuickDatabaseError::Transient(_)
779 ));
780 }
781}