Skip to main content

nyl/kubernetes/
state.rs

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/// Status of a release
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "lowercase")]
23pub enum ReleaseStatus {
24    /// Generated but not applied
25    Rendered,
26    /// Successfully applied
27    Deployed,
28    /// Apply failed
29    Failed,
30    /// Newer revision deployed
31    Superseded,
32}
33
34/// Summary information about a release (for list view)
35#[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/// State of a release revision
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ReleaseState {
49    /// Release name
50    pub release_name: String,
51    /// Release namespace (target namespace for resources)
52    pub release_namespace: String,
53    /// Revision number (starts at 1)
54    pub revision: u32,
55    /// Resource keys for tracking applied resources
56    pub resource_keys: Vec<ResourceKey>,
57    /// Full rendered YAML manifest (for rollback)
58    pub manifest: String,
59    /// Current status
60    pub status: ReleaseStatus,
61    /// When the manifest was rendered
62    pub rendered_at: DateTime<Utc>,
63    /// When the release was applied (if applicable)
64    pub applied_at: Option<DateTime<Utc>>,
65    /// Error message (if status is Failed)
66    pub error: Option<String>,
67}
68
69/// Trait for storing and retrieving release state
70#[async_trait]
71pub trait ReleaseStorage: Send + Sync {
72    /// Save a release state
73    async fn save_release(&self, release: &ReleaseState) -> Result<()>;
74
75    /// Get the latest release for a release name and namespace
76    async fn get_latest_release(&self, release_name: &str, namespace: &str) -> Result<Option<ReleaseState>>;
77
78    /// Get a specific release revision
79    async fn get_release(&self, release_name: &str, namespace: &str, revision: u32) -> Result<Option<ReleaseState>>;
80
81    /// List all revision numbers for a release
82    async fn list_revisions(&self, release_name: &str, namespace: &str) -> Result<Vec<u32>>;
83
84    /// Update the status of a release
85    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    /// List all releases in a namespace (or all namespaces if None)
95    async fn list_releases(&self, namespace: Option<&str>) -> Result<Vec<ReleaseInfo>>;
96
97    /// Delete a specific release revision
98    async fn delete_release(&self, release_name: &str, namespace: &str, revision: u32) -> Result<()>;
99
100    /// Delete all revisions of a release, returns count deleted
101    async fn delete_all_revisions(&self, release_name: &str, namespace: &str) -> Result<u32>;
102}
103
104/// Kubernetes-based release storage using Secrets
105pub struct KubernetesReleaseStorage {
106    client: Client,
107}
108
109impl KubernetesReleaseStorage {
110    /// Create a new Kubernetes release storage
111    pub fn new(client: Client) -> Self {
112        Self { client }
113    }
114
115    /// Generate secret name for a release
116    fn secret_name(release_name: &str, revision: u32) -> String {
117        format!("nyl.release.v1.{}.{}", release_name, revision)
118    }
119
120    /// Parse revision number from secret name
121    #[allow(dead_code)]
122    fn parse_revision(name: &str) -> Option<u32> {
123        // Format: nyl.release.v1.<component>.<revision>
124        name.split('.').next_back()?.parse().ok()
125    }
126
127    /// Encode string to ByteString
128    fn encode_base64(data: &str) -> ByteString {
129        ByteString(data.as_bytes().to_vec())
130    }
131
132    /// Decode ByteString to string
133    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    /// Compress and encode string to ByteString (for large manifest field)
138    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    /// Decode and decompress ByteString to string
151    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    /// Convert ReleaseState to Secret
166    fn to_secret(release: &ReleaseState) -> Result<Secret> {
167        let mut data: BTreeMap<String, ByteString> = BTreeMap::new();
168
169        // Serialize resource keys
170        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        // Log compression ratio for visibility
178        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        // Validate total Secret size doesn't exceed 1MB
207        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    /// Convert Secret to ReleaseState
236    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        // Deserialize resource keys
266        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        // Try to get existing secret
330        match api.get(&name).await {
331            Ok(_) => {
332                // Update existing secret
333                api.replace(&name, &kube::api::PostParams::default(), &secret).await?;
334            }
335            Err(kube::Error::Api(err)) if err.code == 404 => {
336                // Create new secret
337                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        // Get existing release
397        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        // Update status
403        release.status = status;
404        release.error = error;
405
406        // If status is Deployed, set applied_at
407        if release.status == ReleaseStatus::Deployed && release.applied_at.is_none() {
408            release.applied_at = Some(Utc::now());
409        }
410
411        // Save updated release
412        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        // List all release secrets
419        let label_selector = LABEL_RELEASE.to_string(); // All secrets with release label (label existence selector)
420        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        // Group by release name and namespace, keeping only latest revision
431        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                    // Log warning for corrupted secrets but continue
448                    tracing::warn!("Failed to parse release secret {:?}: {}", secret.metadata.name, e);
449                }
450            }
451        }
452
453        // Convert to ReleaseInfo
454        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        // Sort by namespace then name for consistent output
468        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                // Already deleted, consider it success
485                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    /// Mock release storage for testing
511    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            // Use compound key for storage
528            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                // Parse namespace/name from key
597                if let Some((ns, name)) = key.split_once('/') {
598                    // Filter by namespace if specified
599                    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        // Save multiple revisions
689        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        // Save revisions out of order
714        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        // Expect at least 5x compression for repetitive YAML
824        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        // Verify gzip magic header is present
841        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        // Create corrupted data with gzip header but invalid payload
848        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 on the stable wrapper error message rather than backend-specific wording
855        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        // Save releases in different namespaces
893        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        // List all releases
916        let all = storage.list_releases(None).await.unwrap();
917        assert_eq!(all.len(), 3); // app1, app2, app3
918        assert_eq!(all[0].release_name, "app1");
919        assert_eq!(all[0].latest_revision, 2); // Should pick latest
920
921        // List releases in default namespace
922        let default_ns = storage.list_releases(Some("default")).await.unwrap();
923        assert_eq!(default_ns.len(), 2); // app1, app2
924
925        // List releases in prod namespace
926        let prod_ns = storage.list_releases(Some("prod")).await.unwrap();
927        assert_eq!(prod_ns.len(), 1); // app3
928    }
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        // Verify it exists
948        let retrieved = storage.get_release("myapp", "default", 1).await.unwrap();
949        assert!(retrieved.is_some());
950
951        // Delete it
952        storage.delete_release("myapp", "default", 1).await.unwrap();
953
954        // Verify it's gone
955        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        // Save multiple revisions
964        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        // Verify they exist
980        let revisions = storage.list_revisions("myapp", "default").await.unwrap();
981        assert_eq!(revisions.len(), 3);
982
983        // Delete all
984        let count = storage.delete_all_revisions("myapp", "default").await.unwrap();
985        assert_eq!(count, 3);
986
987        // Verify they're gone
988        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}