Skip to main content

reddb_server/
operational_bootstrap.rs

1//! Operational bootstrap planning for deployment topology and runtime config.
2//!
3//! This module is the CLI/runtime seam for deployment shape. It translates the
4//! human container contract (`REDDB_TOPOLOGY`, `REDDB_NODE_ROLE`,
5//! `REDDB_CONFIG_FILE`, storage preset/profile/env overrides, and explicit CLI
6//! role flags) into the process role, config overlay path, and
7//! [`StorageProfileSelection`] that the server runtime already understands.
8
9use 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    /// Command-level override. `red replica` sets this to `replica`; it wins
96    /// over all env and flag input.
97    pub forced_role: Option<String>,
98    /// `red server --role <standalone|primary|replica>`.
99    pub role_flag: Option<String>,
100    /// Human deployment topology, normally `REDDB_TOPOLOGY`.
101    pub topology: Option<String>,
102    /// Human node role, normally `REDDB_NODE_ROLE`.
103    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    /// Mounted config file path, normally `REDDB_CONFIG_FILE`.
111    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}