Skip to main content

reddb_server/storage/
profile.rs

1//! Storage/deploy profile selection contract.
2//!
3//! This module is intentionally pure: it declares the operator-facing
4//! profile/package/preset vocabulary and validates combinations before later
5//! storage layout work chooses concrete directories.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum DeployProfile {
12    Embedded,
13    Serverless,
14    PrimaryReplica,
15    Cluster,
16}
17
18impl DeployProfile {
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::Embedded => "embedded",
22            Self::Serverless => "serverless",
23            Self::PrimaryReplica => "primary-replica",
24            Self::Cluster => "cluster",
25        }
26    }
27
28    pub fn parse(raw: &str) -> Option<Self> {
29        match normalize(raw).as_str() {
30            "embedded" => Some(Self::Embedded),
31            "serverless" => Some(Self::Serverless),
32            "primaryreplica" | "primary" | "replica" => Some(Self::PrimaryReplica),
33            "cluster" => Some(Self::Cluster),
34            _ => None,
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum StoragePackaging {
42    SingleFile,
43    OperationalDirectory,
44}
45
46impl StoragePackaging {
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::SingleFile => "single-file",
50            Self::OperationalDirectory => "operational-directory",
51        }
52    }
53
54    pub const fn is_operational(self) -> bool {
55        matches!(self, Self::OperationalDirectory)
56    }
57
58    pub fn parse(raw: &str) -> Option<Self> {
59        match normalize(raw).as_str() {
60            "singlefile" | "embedded" => Some(Self::SingleFile),
61            "operationaldirectory" | "operational" | "directory" | "dir" => {
62                Some(Self::OperationalDirectory)
63            }
64            _ => None,
65        }
66    }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71pub enum StorageDeployPreset {
72    Embedded,
73    Serverless,
74    PrimaryReplicaDev,
75    PrimaryReplicaSmall,
76    PrimaryReplicaProductionHa,
77    PrimaryReplicaBackup,
78    PrimaryReplicaWalRetention,
79    Cluster,
80}
81
82impl StorageDeployPreset {
83    pub const ALL: [Self; 8] = [
84        Self::Embedded,
85        Self::Serverless,
86        Self::PrimaryReplicaDev,
87        Self::PrimaryReplicaSmall,
88        Self::PrimaryReplicaProductionHa,
89        Self::PrimaryReplicaBackup,
90        Self::PrimaryReplicaWalRetention,
91        Self::Cluster,
92    ];
93
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::Embedded => "embedded",
97            Self::Serverless => "serverless",
98            Self::PrimaryReplicaDev => "primary-replica-dev",
99            Self::PrimaryReplicaSmall => "primary-replica-small",
100            Self::PrimaryReplicaProductionHa => "primary-replica-production-ha",
101            Self::PrimaryReplicaBackup => "primary-replica-backup",
102            Self::PrimaryReplicaWalRetention => "primary-replica-wal-retention",
103            Self::Cluster => "cluster",
104        }
105    }
106
107    pub const fn selection(self) -> StorageProfileSelection {
108        match self {
109            Self::Embedded => StorageProfileSelection {
110                deploy_profile: DeployProfile::Embedded,
111                packaging: StoragePackaging::SingleFile,
112                replica_count: 0,
113                managed_backup: false,
114                wal_retention: false,
115            },
116            Self::Serverless => StorageProfileSelection {
117                deploy_profile: DeployProfile::Serverless,
118                packaging: StoragePackaging::OperationalDirectory,
119                replica_count: 0,
120                managed_backup: true,
121                wal_retention: true,
122            },
123            Self::PrimaryReplicaDev => StorageProfileSelection {
124                deploy_profile: DeployProfile::PrimaryReplica,
125                packaging: StoragePackaging::SingleFile,
126                replica_count: 0,
127                managed_backup: false,
128                wal_retention: false,
129            },
130            Self::PrimaryReplicaSmall => StorageProfileSelection {
131                deploy_profile: DeployProfile::PrimaryReplica,
132                packaging: StoragePackaging::SingleFile,
133                replica_count: 1,
134                managed_backup: false,
135                wal_retention: false,
136            },
137            Self::PrimaryReplicaProductionHa => StorageProfileSelection {
138                deploy_profile: DeployProfile::PrimaryReplica,
139                packaging: StoragePackaging::OperationalDirectory,
140                replica_count: 2,
141                managed_backup: false,
142                wal_retention: false,
143            },
144            Self::PrimaryReplicaBackup => StorageProfileSelection {
145                deploy_profile: DeployProfile::PrimaryReplica,
146                packaging: StoragePackaging::OperationalDirectory,
147                replica_count: 1,
148                managed_backup: true,
149                wal_retention: false,
150            },
151            Self::PrimaryReplicaWalRetention => StorageProfileSelection {
152                deploy_profile: DeployProfile::PrimaryReplica,
153                packaging: StoragePackaging::OperationalDirectory,
154                replica_count: 1,
155                managed_backup: false,
156                wal_retention: true,
157            },
158            Self::Cluster => StorageProfileSelection {
159                deploy_profile: DeployProfile::Cluster,
160                packaging: StoragePackaging::OperationalDirectory,
161                replica_count: 3,
162                managed_backup: true,
163                wal_retention: true,
164            },
165        }
166    }
167
168    pub fn parse(raw: &str) -> Option<Self> {
169        match normalize(raw).as_str() {
170            "embedded" => Some(Self::Embedded),
171            "serverless" => Some(Self::Serverless),
172            "primaryreplicadev" | "replicadev" | "dev" => Some(Self::PrimaryReplicaDev),
173            "primaryreplicasmall" | "replicasmall" | "small" => Some(Self::PrimaryReplicaSmall),
174            "primaryreplicaproductionha" | "productionha" | "ha" => {
175                Some(Self::PrimaryReplicaProductionHa)
176            }
177            "primaryreplicabackup" | "backup" => Some(Self::PrimaryReplicaBackup),
178            "primaryreplicawalretention" | "walretention" | "pitr" => {
179                Some(Self::PrimaryReplicaWalRetention)
180            }
181            "cluster" => Some(Self::Cluster),
182            _ => None,
183        }
184    }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
188pub struct StorageProfileSelection {
189    pub deploy_profile: DeployProfile,
190    pub packaging: StoragePackaging,
191    pub replica_count: u16,
192    pub managed_backup: bool,
193    pub wal_retention: bool,
194}
195
196impl StorageProfileSelection {
197    pub const fn embedded_single_file() -> Self {
198        StorageDeployPreset::Embedded.selection()
199    }
200
201    pub fn inferred_preset(self) -> Option<StorageDeployPreset> {
202        StorageDeployPreset::ALL
203            .into_iter()
204            .find(|preset| preset.selection() == self)
205    }
206
207    pub fn preset_name(self) -> &'static str {
208        self.inferred_preset()
209            .map(StorageDeployPreset::as_str)
210            .unwrap_or("custom")
211    }
212
213    pub fn validate(self) -> Result<Self, String> {
214        if self.deploy_profile == DeployProfile::Cluster && !self.packaging.is_operational() {
215            return Err(
216                "storage deploy profile `cluster` requires storage packaging `operational-directory`; embedded single-file packaging is not allowed"
217                    .to_string(),
218            );
219        }
220
221        if self.deploy_profile == DeployProfile::PrimaryReplica
222            && !self.packaging.is_operational()
223            && (self.managed_backup || self.wal_retention || self.replica_count > 1)
224        {
225            let reason = if self.managed_backup {
226                "managed backup"
227            } else if self.wal_retention {
228                "WAL retention"
229            } else {
230                "more than one replica"
231            };
232            return Err(format!(
233                "production primary-replica deployment with {reason} requires storage packaging `operational-directory`"
234            ));
235        }
236
237        if matches!(
238            self.deploy_profile,
239            DeployProfile::Serverless | DeployProfile::Cluster
240        ) && self.replica_count > 0
241            && self.deploy_profile != DeployProfile::Cluster
242        {
243            return Err(format!(
244                "storage deploy profile `{}` does not accept replica_count={}",
245                self.deploy_profile.as_str(),
246                self.replica_count
247            ));
248        }
249
250        Ok(self)
251    }
252}
253
254fn normalize(raw: &str) -> String {
255    raw.trim()
256        .chars()
257        .filter(|ch| ch.is_ascii_alphanumeric())
258        .flat_map(char::to_lowercase)
259        .collect()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn dev_primary_replica_allows_single_file() {
268        StorageDeployPreset::PrimaryReplicaDev
269            .selection()
270            .validate()
271            .expect("dev primary-replica preset should allow single-file packaging");
272        StorageDeployPreset::PrimaryReplicaSmall
273            .selection()
274            .validate()
275            .expect("small primary-replica preset should allow single-file packaging");
276    }
277
278    #[test]
279    fn production_primary_replica_requires_operational_directory() {
280        let err = StorageProfileSelection {
281            deploy_profile: DeployProfile::PrimaryReplica,
282            packaging: StoragePackaging::SingleFile,
283            replica_count: 2,
284            managed_backup: false,
285            wal_retention: false,
286        }
287        .validate()
288        .unwrap_err();
289        assert!(err.contains("production primary-replica"));
290        assert!(err.contains("operational-directory"));
291
292        let err = StorageProfileSelection {
293            deploy_profile: DeployProfile::PrimaryReplica,
294            packaging: StoragePackaging::SingleFile,
295            replica_count: 1,
296            managed_backup: true,
297            wal_retention: false,
298        }
299        .validate()
300        .unwrap_err();
301        assert!(err.contains("managed backup"));
302
303        let err = StorageProfileSelection {
304            deploy_profile: DeployProfile::PrimaryReplica,
305            packaging: StoragePackaging::SingleFile,
306            replica_count: 1,
307            managed_backup: false,
308            wal_retention: true,
309        }
310        .validate()
311        .unwrap_err();
312        assert!(err.contains("WAL retention"));
313    }
314
315    #[test]
316    fn production_presets_select_operational_directory() {
317        for preset in [
318            StorageDeployPreset::PrimaryReplicaProductionHa,
319            StorageDeployPreset::PrimaryReplicaBackup,
320            StorageDeployPreset::PrimaryReplicaWalRetention,
321            StorageDeployPreset::Cluster,
322        ] {
323            let selection = preset.selection().validate().expect(preset.as_str());
324            assert_eq!(selection.packaging, StoragePackaging::OperationalDirectory);
325        }
326    }
327
328    #[test]
329    fn cluster_rejects_single_file_packaging() {
330        let err = StorageProfileSelection {
331            deploy_profile: DeployProfile::Cluster,
332            packaging: StoragePackaging::SingleFile,
333            replica_count: 3,
334            managed_backup: false,
335            wal_retention: false,
336        }
337        .validate()
338        .unwrap_err();
339        assert!(err.contains("cluster"));
340        assert!(err.contains("embedded single-file"));
341    }
342
343    #[test]
344    fn selection_reports_matching_preset_or_custom() {
345        assert_eq!(
346            StorageDeployPreset::PrimaryReplicaProductionHa
347                .selection()
348                .preset_name(),
349            "primary-replica-production-ha"
350        );
351
352        let custom = StorageProfileSelection {
353            deploy_profile: DeployProfile::PrimaryReplica,
354            packaging: StoragePackaging::OperationalDirectory,
355            replica_count: 4,
356            managed_backup: false,
357            wal_retention: false,
358        };
359        assert_eq!(custom.preset_name(), "custom");
360    }
361}