1mod chunked;
15mod configmap;
16mod file;
17mod mock;
18mod secrets;
19
20pub use chunked::{CHUNK_SIZE, ChunkedIndex, ChunkedStorage};
21pub use configmap::ConfigMapDriver;
22pub use file::FileDriver;
23pub use mock::{MockStorageDriver, OperationCounts};
24pub use secrets::SecretsDriver;
25
26use crate::error::{KubeError, Result};
27use crate::release::StoredRelease;
28use async_trait::async_trait;
29
30pub const MAX_RESOURCE_SIZE: usize = 1_000_000;
32
33#[async_trait]
37pub trait StorageDriver: Send + Sync {
38 async fn get(&self, namespace: &str, name: &str, version: u32) -> Result<StoredRelease>;
40
41 async fn get_latest(&self, namespace: &str, name: &str) -> Result<StoredRelease>;
43
44 async fn list(
46 &self,
47 namespace: Option<&str>,
48 name: Option<&str>,
49 include_superseded: bool,
50 ) -> Result<Vec<StoredRelease>>;
51
52 async fn history(&self, namespace: &str, name: &str) -> Result<Vec<StoredRelease>>;
54
55 async fn create(&self, release: &StoredRelease) -> Result<()>;
57
58 async fn update(&self, release: &StoredRelease) -> Result<()>;
60
61 async fn delete(&self, namespace: &str, name: &str, version: u32) -> Result<StoredRelease>;
63
64 async fn delete_all(&self, namespace: &str, name: &str) -> Result<Vec<StoredRelease>>;
66
67 async fn exists(&self, namespace: &str, name: &str) -> Result<bool> {
69 match self.get_latest(namespace, name).await {
70 Ok(_) => Ok(true),
71 Err(KubeError::ReleaseNotFound { .. }) => Ok(false),
72 Err(e) => Err(e),
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct StorageConfig {
80 pub compression: CompressionMethod,
82
83 pub large_release_strategy: LargeReleaseStrategy,
85
86 pub max_history: u32,
88}
89
90impl Default for StorageConfig {
91 fn default() -> Self {
92 Self {
93 compression: CompressionMethod::Zstd { level: 3 },
94 large_release_strategy: LargeReleaseStrategy::ChunkedSecrets,
95 max_history: 10,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CompressionMethod {
103 None,
105
106 Gzip { level: u32 },
108
109 Zstd { level: i32 },
111}
112
113impl Default for CompressionMethod {
114 fn default() -> Self {
115 Self::Zstd { level: 3 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Default)]
121pub enum LargeReleaseStrategy {
122 Fail,
124
125 #[default]
127 ChunkedSecrets,
128
129 SeparateManifest,
131
132 ExternalReference {
134 endpoint: String,
136 bucket: String,
138 },
139}
140
141#[must_use = "compression result should be used"]
143pub fn compress(data: &[u8], method: CompressionMethod) -> Result<Vec<u8>> {
144 match method {
145 CompressionMethod::None => Ok(data.to_vec()),
146 CompressionMethod::Gzip { level } => {
147 use std::io::Write;
148 let mut encoder =
149 flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(level));
150 encoder
151 .write_all(data)
152 .map_err(|e| KubeError::Compression(e.to_string()))?;
153 encoder
154 .finish()
155 .map_err(|e| KubeError::Compression(e.to_string()))
156 }
157 CompressionMethod::Zstd { level } => zstd::encode_all(std::io::Cursor::new(data), level)
158 .map_err(|e| KubeError::Compression(e.to_string())),
159 }
160}
161
162#[must_use = "decompression result should be used"]
164pub fn decompress(data: &[u8], method: CompressionMethod) -> Result<Vec<u8>> {
165 match method {
166 CompressionMethod::None => Ok(data.to_vec()),
167 CompressionMethod::Gzip { .. } => {
168 use std::io::Read;
169 let mut decoder = flate2::read::GzDecoder::new(data);
170 let mut decompressed = Vec::new();
171 decoder
172 .read_to_end(&mut decompressed)
173 .map_err(|e| KubeError::Compression(e.to_string()))?;
174 Ok(decompressed)
175 }
176 CompressionMethod::Zstd { .. } => zstd::decode_all(std::io::Cursor::new(data))
177 .map_err(|e| KubeError::Compression(e.to_string())),
178 }
179}
180
181#[must_use = "serialization result should be used"]
183pub fn serialize_release(release: &StoredRelease) -> Result<Vec<u8>> {
184 serde_json::to_vec(release).map_err(|e| KubeError::Serialization(e.to_string()))
185}
186
187#[must_use = "deserialization result should be used"]
189pub fn deserialize_release(data: &[u8]) -> Result<StoredRelease> {
190 serde_json::from_slice(data).map_err(|e| KubeError::Serialization(e.to_string()))
191}
192
193#[must_use = "encoded data should be used for storage"]
195pub fn encode_for_storage(release: &StoredRelease, config: &StorageConfig) -> Result<String> {
196 let json = serialize_release(release)?;
197 let compressed = compress(&json, config.compression)?;
198 Ok(base64::Engine::encode(
199 &base64::engine::general_purpose::STANDARD,
200 &compressed,
201 ))
202}
203
204#[must_use = "decoded release should be used"]
206pub fn decode_from_storage(data: &str, compression: CompressionMethod) -> Result<StoredRelease> {
207 let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
208 .map_err(|e| KubeError::Serialization(format!("base64 decode error: {}", e)))?;
209 let decompressed = decompress(&decoded, compression)?;
210 deserialize_release(&decompressed)
211}
212
213#[must_use = "labels should be applied to resources"]
215pub fn storage_labels(release: &StoredRelease) -> std::collections::BTreeMap<String, String> {
216 let mut labels = std::collections::BTreeMap::new();
217 labels.insert(
218 "app.kubernetes.io/managed-by".to_string(),
219 "sherpack".to_string(),
220 );
221 labels.insert("sherpack.io/release-name".to_string(), release.name.clone());
222 labels.insert(
223 "sherpack.io/release-version".to_string(),
224 release.version.to_string(),
225 );
226 labels.insert(
227 "sherpack.io/release-namespace".to_string(),
228 release.namespace.clone(),
229 );
230 labels
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::release::ReleaseState;
237 use sherpack_core::{PackMetadata, Values};
238
239 fn test_release() -> StoredRelease {
240 StoredRelease::for_install(
241 "test".to_string(),
242 "default".to_string(),
243 PackMetadata {
244 name: "test-pack".to_string(),
245 version: semver::Version::new(1, 0, 0),
246 description: Some("Test pack".to_string()),
247 app_version: None,
248 kube_version: None,
249 home: None,
250 icon: None,
251 sources: vec![],
252 keywords: vec![],
253 maintainers: vec![],
254 annotations: Default::default(),
255 },
256 Values::new(),
257 "apiVersion: v1\nkind: ConfigMap".to_string(),
258 )
259 }
260
261 fn test_release_with_manifest(manifest: &str) -> StoredRelease {
262 StoredRelease::for_install(
263 "test".to_string(),
264 "default".to_string(),
265 PackMetadata {
266 name: "test-pack".to_string(),
267 version: semver::Version::new(1, 0, 0),
268 description: None,
269 app_version: None,
270 kube_version: None,
271 home: None,
272 icon: None,
273 sources: vec![],
274 keywords: vec![],
275 maintainers: vec![],
276 annotations: Default::default(),
277 },
278 Values::new(),
279 manifest.to_string(),
280 )
281 }
282
283 #[test]
284 fn test_compression_roundtrip_zstd() {
285 let data = b"Hello, World! This is test data for compression.";
286 let compressed = compress(data, CompressionMethod::Zstd { level: 3 }).unwrap();
287 let decompressed = decompress(&compressed, CompressionMethod::Zstd { level: 3 }).unwrap();
288 assert_eq!(data.as_slice(), decompressed.as_slice());
289 }
290
291 #[test]
292 fn test_compression_roundtrip_gzip() {
293 let data = b"Hello, World! This is test data for compression.";
294 let compressed = compress(data, CompressionMethod::Gzip { level: 6 }).unwrap();
295 let decompressed = decompress(&compressed, CompressionMethod::Gzip { level: 6 }).unwrap();
296 assert_eq!(data.as_slice(), decompressed.as_slice());
297 }
298
299 #[test]
300 fn test_compression_none() {
301 let data = b"No compression test data";
302 let compressed = compress(data, CompressionMethod::None).unwrap();
303 assert_eq!(data.as_slice(), compressed.as_slice());
304 let decompressed = decompress(&compressed, CompressionMethod::None).unwrap();
305 assert_eq!(data.as_slice(), decompressed.as_slice());
306 }
307
308 #[test]
309 fn test_encode_decode_roundtrip() {
310 let release = test_release();
311 let config = StorageConfig::default();
312
313 let encoded = encode_for_storage(&release, &config).unwrap();
314 let decoded = decode_from_storage(&encoded, config.compression).unwrap();
315
316 assert_eq!(release.name, decoded.name);
317 assert_eq!(release.namespace, decoded.namespace);
318 assert_eq!(release.version, decoded.version);
319 }
320
321 #[test]
322 fn test_encode_decode_no_compression() {
323 let release = test_release();
324 let config = StorageConfig {
325 compression: CompressionMethod::None,
326 ..Default::default()
327 };
328
329 let encoded = encode_for_storage(&release, &config).unwrap();
330 let decoded = decode_from_storage(&encoded, config.compression).unwrap();
331
332 assert_eq!(release.name, decoded.name);
333 assert_eq!(release.manifest, decoded.manifest);
334 }
335
336 #[test]
337 fn test_encode_decode_gzip() {
338 let release = test_release();
339 let config = StorageConfig {
340 compression: CompressionMethod::Gzip { level: 6 },
341 ..Default::default()
342 };
343
344 let encoded = encode_for_storage(&release, &config).unwrap();
345 let decoded = decode_from_storage(&encoded, config.compression).unwrap();
346
347 assert_eq!(release.name, decoded.name);
348 }
349
350 #[test]
351 fn test_zstd_smaller_than_gzip() {
352 let data: Vec<u8> = (0..10000).map(|i| (i % 256) as u8).collect();
354
355 let zstd_compressed = compress(&data, CompressionMethod::Zstd { level: 3 }).unwrap();
356 let gzip_compressed = compress(&data, CompressionMethod::Gzip { level: 6 }).unwrap();
357
358 assert!(
360 zstd_compressed.len() <= gzip_compressed.len() + 100,
361 "Zstd: {}, Gzip: {}",
362 zstd_compressed.len(),
363 gzip_compressed.len()
364 );
365 }
366
367 #[test]
368 fn test_storage_labels() {
369 let release = test_release();
370 let labels = storage_labels(&release);
371
372 assert_eq!(
373 labels.get("app.kubernetes.io/managed-by"),
374 Some(&"sherpack".to_string())
375 );
376 assert_eq!(
377 labels.get("sherpack.io/release-name"),
378 Some(&"test".to_string())
379 );
380 assert_eq!(
381 labels.get("sherpack.io/release-version"),
382 Some(&"1".to_string())
383 );
384 assert_eq!(
385 labels.get("sherpack.io/release-namespace"),
386 Some(&"default".to_string())
387 );
388 }
389
390 #[test]
391 fn test_serialize_deserialize_release() {
392 let release = test_release();
393 let serialized = serialize_release(&release).unwrap();
394 let deserialized = deserialize_release(&serialized).unwrap();
395
396 assert_eq!(release.name, deserialized.name);
397 assert_eq!(release.namespace, deserialized.namespace);
398 assert_eq!(release.version, deserialized.version);
399 assert_eq!(release.manifest, deserialized.manifest);
400 }
401
402 #[test]
403 fn test_serialize_release_with_all_fields() {
404 let mut release = test_release();
405 release.notes = Some("Installation notes".to_string());
406 release.labels.insert("env".to_string(), "prod".to_string());
407
408 let serialized = serialize_release(&release).unwrap();
409 let deserialized = deserialize_release(&serialized).unwrap();
410
411 assert_eq!(deserialized.notes, Some("Installation notes".to_string()));
412 assert_eq!(deserialized.labels.get("env"), Some(&"prod".to_string()));
413 }
414
415 #[test]
416 fn test_storage_config_default() {
417 let config = StorageConfig::default();
418
419 assert!(matches!(
420 config.compression,
421 CompressionMethod::Zstd { level: 3 }
422 ));
423 assert!(matches!(
424 config.large_release_strategy,
425 LargeReleaseStrategy::ChunkedSecrets
426 ));
427 assert_eq!(config.max_history, 10);
428 }
429
430 #[test]
431 fn test_large_manifest_compression() {
432 let large_manifest = "apiVersion: v1\nkind: ConfigMap\n".repeat(1000);
434 let release = test_release_with_manifest(&large_manifest);
435 let config = StorageConfig::default();
436
437 let encoded = encode_for_storage(&release, &config).unwrap();
438 let decoded = decode_from_storage(&encoded, config.compression).unwrap();
439
440 assert_eq!(release.manifest, decoded.manifest);
441
442 let json = serialize_release(&release).unwrap();
444 let base64_decoded =
445 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &encoded).unwrap();
446 assert!(
447 base64_decoded.len() < json.len(),
448 "Compressed {} should be smaller than JSON {}",
449 base64_decoded.len(),
450 json.len()
451 );
452 }
453
454 #[test]
455 fn test_decode_invalid_base64() {
456 let result = decode_from_storage("not valid base64!!!", CompressionMethod::None);
457 assert!(result.is_err());
458 }
459
460 #[test]
461 fn test_decode_invalid_json() {
462 let invalid =
464 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"not json");
465 let result = decode_from_storage(&invalid, CompressionMethod::None);
466 assert!(result.is_err());
467 }
468
469 #[test]
470 fn test_release_state_preserved() {
471 let mut release = test_release();
472 release.state = ReleaseState::Failed {
473 reason: "Test failure".to_string(),
474 recoverable: true,
475 failed_at: chrono::Utc::now(),
476 };
477
478 let serialized = serialize_release(&release).unwrap();
479 let deserialized = deserialize_release(&serialized).unwrap();
480
481 assert!(
482 matches!(deserialized.state, ReleaseState::Failed { reason, .. } if reason == "Test failure")
483 );
484 }
485}