1use crate::storage::{
10 DeployProfile, StorageDeployPreset, StoragePackaging, StorageProfileSelection,
11};
12
13pub const DEFAULT_CONFIG_FILE_PATH: &str = "/etc/reddb/config.json";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum OperationalTopology {
17 Standalone,
18 Serverless,
19 PrimaryReplica,
20 Cluster,
21}
22
23impl OperationalTopology {
24 pub fn parse(raw: &str) -> Option<Self> {
25 match raw {
26 "standalone" => Some(Self::Standalone),
27 "serverless" => Some(Self::Serverless),
28 "primary-replica" => Some(Self::PrimaryReplica),
29 "cluster" => Some(Self::Cluster),
30 _ => None,
31 }
32 }
33
34 pub const fn as_str(self) -> &'static str {
35 match self {
36 Self::Standalone => "standalone",
37 Self::Serverless => "serverless",
38 Self::PrimaryReplica => "primary-replica",
39 Self::Cluster => "cluster",
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum OperationalNodeRole {
46 Standalone,
47 Serverless,
48 Primary,
49 Replica,
50 ClusterMember,
51}
52
53impl OperationalNodeRole {
54 pub fn parse(raw: &str) -> Option<Self> {
55 match raw {
56 "standalone" => Some(Self::Standalone),
57 "serverless" => Some(Self::Serverless),
58 "primary" => Some(Self::Primary),
59 "replica" => Some(Self::Replica),
60 "cluster-member" => Some(Self::ClusterMember),
61 _ => None,
62 }
63 }
64
65 pub const fn process_role(self) -> &'static str {
66 match self {
67 Self::Primary => "primary",
68 Self::Replica => "replica",
69 Self::Standalone | Self::Serverless | Self::ClusterMember => "standalone",
70 }
71 }
72
73 pub const fn implied_topology(self) -> OperationalTopology {
74 match self {
75 Self::Standalone => OperationalTopology::Standalone,
76 Self::Serverless => OperationalTopology::Serverless,
77 Self::Primary | Self::Replica => OperationalTopology::PrimaryReplica,
78 Self::ClusterMember => OperationalTopology::Cluster,
79 }
80 }
81
82 pub const fn as_str(self) -> &'static str {
83 match self {
84 Self::Standalone => "standalone",
85 Self::Serverless => "serverless",
86 Self::Primary => "primary",
87 Self::Replica => "replica",
88 Self::ClusterMember => "cluster-member",
89 }
90 }
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct OperationalBootstrapInput {
95 pub forced_role: Option<String>,
98 pub role_flag: Option<String>,
100 pub topology: Option<String>,
102 pub node_role: Option<String>,
104 pub storage_preset: Option<String>,
105 pub storage_profile: Option<String>,
106 pub storage_packaging: Option<String>,
107 pub replica_count: Option<String>,
108 pub managed_backup: bool,
109 pub wal_retention: bool,
110 pub config_file_path: Option<String>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct OperationalBootstrapPlan {
116 pub topology: OperationalTopology,
117 pub node_role: OperationalNodeRole,
118 pub process_role: String,
119 pub storage_profile: StorageProfileSelection,
120 pub config_file_path: String,
121}
122
123pub fn resolve_operational_bootstrap(
124 input: OperationalBootstrapInput,
125) -> Result<OperationalBootstrapPlan, String> {
126 let topology = parse_optional_topology(input.topology.as_deref())?;
127 let env_node_role = parse_optional_node_role(input.node_role.as_deref())?;
128 let process_node_role =
129 parse_process_role(input.forced_role.as_deref().or(input.role_flag.as_deref()))?;
130
131 let node_role = if let Some(forced) = input.forced_role.as_deref() {
132 parse_process_role(Some(forced))?.expect("forced process role is present")
133 } else {
134 env_node_role
135 .or_else(|| match process_node_role {
136 Some(OperationalNodeRole::Primary | OperationalNodeRole::Replica) => {
137 process_node_role
138 }
139 _ => topology.map(default_node_role_for_topology),
140 })
141 .or(process_node_role)
142 .unwrap_or(OperationalNodeRole::Standalone)
143 };
144
145 let topology = topology.unwrap_or_else(|| node_role.implied_topology());
146 validate_topology_node_role(topology, node_role)?;
147
148 let process_role = process_node_role
149 .unwrap_or(node_role)
150 .process_role()
151 .to_string();
152
153 let storage_profile = resolve_storage_selection(&input, topology)?;
154 let config_file_path = resolve_config_file_path(input.config_file_path.as_deref());
155
156 Ok(OperationalBootstrapPlan {
157 topology,
158 node_role,
159 process_role,
160 storage_profile,
161 config_file_path,
162 })
163}
164
165pub fn resolve_config_file_path(raw: Option<&str>) -> String {
166 raw.filter(|value| !value.trim().is_empty())
167 .unwrap_or(DEFAULT_CONFIG_FILE_PATH)
168 .to_string()
169}
170
171fn parse_optional_topology(raw: Option<&str>) -> Result<Option<OperationalTopology>, String> {
172 raw.filter(|value| !value.trim().is_empty())
173 .map(|value| {
174 OperationalTopology::parse(value).ok_or_else(|| {
175 format!(
176 "topology {value:?} is not recognised (expected standalone, serverless, primary-replica, or cluster)"
177 )
178 })
179 })
180 .transpose()
181}
182
183fn parse_optional_node_role(raw: Option<&str>) -> Result<Option<OperationalNodeRole>, String> {
184 raw.filter(|value| !value.trim().is_empty())
185 .map(|value| {
186 OperationalNodeRole::parse(value).ok_or_else(|| {
187 format!(
188 "node role {value:?} is not recognised (expected standalone, serverless, primary, replica, or cluster-member)"
189 )
190 })
191 })
192 .transpose()
193}
194
195fn parse_process_role(raw: Option<&str>) -> Result<Option<OperationalNodeRole>, String> {
196 raw.filter(|value| !value.trim().is_empty())
197 .map(|value| match value {
198 "standalone" => Ok(OperationalNodeRole::Standalone),
199 "primary" => Ok(OperationalNodeRole::Primary),
200 "replica" => Ok(OperationalNodeRole::Replica),
201 _ => Err(format!(
202 "process role {value:?} is not recognised (expected standalone, primary, or replica)"
203 )),
204 })
205 .transpose()
206}
207
208fn default_node_role_for_topology(topology: OperationalTopology) -> OperationalNodeRole {
209 match topology {
210 OperationalTopology::Standalone => OperationalNodeRole::Standalone,
211 OperationalTopology::Serverless => OperationalNodeRole::Serverless,
212 OperationalTopology::PrimaryReplica => OperationalNodeRole::Primary,
213 OperationalTopology::Cluster => OperationalNodeRole::ClusterMember,
214 }
215}
216
217fn validate_topology_node_role(
218 topology: OperationalTopology,
219 node_role: OperationalNodeRole,
220) -> Result<(), String> {
221 let ok = matches!(
222 (topology, node_role),
223 (
224 OperationalTopology::Standalone,
225 OperationalNodeRole::Standalone
226 ) | (
227 OperationalTopology::Serverless,
228 OperationalNodeRole::Serverless
229 ) | (
230 OperationalTopology::Serverless,
231 OperationalNodeRole::Standalone
232 ) | (
233 OperationalTopology::PrimaryReplica,
234 OperationalNodeRole::Primary
235 ) | (
236 OperationalTopology::PrimaryReplica,
237 OperationalNodeRole::Replica
238 ) | (
239 OperationalTopology::Cluster,
240 OperationalNodeRole::ClusterMember
241 ) | (
242 OperationalTopology::Cluster,
243 OperationalNodeRole::Standalone
244 )
245 );
246 if ok {
247 Ok(())
248 } else {
249 Err(format!(
250 "node role {:?} is not valid for topology {:?}",
251 node_role.as_str(),
252 topology.as_str()
253 ))
254 }
255}
256
257fn resolve_storage_selection(
258 input: &OperationalBootstrapInput,
259 topology: OperationalTopology,
260) -> Result<StorageProfileSelection, String> {
261 let mut selection = if let Some(raw) = input
262 .storage_preset
263 .as_deref()
264 .filter(|value| !value.is_empty())
265 {
266 let preset = StorageDeployPreset::parse(raw).ok_or_else(|| {
267 format!(
268 "storage preset {raw:?} is not recognised (expected embedded, serverless, primary-replica-dev, primary-replica-small, primary-replica-production-ha, primary-replica-backup, primary-replica-wal-retention, or cluster)"
269 )
270 })?;
271 preset.selection()
272 } else {
273 default_storage_selection(topology)
274 };
275
276 if let Some(raw) = input
277 .storage_profile
278 .as_deref()
279 .filter(|value| !value.is_empty())
280 {
281 selection.deploy_profile = DeployProfile::parse(raw).ok_or_else(|| {
282 format!(
283 "storage profile {raw:?} is not recognised (expected embedded, serverless, primary-replica, or cluster)"
284 )
285 })?;
286 }
287
288 if let Some(raw) = input
289 .storage_packaging
290 .as_deref()
291 .filter(|value| !value.is_empty())
292 {
293 selection.packaging = StoragePackaging::parse(raw).ok_or_else(|| {
294 format!(
295 "storage packaging {raw:?} is not recognised (expected single-file or operational-directory)"
296 )
297 })?;
298 }
299
300 if let Some(raw) = input
301 .replica_count
302 .as_deref()
303 .filter(|value| !value.is_empty())
304 {
305 selection.replica_count = raw
306 .parse::<u16>()
307 .map_err(|_| format!("replica-count must be a non-negative integer, got {raw:?}"))?;
308 }
309
310 if input.managed_backup {
311 selection.managed_backup = true;
312 }
313 if input.wal_retention {
314 selection.wal_retention = true;
315 }
316
317 selection.validate()
318}
319
320fn default_storage_selection(topology: OperationalTopology) -> StorageProfileSelection {
321 match topology {
322 OperationalTopology::Standalone => StorageProfileSelection::embedded_single_file(),
323 OperationalTopology::Serverless => StorageDeployPreset::Serverless.selection(),
324 OperationalTopology::PrimaryReplica => StorageDeployPreset::PrimaryReplicaDev.selection(),
325 OperationalTopology::Cluster => StorageDeployPreset::Cluster.selection(),
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn serverless_topology_defaults_to_serverless_storage_and_standalone_process() {
335 let plan = resolve_operational_bootstrap(OperationalBootstrapInput {
336 topology: Some("serverless".to_string()),
337 ..Default::default()
338 })
339 .unwrap();
340
341 assert_eq!(plan.topology, OperationalTopology::Serverless);
342 assert_eq!(plan.node_role, OperationalNodeRole::Serverless);
343 assert_eq!(plan.process_role, "standalone");
344 assert_eq!(
345 plan.storage_profile.deploy_profile,
346 DeployProfile::Serverless
347 );
348 }
349
350 #[test]
351 fn primary_replica_node_role_selects_process_role() {
352 let plan = resolve_operational_bootstrap(OperationalBootstrapInput {
353 topology: Some("primary-replica".to_string()),
354 node_role: Some("replica".to_string()),
355 ..Default::default()
356 })
357 .unwrap();
358
359 assert_eq!(plan.node_role, OperationalNodeRole::Replica);
360 assert_eq!(plan.process_role, "replica");
361 assert_eq!(
362 plan.storage_profile.deploy_profile,
363 DeployProfile::PrimaryReplica
364 );
365 }
366
367 #[test]
368 fn cluster_member_uses_cluster_storage_but_standalone_process_role() {
369 let plan = resolve_operational_bootstrap(OperationalBootstrapInput {
370 topology: Some("cluster".to_string()),
371 node_role: Some("cluster-member".to_string()),
372 ..Default::default()
373 })
374 .unwrap();
375
376 assert_eq!(plan.topology, OperationalTopology::Cluster);
377 assert_eq!(plan.node_role, OperationalNodeRole::ClusterMember);
378 assert_eq!(plan.process_role, "standalone");
379 assert_eq!(plan.storage_profile.deploy_profile, DeployProfile::Cluster);
380 }
381
382 #[test]
383 fn standalone_process_role_does_not_hide_cluster_topology_default() {
384 let plan = resolve_operational_bootstrap(OperationalBootstrapInput {
385 topology: Some("cluster".to_string()),
386 role_flag: Some("standalone".to_string()),
387 ..Default::default()
388 })
389 .unwrap();
390
391 assert_eq!(plan.node_role, OperationalNodeRole::ClusterMember);
392 assert_eq!(plan.process_role, "standalone");
393 assert_eq!(plan.storage_profile.deploy_profile, DeployProfile::Cluster);
394 }
395
396 #[test]
397 fn explicit_storage_preset_wins_over_topology_default() {
398 let plan = resolve_operational_bootstrap(OperationalBootstrapInput {
399 topology: Some("serverless".to_string()),
400 storage_preset: Some("embedded".to_string()),
401 ..Default::default()
402 })
403 .unwrap();
404
405 assert_eq!(plan.topology, OperationalTopology::Serverless);
406 assert_eq!(plan.storage_profile.deploy_profile, DeployProfile::Embedded);
407 }
408
409 #[test]
410 fn incompatible_topology_and_node_role_is_rejected() {
411 let err = resolve_operational_bootstrap(OperationalBootstrapInput {
412 topology: Some("serverless".to_string()),
413 node_role: Some("replica".to_string()),
414 ..Default::default()
415 })
416 .unwrap_err();
417
418 assert!(err.contains("not valid for topology"), "{err}");
419 }
420
421 #[test]
422 fn config_file_path_defaults_to_container_path() {
423 let plan = resolve_operational_bootstrap(OperationalBootstrapInput::default()).unwrap();
424
425 assert_eq!(plan.config_file_path, DEFAULT_CONFIG_FILE_PATH);
426 }
427
428 #[test]
429 fn explicit_config_file_path_wins() {
430 let plan = resolve_operational_bootstrap(OperationalBootstrapInput {
431 config_file_path: Some("/custom/reddb.json".to_string()),
432 ..Default::default()
433 })
434 .unwrap();
435
436 assert_eq!(plan.config_file_path, "/custom/reddb.json");
437 }
438}