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}