Skip to main content

redis_enterprise/
bootstrap.rs

1//! Cluster bootstrap and node joining operations
2//!
3//! ## Overview
4//! - Bootstrap new clusters
5//! - Join nodes to existing clusters
6//! - Configure initial settings
7
8use crate::client::RestClient;
9use crate::error::Result;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use typed_builder::TypedBuilder;
13
14/// Bootstrap configuration for cluster initialization
15///
16/// # Examples
17///
18/// ```rust,no_run
19/// use redis_enterprise::{BootstrapConfig, ClusterBootstrap, CredentialsBootstrap};
20///
21/// let config = BootstrapConfig::builder()
22///     .action("create_cluster")
23///     .cluster(ClusterBootstrap::builder()
24///         .name("my-cluster.local")
25///         .rack_aware(true)
26///         .build())
27///     .credentials(CredentialsBootstrap::builder()
28///         .username("admin@example.com")
29///         .password("secure-password")
30///         .build())
31///     .build();
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
34pub struct BootstrapConfig {
35    /// Action to perform (e.g., 'create', 'join', 'recover_cluster')
36    #[builder(setter(into))]
37    pub action: String,
38    /// Cluster configuration for initialization
39    #[serde(skip_serializing_if = "Option::is_none")]
40    #[builder(default, setter(strip_option))]
41    pub cluster: Option<ClusterBootstrap>,
42    /// Node configuration for bootstrap
43    #[serde(skip_serializing_if = "Option::is_none")]
44    #[builder(default, setter(strip_option))]
45    pub node: Option<NodeBootstrap>,
46    /// Admin credentials for cluster access
47    #[serde(skip_serializing_if = "Option::is_none")]
48    #[builder(default, setter(strip_option))]
49    pub credentials: Option<CredentialsBootstrap>,
50}
51
52/// Cluster bootstrap configuration
53#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
54pub struct ClusterBootstrap {
55    /// Cluster name for identification
56    #[builder(setter(into))]
57    pub name: String,
58    /// DNS suffixes for cluster FQDN resolution
59    #[serde(skip_serializing_if = "Option::is_none")]
60    #[builder(default, setter(strip_option))]
61    pub dns_suffixes: Option<Vec<String>>,
62    /// Enable rack-aware placement for high availability
63    #[serde(skip_serializing_if = "Option::is_none")]
64    #[builder(default, setter(strip_option))]
65    pub rack_aware: Option<bool>,
66}
67
68/// Node bootstrap configuration
69#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
70pub struct NodeBootstrap {
71    /// Storage paths configuration for the node
72    #[serde(skip_serializing_if = "Option::is_none")]
73    #[builder(default, setter(strip_option))]
74    pub paths: Option<NodePaths>,
75}
76
77/// Node paths configuration
78#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
79pub struct NodePaths {
80    /// Path for persistent storage (databases, configuration, logs)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    #[builder(default, setter(into, strip_option))]
83    pub persistent_path: Option<String>,
84    /// Path for ephemeral storage (temporary files, caches)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    #[builder(default, setter(into, strip_option))]
87    pub ephemeral_path: Option<String>,
88}
89
90/// Credentials bootstrap configuration
91#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
92pub struct CredentialsBootstrap {
93    /// Admin username for cluster management
94    #[builder(setter(into))]
95    pub username: String,
96    /// Admin password for authentication
97    #[builder(setter(into))]
98    pub password: String,
99}
100
101/// Inner bootstrap state, as carried inside [`BootstrapStatusResponse`].
102///
103/// The Redis Enterprise REST API uses the field name `state`
104/// (not `status`) for the bootstrap lifecycle value, and pairs it with
105/// `start_time` and `end_time`. The previous shape (`status` /
106/// `progress` / `message`) did not match the wire response —
107/// see `tests/fixtures/bootstrap_status.json`.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct BootstrapStatus {
110    /// Current bootstrap state (e.g. `"idle"`, `"initializing"`,
111    /// `"completed"`, `"failed"`).
112    pub state: String,
113    /// ISO-8601 timestamp when the bootstrap began.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub start_time: Option<String>,
116    /// ISO-8601 timestamp when the bootstrap reached its terminal state.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub end_time: Option<String>,
119}
120
121/// Response wrapper for `GET /v1/bootstrap` (and `POST /v1/bootstrap` /
122/// `POST /v1/bootstrap/join`).
123///
124/// The Redis Enterprise API wraps the bootstrap state in a top-level
125/// `bootstrap_status` field and includes a `local_node_info` object
126/// describing the node that received the request. The previous Rust
127/// shape collapsed these into a single struct and used the wrong
128/// field names; the resulting decode failed against real responses.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct BootstrapStatusResponse {
131    /// Inner bootstrap state.
132    pub bootstrap_status: BootstrapStatus,
133    /// Information about the local node that handled the request.
134    ///
135    /// Typed as `serde_json::Value` because the contents are
136    /// version-specific and operator-oriented (CPU/storage info,
137    /// supported Redis versions, software version, etc.); see the
138    /// recorded `bootstrap_status.json` fixture.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub local_node_info: Option<Value>,
141}
142
143/// Bootstrap handler for cluster initialization
144pub struct BootstrapHandler {
145    client: RestClient,
146}
147
148impl BootstrapHandler {
149    /// Create a new handler bound to the given REST client.
150    pub fn new(client: RestClient) -> Self {
151        BootstrapHandler { client }
152    }
153
154    /// Initialize cluster bootstrap.
155    ///
156    /// `POST /v1/bootstrap`. Returns the [`BootstrapStatusResponse`]
157    /// wrapper (`bootstrap_status` + optional `local_node_info`).
158    pub async fn create(&self, config: BootstrapConfig) -> Result<BootstrapStatusResponse> {
159        self.client.post("/v1/bootstrap", &config).await
160    }
161
162    /// Get current bootstrap status.
163    ///
164    /// `GET /v1/bootstrap`. Returns the [`BootstrapStatusResponse`]
165    /// wrapper.
166    pub async fn status(&self) -> Result<BootstrapStatusResponse> {
167        self.client.get("/v1/bootstrap").await
168    }
169
170    /// Join this node to an existing cluster.
171    ///
172    /// `POST /v1/bootstrap/join`. Returns the [`BootstrapStatusResponse`]
173    /// wrapper.
174    pub async fn join(&self, config: BootstrapConfig) -> Result<BootstrapStatusResponse> {
175        self.client.post("/v1/bootstrap/join", &config).await
176    }
177
178    /// Reset bootstrap (dangerous operation)
179    pub async fn reset(&self) -> Result<()> {
180        self.client.delete("/v1/bootstrap").await
181    }
182
183    /// Validate bootstrap for a specific UID - POST /v1/bootstrap/validate/{uid}
184    pub async fn validate_for(&self, uid: u32, body: Value) -> Result<Value> {
185        self.client
186            .post(&format!("/v1/bootstrap/validate/{}", uid), &body)
187            .await
188    }
189
190    /// Post a specific bootstrap action - POST /v1/bootstrap/{action}
191    pub async fn post_action(&self, action: &str, body: Value) -> Result<Value> {
192        self.client
193            .post(&format!("/v1/bootstrap/{}", action), &body)
194            .await
195    }
196}