1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use flate2::read::GzDecoder;
4use flate2::write::GzEncoder;
5use flate2::Compression;
6use k8s_openapi::api::core::v1::Secret;
7use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
8use k8s_openapi::ByteString;
9use kube::{api::ListParams, Api, Client};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::io::{Read, Write};
13
14use crate::{
15 constants::{LABEL_RELEASE, LABEL_REVISION, SECRET_TYPE_RELEASE},
16 kubernetes::ResourceKey,
17 NylError, Result,
18};
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "lowercase")]
23pub enum ReleaseStatus {
24 Rendered,
26 Deployed,
28 Failed,
30 Superseded,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ReleaseInfo {
37 pub release_name: String,
38 pub release_namespace: String,
39 pub latest_revision: u32,
40 pub status: ReleaseStatus,
41 pub rendered_at: DateTime<Utc>,
42 pub applied_at: Option<DateTime<Utc>>,
43 pub resource_count: usize,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ReleaseState {
49 pub release_name: String,
51 pub release_namespace: String,
53 pub revision: u32,
55 pub resource_keys: Vec<ResourceKey>,
57 pub manifest: String,
59 pub status: ReleaseStatus,
61 pub rendered_at: DateTime<Utc>,
63 pub applied_at: Option<DateTime<Utc>>,
65 pub error: Option<String>,
67}
68
69#[async_trait]
71pub trait ReleaseStorage: Send + Sync {
72 async fn save_release(&self, release: &ReleaseState) -> Result<()>;
74
75 async fn get_latest_release(&self, release_name: &str, namespace: &str) -> Result<Option<ReleaseState>>;
77
78 async fn get_release(&self, release_name: &str, namespace: &str, revision: u32) -> Result<Option<ReleaseState>>;
80
81 async fn list_revisions(&self, release_name: &str, namespace: &str) -> Result<Vec<u32>>;
83
84 async fn update_release_status(
86 &self,
87 release_name: &str,
88 namespace: &str,
89 revision: u32,
90 status: ReleaseStatus,
91 error: Option<String>,
92 ) -> Result<()>;
93
94 async fn list_releases(&self, namespace: Option<&str>) -> Result<Vec<ReleaseInfo>>;
96
97 async fn delete_release(&self, release_name: &str, namespace: &str, revision: u32) -> Result<()>;
99
100 async fn delete_all_revisions(&self, release_name: &str, namespace: &str) -> Result<u32>;
102}
103
104pub struct KubernetesReleaseStorage {
106 client: Client,
107}
108
109impl KubernetesReleaseStorage {
110 pub fn new(client: Client) -> Self {
112 Self { client }
113 }
114
115 fn secret_name(release_name: &str, revision: u32) -> String {
117 format!("nyl.release.v1.{}.{}", release_name, revision)
118 }
119
120 #[allow(dead_code)]
122 fn parse_revision(name: &str) -> Option<u32> {
123 name.split('.').next_back()?.parse().ok()
125 }
126
127 fn encode_base64(data: &str) -> ByteString {
129 ByteString(data.as_bytes().to_vec())
130 }
131
132 fn decode_base64(encoded: &ByteString) -> Result<String> {
134 String::from_utf8(encoded.0.clone()).map_err(|e| NylError::Config(format!("Invalid UTF-8 in data: {}", e)))
135 }
136
137 fn compress_and_encode(data: &str) -> Result<ByteString> {
139 let mut encoder = GzEncoder::new(Vec::new(), Compression::new(6));
140 encoder
141 .write_all(data.as_bytes())
142 .map_err(|e| NylError::Config(format!("Compression failed: {}", e)))?;
143 let compressed = encoder
144 .finish()
145 .map_err(|e| NylError::Config(format!("Compression finish failed: {}", e)))?;
146
147 Ok(ByteString(compressed))
148 }
149
150 fn decode_and_decompress(encoded: &ByteString) -> Result<String> {
152 let mut decoder = GzDecoder::new(&encoded.0[..]);
153 let mut decompressed = String::new();
154 decoder.read_to_string(&mut decompressed).map_err(|e| {
155 NylError::Config(format!(
156 "Decompression failed: {}.\nHint: The release Secret may be corrupted.",
157 e
158 ))
159 })?;
160
161 tracing::debug!("Decompressed manifest: {} bytes", decompressed.len());
162 Ok(decompressed)
163 }
164
165 fn to_secret(release: &ReleaseState) -> Result<Secret> {
167 let mut data: BTreeMap<String, ByteString> = BTreeMap::new();
168
169 data.insert(
171 "resource_keys".to_string(),
172 Self::encode_base64(&serde_json::to_string(&release.resource_keys)?),
173 );
174
175 let compressed_manifest = Self::compress_and_encode(&release.manifest)?;
176
177 let original_size = release.manifest.len();
179 let compressed_size = compressed_manifest.0.len();
180 #[allow(clippy::cast_precision_loss)]
181 let ratio = original_size as f64 / compressed_size as f64;
182 tracing::debug!(
183 "Compressed manifest: {} bytes → {} bytes ({:.1}x reduction)",
184 original_size,
185 compressed_size,
186 ratio
187 );
188
189 data.insert("manifest".to_string(), compressed_manifest);
190
191 data.insert(
192 "status".to_string(),
193 Self::encode_base64(&serde_json::to_string(&release.status)?),
194 );
195 data.insert(
196 "rendered_at".to_string(),
197 Self::encode_base64(&release.rendered_at.to_rfc3339()),
198 );
199 if let Some(applied_at) = &release.applied_at {
200 data.insert("applied_at".to_string(), Self::encode_base64(&applied_at.to_rfc3339()));
201 }
202 if let Some(error) = &release.error {
203 data.insert("error".to_string(), Self::encode_base64(error));
204 }
205
206 let total_size: usize = data.values().map(|v| v.0.len()).sum();
208 if total_size > 1_000_000 {
209 #[allow(clippy::cast_precision_loss)]
210 let size_mb = total_size as f64 / 1_000_000.0;
211 return Err(NylError::Kubernetes(format!(
212 "Release Secret exceeds 1MB limit even after compression ({:.2}MB).\n\
213 Hint: Consider splitting your manifests into multiple releases or components.",
214 size_mb
215 )));
216 }
217
218 let mut labels = BTreeMap::new();
219 labels.insert(LABEL_RELEASE.to_string(), release.release_name.clone());
220 labels.insert(LABEL_REVISION.to_string(), release.revision.to_string());
221
222 Ok(Secret {
223 metadata: ObjectMeta {
224 name: Some(Self::secret_name(&release.release_name, release.revision)),
225 namespace: Some(release.release_namespace.clone()),
226 labels: Some(labels),
227 ..Default::default()
228 },
229 type_: Some(SECRET_TYPE_RELEASE.to_string()),
230 data: Some(data),
231 ..Default::default()
232 })
233 }
234
235 fn from_secret(secret: &Secret) -> Result<ReleaseState> {
237 let data = secret
238 .data
239 .as_ref()
240 .ok_or_else(|| NylError::Config("Secret missing data field".to_string()))?;
241
242 let release_name = secret
243 .metadata
244 .labels
245 .as_ref()
246 .and_then(|l| l.get(LABEL_RELEASE))
247 .ok_or_else(|| NylError::Config("Secret missing release label".to_string()))?
248 .clone();
249
250 let release_namespace = secret
251 .metadata
252 .namespace
253 .as_ref()
254 .ok_or_else(|| NylError::Config("Secret missing namespace".to_string()))?
255 .clone();
256
257 let revision: u32 = secret
258 .metadata
259 .labels
260 .as_ref()
261 .and_then(|l| l.get(LABEL_REVISION))
262 .and_then(|r| r.parse().ok())
263 .ok_or_else(|| NylError::Config("Secret missing or invalid revision label".to_string()))?;
264
265 let resource_keys_str = Self::decode_base64(
267 data.get("resource_keys")
268 .ok_or_else(|| NylError::Config("Secret missing resource_keys field".to_string()))?,
269 )?;
270 let resource_keys: Vec<ResourceKey> = serde_json::from_str(&resource_keys_str)?;
271
272 let manifest = Self::decode_and_decompress(
273 data.get("manifest")
274 .ok_or_else(|| NylError::Config("Secret missing manifest field".to_string()))?,
275 )?;
276
277 let status_str = Self::decode_base64(
278 data.get("status")
279 .ok_or_else(|| NylError::Config("Secret missing status field".to_string()))?,
280 )?;
281 let status: ReleaseStatus = serde_json::from_str(&status_str)?;
282
283 let rendered_at_str = Self::decode_base64(
284 data.get("rendered_at")
285 .ok_or_else(|| NylError::Config("Secret missing rendered_at field".to_string()))?,
286 )?;
287 let rendered_at = DateTime::parse_from_rfc3339(&rendered_at_str)
288 .map_err(|e| NylError::Config(format!("Invalid rendered_at timestamp: {}", e)))?
289 .with_timezone(&Utc);
290
291 let applied_at = if let Some(applied_at_data) = data.get("applied_at") {
292 let applied_at_str = Self::decode_base64(applied_at_data)?;
293 Some(
294 DateTime::parse_from_rfc3339(&applied_at_str)
295 .map_err(|e| NylError::Config(format!("Invalid applied_at timestamp: {}", e)))?
296 .with_timezone(&Utc),
297 )
298 } else {
299 None
300 };
301
302 let error = if let Some(error_data) = data.get("error") {
303 Some(Self::decode_base64(error_data)?)
304 } else {
305 None
306 };
307
308 Ok(ReleaseState {
309 release_name,
310 release_namespace,
311 revision,
312 resource_keys,
313 manifest,
314 status,
315 rendered_at,
316 applied_at,
317 error,
318 })
319 }
320}
321
322#[async_trait]
323impl ReleaseStorage for KubernetesReleaseStorage {
324 async fn save_release(&self, release: &ReleaseState) -> Result<()> {
325 let api: Api<Secret> = Api::namespaced(self.client.clone(), &release.release_namespace);
326 let secret = Self::to_secret(release)?;
327 let name = Self::secret_name(&release.release_name, release.revision);
328
329 match api.get(&name).await {
331 Ok(_) => {
332 api.replace(&name, &kube::api::PostParams::default(), &secret).await?;
334 }
335 Err(kube::Error::Api(err)) if err.code == 404 => {
336 api.create(&kube::api::PostParams::default(), &secret).await?;
338 }
339 Err(e) => return Err(e.into()),
340 }
341
342 Ok(())
343 }
344
345 async fn get_latest_release(&self, release_name: &str, namespace: &str) -> Result<Option<ReleaseState>> {
346 let revisions = self.list_revisions(release_name, namespace).await?;
347 if revisions.is_empty() {
348 return Ok(None);
349 }
350
351 let latest_revision = revisions.iter().max().unwrap();
352 self.get_release(release_name, namespace, *latest_revision).await
353 }
354
355 async fn get_release(&self, release_name: &str, namespace: &str, revision: u32) -> Result<Option<ReleaseState>> {
356 let api: Api<Secret> = Api::namespaced(self.client.clone(), namespace);
357 let name = Self::secret_name(release_name, revision);
358
359 match api.get(&name).await {
360 Ok(secret) => Ok(Some(Self::from_secret(&secret)?)),
361 Err(kube::Error::Api(err)) if err.code == 404 => Ok(None),
362 Err(e) => Err(e.into()),
363 }
364 }
365
366 async fn list_revisions(&self, release_name: &str, namespace: &str) -> Result<Vec<u32>> {
367 let api: Api<Secret> = Api::namespaced(self.client.clone(), namespace);
368 let label_selector = format!("{}={}", LABEL_RELEASE, release_name);
369 let lp = ListParams::default().labels(&label_selector);
370
371 let secrets = api.list(&lp).await?;
372 let mut revisions: Vec<u32> = secrets
373 .items
374 .iter()
375 .filter_map(|s| {
376 s.metadata
377 .labels
378 .as_ref()
379 .and_then(|l| l.get(LABEL_REVISION))
380 .and_then(|r| r.parse().ok())
381 })
382 .collect();
383
384 revisions.sort_unstable();
385 Ok(revisions)
386 }
387
388 async fn update_release_status(
389 &self,
390 release_name: &str,
391 namespace: &str,
392 revision: u32,
393 status: ReleaseStatus,
394 error: Option<String>,
395 ) -> Result<()> {
396 let mut release = self
398 .get_release(release_name, namespace, revision)
399 .await?
400 .ok_or_else(|| NylError::Config(format!("Release {} revision {} not found", release_name, revision)))?;
401
402 release.status = status;
404 release.error = error;
405
406 if release.status == ReleaseStatus::Deployed && release.applied_at.is_none() {
408 release.applied_at = Some(Utc::now());
409 }
410
411 self.save_release(&release).await
413 }
414
415 async fn list_releases(&self, namespace: Option<&str>) -> Result<Vec<ReleaseInfo>> {
416 use std::collections::HashMap;
417
418 let label_selector = LABEL_RELEASE.to_string(); let lp = ListParams::default().labels(&label_selector);
421
422 let secrets = if let Some(ns) = namespace {
423 let api: Api<Secret> = Api::namespaced(self.client.clone(), ns);
424 api.list(&lp).await?
425 } else {
426 let api: Api<Secret> = Api::all(self.client.clone());
427 api.list(&lp).await?
428 };
429
430 let mut releases: HashMap<(String, String), ReleaseState> = HashMap::new();
432
433 for secret in secrets.items {
434 match Self::from_secret(&secret) {
435 Ok(state) => {
436 let key = (state.release_name.clone(), state.release_namespace.clone());
437 releases
438 .entry(key)
439 .and_modify(|existing| {
440 if state.revision > existing.revision {
441 *existing = state.clone();
442 }
443 })
444 .or_insert(state);
445 }
446 Err(e) => {
447 tracing::warn!("Failed to parse release secret {:?}: {}", secret.metadata.name, e);
449 }
450 }
451 }
452
453 let mut result: Vec<ReleaseInfo> = releases
455 .into_values()
456 .map(|state| ReleaseInfo {
457 release_name: state.release_name,
458 release_namespace: state.release_namespace,
459 latest_revision: state.revision,
460 status: state.status,
461 rendered_at: state.rendered_at,
462 applied_at: state.applied_at,
463 resource_count: state.resource_keys.len(),
464 })
465 .collect();
466
467 result.sort_by(|a, b| {
469 a.release_namespace
470 .cmp(&b.release_namespace)
471 .then_with(|| a.release_name.cmp(&b.release_name))
472 });
473
474 Ok(result)
475 }
476
477 async fn delete_release(&self, release_name: &str, namespace: &str, revision: u32) -> Result<()> {
478 let api: Api<Secret> = Api::namespaced(self.client.clone(), namespace);
479 let name = Self::secret_name(release_name, revision);
480
481 match api.delete(&name, &kube::api::DeleteParams::default()).await {
482 Ok(_) => Ok(()),
483 Err(kube::Error::Api(err)) if err.code == 404 => {
484 Ok(())
486 }
487 Err(e) => Err(e.into()),
488 }
489 }
490
491 async fn delete_all_revisions(&self, release_name: &str, namespace: &str) -> Result<u32> {
492 let revisions = self.list_revisions(release_name, namespace).await?;
493 let mut count = 0;
494
495 for revision in revisions {
496 self.delete_release(release_name, namespace, revision).await?;
497 count += 1;
498 }
499
500 Ok(count)
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use std::collections::HashMap;
508 use std::sync::{Arc, Mutex};
509
510 struct MockReleaseStorage {
512 releases: Arc<Mutex<HashMap<(String, u32), ReleaseState>>>,
513 }
514
515 impl MockReleaseStorage {
516 fn new() -> Self {
517 Self {
518 releases: Arc::new(Mutex::new(HashMap::new())),
519 }
520 }
521 }
522
523 #[async_trait]
524 impl ReleaseStorage for MockReleaseStorage {
525 async fn save_release(&self, release: &ReleaseState) -> Result<()> {
526 let mut store = self.releases.lock().unwrap();
527 let key = (
529 format!("{}/{}", release.release_namespace, release.release_name),
530 release.revision,
531 );
532 store.insert(key, release.clone());
533 Ok(())
534 }
535
536 async fn get_latest_release(&self, release_name: &str, namespace: &str) -> Result<Option<ReleaseState>> {
537 let revisions = self.list_revisions(release_name, namespace).await?;
538 if revisions.is_empty() {
539 return Ok(None);
540 }
541
542 let latest = revisions.iter().max().unwrap();
543 self.get_release(release_name, namespace, *latest).await
544 }
545
546 async fn get_release(
547 &self,
548 release_name: &str,
549 namespace: &str,
550 revision: u32,
551 ) -> Result<Option<ReleaseState>> {
552 let store = self.releases.lock().unwrap();
553 let key = format!("{}/{}", namespace, release_name);
554 Ok(store.get(&(key, revision)).cloned())
555 }
556
557 async fn list_revisions(&self, release_name: &str, namespace: &str) -> Result<Vec<u32>> {
558 let store = self.releases.lock().unwrap();
559 let key_prefix = format!("{}/{}", namespace, release_name);
560 let mut revisions: Vec<u32> = store
561 .keys()
562 .filter(|(c, _)| c == &key_prefix)
563 .map(|(_, r)| *r)
564 .collect();
565 revisions.sort_unstable();
566 Ok(revisions)
567 }
568
569 async fn update_release_status(
570 &self,
571 release_name: &str,
572 namespace: &str,
573 revision: u32,
574 status: ReleaseStatus,
575 error: Option<String>,
576 ) -> Result<()> {
577 let mut store = self.releases.lock().unwrap();
578 let key = format!("{}/{}", namespace, release_name);
579 if let Some(release) = store.get_mut(&(key, revision)) {
580 release.status = status;
581 release.error = error;
582 if release.status == ReleaseStatus::Deployed && release.applied_at.is_none() {
583 release.applied_at = Some(Utc::now());
584 }
585 }
586 Ok(())
587 }
588
589 async fn list_releases(&self, namespace: Option<&str>) -> Result<Vec<ReleaseInfo>> {
590 use std::collections::HashMap;
591
592 let store = self.releases.lock().unwrap();
593 let mut releases: HashMap<(String, String), ReleaseState> = HashMap::new();
594
595 for ((key, revision), state) in store.iter() {
596 if let Some((ns, name)) = key.split_once('/') {
598 if let Some(filter_ns) = namespace {
600 if ns != filter_ns {
601 continue;
602 }
603 }
604
605 let release_key = (name.to_string(), ns.to_string());
606 releases
607 .entry(release_key)
608 .and_modify(|existing| {
609 if revision > &existing.revision {
610 *existing = state.clone();
611 }
612 })
613 .or_insert_with(|| state.clone());
614 }
615 }
616
617 let mut result: Vec<ReleaseInfo> = releases
618 .into_values()
619 .map(|state| ReleaseInfo {
620 release_name: state.release_name,
621 release_namespace: state.release_namespace,
622 latest_revision: state.revision,
623 status: state.status,
624 rendered_at: state.rendered_at,
625 applied_at: state.applied_at,
626 resource_count: state.resource_keys.len(),
627 })
628 .collect();
629
630 result.sort_by(|a, b| {
631 a.release_namespace
632 .cmp(&b.release_namespace)
633 .then_with(|| a.release_name.cmp(&b.release_name))
634 });
635
636 Ok(result)
637 }
638
639 async fn delete_release(&self, release_name: &str, namespace: &str, revision: u32) -> Result<()> {
640 let mut store = self.releases.lock().unwrap();
641 let key = (format!("{}/{}", namespace, release_name), revision);
642 store.remove(&key);
643 Ok(())
644 }
645
646 async fn delete_all_revisions(&self, release_name: &str, namespace: &str) -> Result<u32> {
647 let revisions = self.list_revisions(release_name, namespace).await?;
648 let count = u32::try_from(revisions.len())
649 .map_err(|e| NylError::Other(format!("Too many revisions to count: {}", e)))?;
650
651 for revision in revisions {
652 self.delete_release(release_name, namespace, revision).await?;
653 }
654
655 Ok(count)
656 }
657 }
658
659 #[tokio::test]
660 async fn test_save_and_get_release() {
661 let storage = MockReleaseStorage::new();
662 let release = ReleaseState {
663 release_name: "myapp".to_string(),
664 release_namespace: "default".to_string(),
665 revision: 1,
666 resource_keys: vec![],
667 manifest: "apiVersion: v1\nkind: ConfigMap".to_string(),
668 status: ReleaseStatus::Rendered,
669 rendered_at: Utc::now(),
670 applied_at: None,
671 error: None,
672 };
673
674 storage.save_release(&release).await.unwrap();
675
676 let retrieved = storage.get_release("myapp", "default", 1).await.unwrap();
677 assert!(retrieved.is_some());
678 let retrieved = retrieved.unwrap();
679 assert_eq!(retrieved.release_name, "myapp");
680 assert_eq!(retrieved.release_namespace, "default");
681 assert_eq!(retrieved.revision, 1);
682 }
683
684 #[tokio::test]
685 async fn test_get_latest_release() {
686 let storage = MockReleaseStorage::new();
687
688 for i in 1..=3 {
690 let release = ReleaseState {
691 release_name: "myapp".to_string(),
692 release_namespace: "default".to_string(),
693 revision: i,
694 resource_keys: vec![],
695 manifest: format!("revision {}", i),
696 status: ReleaseStatus::Deployed,
697 rendered_at: Utc::now(),
698 applied_at: Some(Utc::now()),
699 error: None,
700 };
701 storage.save_release(&release).await.unwrap();
702 }
703
704 let latest = storage.get_latest_release("myapp", "default").await.unwrap();
705 assert!(latest.is_some());
706 assert_eq!(latest.unwrap().revision, 3);
707 }
708
709 #[tokio::test]
710 async fn test_list_revisions() {
711 let storage = MockReleaseStorage::new();
712
713 for i in [3, 1, 2] {
715 let release = ReleaseState {
716 release_name: "myapp".to_string(),
717 release_namespace: "default".to_string(),
718 revision: i,
719 resource_keys: vec![],
720 manifest: format!("revision {}", i),
721 status: ReleaseStatus::Deployed,
722 rendered_at: Utc::now(),
723 applied_at: Some(Utc::now()),
724 error: None,
725 };
726 storage.save_release(&release).await.unwrap();
727 }
728
729 let revisions = storage.list_revisions("myapp", "default").await.unwrap();
730 assert_eq!(revisions, vec![1, 2, 3]);
731 }
732
733 #[tokio::test]
734 async fn test_update_release_status() {
735 let storage = MockReleaseStorage::new();
736 let release = ReleaseState {
737 release_name: "myapp".to_string(),
738 release_namespace: "default".to_string(),
739 revision: 1,
740 resource_keys: vec![],
741 manifest: "test".to_string(),
742 status: ReleaseStatus::Rendered,
743 rendered_at: Utc::now(),
744 applied_at: None,
745 error: None,
746 };
747
748 storage.save_release(&release).await.unwrap();
749
750 storage
751 .update_release_status("myapp", "default", 1, ReleaseStatus::Deployed, None)
752 .await
753 .unwrap();
754
755 let updated = storage.get_release("myapp", "default", 1).await.unwrap().unwrap();
756 assert_eq!(updated.status, ReleaseStatus::Deployed);
757 assert!(updated.applied_at.is_some());
758 }
759
760 #[tokio::test]
761 async fn test_get_missing_release() {
762 let storage = MockReleaseStorage::new();
763 let result = storage.get_release("missing", "default", 1).await.unwrap();
764 assert!(result.is_none());
765 }
766
767 #[tokio::test]
768 async fn test_get_latest_no_releases() {
769 let storage = MockReleaseStorage::new();
770 let result = storage.get_latest_release("missing", "default").await.unwrap();
771 assert!(result.is_none());
772 }
773
774 #[test]
775 fn test_secret_name_generation() {
776 assert_eq!(
777 KubernetesReleaseStorage::secret_name("myapp", 1),
778 "nyl.release.v1.myapp.1"
779 );
780 assert_eq!(
781 KubernetesReleaseStorage::secret_name("my-component", 42),
782 "nyl.release.v1.my-component.42"
783 );
784 }
785
786 #[test]
787 fn test_parse_revision() {
788 assert_eq!(
789 KubernetesReleaseStorage::parse_revision("nyl.release.v1.myapp.1"),
790 Some(1)
791 );
792 assert_eq!(
793 KubernetesReleaseStorage::parse_revision("nyl.release.v1.myapp.42"),
794 Some(42)
795 );
796 assert_eq!(KubernetesReleaseStorage::parse_revision("invalid"), None);
797 }
798
799 #[test]
800 fn test_bytestring_roundtrip() {
801 let original = "test data with special chars: 你好";
802 let encoded = KubernetesReleaseStorage::encode_base64(original);
803 let decoded = KubernetesReleaseStorage::decode_base64(&encoded).unwrap();
804 assert_eq!(original, decoded);
805 }
806
807 #[test]
808 fn test_compression_roundtrip() {
809 let original = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\n".repeat(100);
810 let compressed = KubernetesReleaseStorage::compress_and_encode(&original).unwrap();
811 let decompressed = KubernetesReleaseStorage::decode_and_decompress(&compressed).unwrap();
812 assert_eq!(original, decompressed);
813 }
814
815 #[test]
816 fn test_compression_reduces_size() {
817 let large_manifest =
818 "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test\ndata:\n key: value\n".repeat(1000);
819 let original_size = large_manifest.len();
820 let compressed = KubernetesReleaseStorage::compress_and_encode(&large_manifest).unwrap();
821 let compressed_size = compressed.0.len();
822
823 assert!(compressed_size < original_size / 5);
825 }
826
827 #[test]
828 fn test_unicode_compression_roundtrip() {
829 let unicode_data = "Hello 世界 🚀 café\n".repeat(50);
830 let compressed = KubernetesReleaseStorage::compress_and_encode(&unicode_data).unwrap();
831 let decompressed = KubernetesReleaseStorage::decode_and_decompress(&compressed).unwrap();
832 assert_eq!(unicode_data, decompressed);
833 }
834
835 #[test]
836 fn test_gzip_magic_header_detection() {
837 let data = "test data for compression";
838 let compressed = KubernetesReleaseStorage::compress_and_encode(data).unwrap();
839
840 assert_eq!(compressed.0[0], 0x1f);
842 assert_eq!(compressed.0[1], 0x8b);
843 }
844
845 #[test]
846 fn test_corrupted_compressed_data_error() {
847 let mut corrupted = vec![0x1f, 0x8b, 0x08, 0x00];
849 corrupted.extend_from_slice(&[0xFF; 100]);
850 let corrupted_bytes = ByteString(corrupted);
851
852 let result = KubernetesReleaseStorage::decode_and_decompress(&corrupted_bytes);
853 assert!(result.is_err());
854 assert!(result.unwrap_err().to_string().contains("Decompression failed"));
856 }
857
858 #[test]
859 fn test_empty_manifest_compression() {
860 let empty = "";
861 let compressed = KubernetesReleaseStorage::compress_and_encode(empty).unwrap();
862 let decompressed = KubernetesReleaseStorage::decode_and_decompress(&compressed).unwrap();
863 assert_eq!(empty, decompressed);
864 }
865
866 #[tokio::test]
867 async fn test_release_state_compression_roundtrip() {
868 let large_manifest = "apiVersion: v1\nkind: ConfigMap\ndata:\n key: value\n".repeat(5000);
869 let release = ReleaseState {
870 release_name: "test-release".to_string(),
871 release_namespace: "default".to_string(),
872 revision: 1,
873 resource_keys: vec![],
874 manifest: large_manifest.clone(),
875 status: ReleaseStatus::Rendered,
876 rendered_at: Utc::now(),
877 applied_at: None,
878 error: None,
879 };
880
881 let secret = KubernetesReleaseStorage::to_secret(&release).unwrap();
882 let restored = KubernetesReleaseStorage::from_secret(&secret).unwrap();
883
884 assert_eq!(release.manifest, restored.manifest);
885 assert_eq!(release.release_name, restored.release_name);
886 }
887
888 #[tokio::test]
889 async fn test_list_releases() {
890 let storage = MockReleaseStorage::new();
891
892 let releases = vec![
894 ("app1", "default", 1),
895 ("app1", "default", 2),
896 ("app2", "default", 1),
897 ("app3", "prod", 1),
898 ];
899
900 for (name, ns, rev) in releases {
901 let release = ReleaseState {
902 release_name: name.to_string(),
903 release_namespace: ns.to_string(),
904 revision: rev,
905 resource_keys: vec![],
906 manifest: "test".to_string(),
907 status: ReleaseStatus::Deployed,
908 rendered_at: Utc::now(),
909 applied_at: Some(Utc::now()),
910 error: None,
911 };
912 storage.save_release(&release).await.unwrap();
913 }
914
915 let all = storage.list_releases(None).await.unwrap();
917 assert_eq!(all.len(), 3); assert_eq!(all[0].release_name, "app1");
919 assert_eq!(all[0].latest_revision, 2); let default_ns = storage.list_releases(Some("default")).await.unwrap();
923 assert_eq!(default_ns.len(), 2); let prod_ns = storage.list_releases(Some("prod")).await.unwrap();
927 assert_eq!(prod_ns.len(), 1); }
929
930 #[tokio::test]
931 async fn test_delete_release() {
932 let storage = MockReleaseStorage::new();
933 let release = ReleaseState {
934 release_name: "myapp".to_string(),
935 release_namespace: "default".to_string(),
936 revision: 1,
937 resource_keys: vec![],
938 manifest: "test".to_string(),
939 status: ReleaseStatus::Deployed,
940 rendered_at: Utc::now(),
941 applied_at: Some(Utc::now()),
942 error: None,
943 };
944
945 storage.save_release(&release).await.unwrap();
946
947 let retrieved = storage.get_release("myapp", "default", 1).await.unwrap();
949 assert!(retrieved.is_some());
950
951 storage.delete_release("myapp", "default", 1).await.unwrap();
953
954 let retrieved = storage.get_release("myapp", "default", 1).await.unwrap();
956 assert!(retrieved.is_none());
957 }
958
959 #[tokio::test]
960 async fn test_delete_all_revisions() {
961 let storage = MockReleaseStorage::new();
962
963 for i in 1..=3 {
965 let release = ReleaseState {
966 release_name: "myapp".to_string(),
967 release_namespace: "default".to_string(),
968 revision: i,
969 resource_keys: vec![],
970 manifest: format!("revision {}", i),
971 status: ReleaseStatus::Deployed,
972 rendered_at: Utc::now(),
973 applied_at: Some(Utc::now()),
974 error: None,
975 };
976 storage.save_release(&release).await.unwrap();
977 }
978
979 let revisions = storage.list_revisions("myapp", "default").await.unwrap();
981 assert_eq!(revisions.len(), 3);
982
983 let count = storage.delete_all_revisions("myapp", "default").await.unwrap();
985 assert_eq!(count, 3);
986
987 let revisions = storage.list_revisions("myapp", "default").await.unwrap();
989 assert_eq!(revisions.len(), 0);
990 }
991
992 #[tokio::test]
993 async fn test_list_releases_empty() {
994 let storage = MockReleaseStorage::new();
995 let releases = storage.list_releases(None).await.unwrap();
996 assert_eq!(releases.len(), 0);
997 }
998}