1use 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}