diff --git a/CHANGELOG.md b/CHANGELOG.md index c102a08..7be88f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,13 @@ This project follows semantic versioning once stable releases begin. ## [Unreleased] +### Added + +- Added Helm v3 release Secret interoperability, legacy HelmSharp payload compatibility, and actionable storage diagnostics (#190). + ### Changed - Validate packaged NuGet README files and require the Markdown HelmSharp wordmark before artifacts are uploaded or published. - ## [1.2.0] - 2026-07-16 ### Added diff --git a/src/HelmSharp.Action/HelmClient.cs b/src/HelmSharp.Action/HelmClient.cs index 76fbc36..62f3403 100644 --- a/src/HelmSharp.Action/HelmClient.cs +++ b/src/HelmSharp.Action/HelmClient.cs @@ -150,7 +150,8 @@ public async IAsyncEnumerable UpgradeInstallStreamAsync( } var valuesFiles = CombineValuesFiles(request.ValuesFile, request.ValuesFiles); - var values = await HelmValues.BuildAsync(chart, valuesFiles, request.ValuesContent, request.SetValues, request.SetFileValues, request.SetStringValues, request.SetJsonValues, cancellationToken); + var overrides = await HelmValues.BuildOverridesAsync(valuesFiles, request.ValuesContent, request.SetValues, request.SetFileValues, request.SetStringValues, request.SetJsonValues, cancellationToken); + var values = HelmValues.BuildFromOverrides(chart, overrides); if (request.DryRun) { @@ -289,6 +290,10 @@ public async IAsyncEnumerable UpgradeInstallStreamAsync( yield return waitLine; } } + var deployedAt = DateTimeOffset.UtcNow; + var firstDeployedAt = existingHistory.Count == 0 + ? deployedAt + : existingHistory.Min(record => record.FirstDeployedAt ?? record.UpdatedAt); await store.SaveAsync(new HelmReleaseRecord { Name = request.ReleaseName, @@ -298,9 +303,21 @@ await store.SaveAsync(new HelmReleaseRecord ChartName = chart.Name, ChartVersion = chart.Version, AppVersion = chart.AppVersion, - Manifest = manifest, - ValuesYaml = HelmValues.ToYaml(values), - UpdatedAt = DateTimeOffset.UtcNow + ChartApiVersion = chart.ApiVersion, + ChartDescription = chart.Description, + ChartType = chart.Type, + ChartKubeVersion = chart.KubeVersion, + ChartValuesYaml = chart.ValuesYaml, + RawChartJson = HelmV3ReleaseCodec.CreateChartSnapshot(chart), + Manifest = mainManifest, + ValuesYaml = HelmValues.ToYaml(overrides), + ComputedValuesYaml = HelmValues.ToYaml(values), + FirstDeployedAt = firstDeployedAt, + UpdatedAt = deployedAt, + Description = request.Description ?? (isUpgrade ? "Upgrade complete" : "Install complete"), + Notes = renderer.RenderNotes(), + Hooks = hooks.Select(ToReleaseHook).ToList(), + Labels = ResolveReleaseLabels(existingHistory, isUpgrade, request.Labels) }, cancellationToken); // Enforce max history @@ -313,6 +330,31 @@ await store.SaveAsync(new HelmReleaseRecord yield return $"Release {request.ReleaseName} revision {revision} deployed ({applied} resources)"; } + internal static Dictionary? ResolveReleaseLabels( + IReadOnlyCollection history, + bool isUpgrade, + IDictionary? requestedLabels) + { + var labels = new Dictionary(StringComparer.Ordinal); + if (isUpgrade) + { + var inherited = history.MaxBy(record => record.Revision)?.Labels; + if (inherited is not null) + { + foreach (var (key, value) in inherited) + labels[key] = value; + } + } + + if (requestedLabels is not null) + { + foreach (var (key, value) in requestedLabels) + labels[key] = value; + } + + return labels.Count == 0 ? null : labels; + } + internal static (bool IsUpgrade, int Revision) ResolveReleaseRenderState( IReadOnlyCollection history) { @@ -372,7 +414,7 @@ public async Task UninstallAsync( if (latest is null) return Fail($"release: not found: {request.ReleaseName}"); - var (mainManifest, hooks) = HelmHookExecutor.ExtractHooks(latest.Manifest, ns); + var (mainManifest, hooks) = ResolveStoredManifest(latest, ns); var hookExecutor = new HelmHookExecutor(client, options.FieldManager); // Execute pre-delete hooks @@ -456,8 +498,7 @@ public async Task RollbackAsync( if (targetRecord is null) return Fail($"release has no revision {revision}"); - var targetManifest = targetRecord.Manifest; - var (mainManifest, hooks) = HelmHookExecutor.ExtractHooks(targetManifest, ns); + var (mainManifest, hooks) = ResolveStoredManifest(targetRecord, ns); var hookExecutor = new HelmHookExecutor(client, options.FieldManager); var output = new StringBuilder(); @@ -488,6 +529,7 @@ public async Task RollbackAsync( } var newRevision = await store.NextRevisionAsync(releaseName, ns, cancellationToken); + var deployedAt = DateTimeOffset.UtcNow; await store.SaveAsync(new HelmReleaseRecord { Name = releaseName, @@ -497,9 +539,21 @@ await store.SaveAsync(new HelmReleaseRecord ChartName = targetRecord.ChartName, ChartVersion = targetRecord.ChartVersion, AppVersion = targetRecord.AppVersion, - Manifest = targetManifest, + ChartApiVersion = targetRecord.ChartApiVersion, + ChartDescription = targetRecord.ChartDescription, + ChartType = targetRecord.ChartType, + ChartKubeVersion = targetRecord.ChartKubeVersion, + ChartValuesYaml = targetRecord.ChartValuesYaml, + RawChartJson = targetRecord.RawChartJson, + Manifest = mainManifest, ValuesYaml = targetRecord.ValuesYaml, - UpdatedAt = DateTimeOffset.UtcNow + ComputedValuesYaml = targetRecord.ComputedValuesYaml, + FirstDeployedAt = targetRecord.FirstDeployedAt, + UpdatedAt = deployedAt, + Description = "Rollback complete", + Notes = targetRecord.Notes, + Hooks = hooks.Select(ToReleaseHook).ToList(), + Labels = targetRecord.Labels }, cancellationToken); await store.MarkStatusAsync(current, "superseded", cancellationToken); @@ -600,7 +654,7 @@ public async Task GetValuesAsync( var latest = await store.GetLatestAsync(releaseName, @namespace ?? options.DefaultNamespace ?? "default", cancellationToken); return latest is null ? Fail($"release: not found: {releaseName}") - : Ok(latest.ValuesYaml); + : Ok(GetStoredValuesYaml(latest, allValues)); } public async Task GetManifestAsync( @@ -649,7 +703,7 @@ public async Task GetNotesAsync( var chart = await HelmChartLoader.LoadAsync( await ResolveChartPathAsync(record.ChartName, record.ChartVersion, options, cancellationToken), cancellationToken); - var values = HelmYaml.DeserializeDictionary(record.ValuesYaml); + var values = HelmYaml.DeserializeDictionary(GetStoredValuesYaml(record, allValues: true)); var renderer = new HelmTemplateRenderer(chart, releaseName, ns, values); var notes = renderer.RenderNotes(); if (!string.IsNullOrWhiteSpace(notes)) @@ -661,7 +715,29 @@ await ResolveChartPathAsync(record.ChartName, record.ChartVersion, options, canc } } - return Ok("No notes found for this release."); + return Ok(GetStoredNotes(record)); + } + + internal static string GetStoredValuesYaml(HelmReleaseRecord record, bool allValues) + { + ArgumentNullException.ThrowIfNull(record); + if (!allValues) + return record.ValuesYaml; + + if (!string.IsNullOrWhiteSpace(record.ComputedValuesYaml)) + return record.ComputedValuesYaml; + + var values = HelmYaml.DeserializeDictionary(record.ChartValuesYaml); + HelmValues.MergeInto(values, HelmYaml.DeserializeDictionary(record.ValuesYaml)); + return HelmValues.ToYaml(values); + } + + internal static string GetStoredNotes(HelmReleaseRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return string.IsNullOrWhiteSpace(record.Notes) + ? "No notes found for this release." + : record.Notes; } public async Task GetHooksAsync( @@ -682,7 +758,7 @@ public async Task GetHooksAsync( if (record is null) return Fail($"release: not found: {releaseName}"); - var (mainManifest, hooks) = HelmHookExecutor.ExtractHooks(record.Manifest, ns); + var (_, hooks) = ResolveStoredManifest(record, ns); if (hooks.Count == 0) return Ok("No hooks found for this release."); @@ -752,7 +828,7 @@ public async Task TestAsync( if (latest is null) return Fail($"release: not found: {releaseName}"); - var (_, hooks) = HelmHookExecutor.ExtractHooks(latest.Manifest, ns); + var (_, hooks) = ResolveStoredManifest(latest, ns); var testHooks = hooks.Where(h => h.Events.Contains(HelmHookEvent.Test)).ToList(); if (testHooks.Count == 0) @@ -1826,6 +1902,107 @@ private static void ValidateUpgradeRequest(HelmUpgradeInstallRequest request) throw new ArgumentException("Chart is required.", nameof(request)); } + internal static (string MainManifest, List Hooks) ResolveStoredManifest( + HelmReleaseRecord record, + string defaultNamespace) + { + if (record.Hooks.Count > 0) + return (record.Manifest, record.Hooks.Select(FromReleaseHook).ToList()); + + return HelmHookExecutor.ExtractHooks(record.Manifest, defaultNamespace); + } + + private static HelmReleaseHookRecord ToReleaseHook(HelmHook hook) + => new() + { + Name = hook.Name, + Kind = hook.Kind, + Path = hook.Path, + Manifest = hook.Manifest, + Events = hook.Events.Select(ToReleaseHookEvent).ToList(), + LastRunPhase = "Unknown", + Weight = hook.Weight, + DeletePolicies = hook.DeletePolicies.Select(ToReleaseHookDeletePolicy).ToList() + }; + + private static HelmHook FromReleaseHook(HelmReleaseHookRecord record) + { + var hook = new HelmHook + { + Name = record.Name, + Kind = record.Kind, + Path = record.Path, + Manifest = record.Manifest, + Weight = record.Weight + }; + foreach (var value in record.Events) + { + if (TryParseReleaseHookEvent(value, out var hookEvent)) + hook.Events.Add(hookEvent); + } + foreach (var value in record.DeletePolicies) + { + if (TryParseReleaseHookDeletePolicy(value, out var deletePolicy)) + hook.DeletePolicies.Add(deletePolicy); + } + return hook; + } + + private static string ToReleaseHookEvent(HelmHookEvent value) + => value switch + { + HelmHookEvent.PreInstall => "pre-install", + HelmHookEvent.PostInstall => "post-install", + HelmHookEvent.PreUpgrade => "pre-upgrade", + HelmHookEvent.PostUpgrade => "post-upgrade", + HelmHookEvent.PreDelete => "pre-delete", + HelmHookEvent.PostDelete => "post-delete", + HelmHookEvent.PreRollback => "pre-rollback", + HelmHookEvent.PostRollback => "post-rollback", + HelmHookEvent.Test => "test", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) + }; + + private static string ToReleaseHookDeletePolicy(HelmHookDeletePolicy value) + => value switch + { + HelmHookDeletePolicy.BeforeHookCreation => "before-hook-creation", + HelmHookDeletePolicy.HookSucceeded => "hook-succeeded", + HelmHookDeletePolicy.HookFailed => "hook-failed", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) + }; + + private static bool TryParseReleaseHookEvent(string value, out HelmHookEvent result) + { + result = value switch + { + "pre-install" => HelmHookEvent.PreInstall, + "post-install" => HelmHookEvent.PostInstall, + "pre-upgrade" => HelmHookEvent.PreUpgrade, + "post-upgrade" => HelmHookEvent.PostUpgrade, + "pre-delete" => HelmHookEvent.PreDelete, + "post-delete" => HelmHookEvent.PostDelete, + "pre-rollback" => HelmHookEvent.PreRollback, + "post-rollback" => HelmHookEvent.PostRollback, + "test" => HelmHookEvent.Test, + _ => default + }; + return value is "pre-install" or "post-install" or "pre-upgrade" or "post-upgrade" + or "pre-delete" or "post-delete" or "pre-rollback" or "post-rollback" or "test"; + } + + private static bool TryParseReleaseHookDeletePolicy(string value, out HelmHookDeletePolicy result) + { + result = value switch + { + "before-hook-creation" => HelmHookDeletePolicy.BeforeHookCreation, + "hook-succeeded" => HelmHookDeletePolicy.HookSucceeded, + "hook-failed" => HelmHookDeletePolicy.HookFailed, + _ => default + }; + return value is "before-hook-creation" or "hook-succeeded" or "hook-failed"; + } + private static async Task CreateKubernetesClientAsync( HelmExecutionOptions options, string? requestKubeConfigPath, diff --git a/src/HelmSharp.Chart/HelmChartLoader.cs b/src/HelmSharp.Chart/HelmChartLoader.cs index ef556b7..61c4515 100644 --- a/src/HelmSharp.Chart/HelmChartLoader.cs +++ b/src/HelmSharp.Chart/HelmChartLoader.cs @@ -25,6 +25,8 @@ public sealed class HelmChart public Dictionary? Annotations { get; set; } public List Dependencies { get; } = new(); public List LockEntries { get; } = new(); + public string? LockDigest { get; set; } + public string? LockGenerated { get; set; } public string ValuesYaml { get; init; } = string.Empty; public Dictionary Templates { get; } = new(StringComparer.Ordinal); public Dictionary Files { get; } = new(StringComparer.Ordinal); @@ -80,6 +82,8 @@ private static async Task LoadFromFilesAsync( if (lockContent is not null) { var lockDict = HelmYaml.DeserializeDictionary(DecodeText(lockContent)); + chart.LockDigest = HelmYaml.GetString(lockDict, "digest"); + chart.LockGenerated = HelmYaml.GetString(lockDict, "generated"); if (lockDict.TryGetValue("dependencies", out var lockDeps) && lockDeps is IList lockDepsList) { foreach (var lockDep in lockDepsList) diff --git a/src/HelmSharp.Chart/HelmValues.cs b/src/HelmSharp.Chart/HelmValues.cs index 25c7ff0..59975eb 100644 --- a/src/HelmSharp.Chart/HelmValues.cs +++ b/src/HelmSharp.Chart/HelmValues.cs @@ -17,6 +17,26 @@ public static class HelmValues Dictionary? setStringValues, Dictionary? setJsonValues, CancellationToken cancellationToken) + { + var overrides = await BuildOverridesAsync( + valuesFiles, + valuesContent, + setValues, + setFileValues, + setStringValues, + setJsonValues, + cancellationToken); + return BuildFromOverrides(chart, overrides); + } + + internal static async Task> BuildOverridesAsync( + IEnumerable? valuesFiles, + string? valuesContent, + Dictionary? setValues, + Dictionary? setFileValues, + Dictionary? setStringValues, + Dictionary? setJsonValues, + CancellationToken cancellationToken) { var overrides = new Dictionary(StringComparer.Ordinal); @@ -52,9 +72,14 @@ public static class HelmValues foreach (var (key, value) in setJsonValues ?? []) SetPath(overrides, key, ParseJsonValue(value)); - return ProcessOverrides(chart, overrides, out _); + return overrides; } + internal static Dictionary BuildFromOverrides( + HelmChart chart, + Dictionary overrides) + => ProcessOverrides(chart, overrides, out _); + internal static Dictionary PrepareForRender( HelmChart chart, Dictionary values, diff --git a/src/HelmSharp.Release/HelmReleaseRecord.cs b/src/HelmSharp.Release/HelmReleaseRecord.cs new file mode 100644 index 0000000..d53e928 --- /dev/null +++ b/src/HelmSharp.Release/HelmReleaseRecord.cs @@ -0,0 +1,44 @@ +namespace HelmSharp.Release; + +public sealed record HelmReleaseRecord +{ + public string Name { get; init; } = string.Empty; + public string Namespace { get; init; } = "default"; + public int Revision { get; init; } + public string Status { get; set; } = "deployed"; + public string ChartName { get; init; } = string.Empty; + public string ChartVersion { get; init; } = string.Empty; + public string? AppVersion { get; init; } + public string? ChartApiVersion { get; init; } + public string? ChartDescription { get; init; } + public string? ChartType { get; init; } + public string? ChartKubeVersion { get; init; } + public string ChartValuesYaml { get; init; } = string.Empty; + public string? RawChartJson { get; init; } + public string Manifest { get; init; } = string.Empty; + public string ValuesYaml { get; init; } = string.Empty; + /// Fully computed values retained by HelmSharp for --all queries. + public string ComputedValuesYaml { get; init; } = string.Empty; + public DateTimeOffset? FirstDeployedAt { get; init; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; init; } + public string? Description { get; init; } + public string? Notes { get; init; } + public IReadOnlyList Hooks { get; init; } = Array.Empty(); + public Dictionary? Labels { get; init; } +} + +public sealed record HelmReleaseHookRecord +{ + public string Name { get; init; } = string.Empty; + public string Kind { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public string Manifest { get; init; } = string.Empty; + public IReadOnlyList Events { get; init; } = Array.Empty(); + public DateTimeOffset? LastRunStartedAt { get; init; } + public DateTimeOffset? LastRunCompletedAt { get; init; } + public string? LastRunPhase { get; init; } + public int Weight { get; init; } + public IReadOnlyList DeletePolicies { get; init; } = Array.Empty(); + public IReadOnlyList OutputLogPolicies { get; init; } = Array.Empty(); +} diff --git a/src/HelmSharp.Release/HelmReleaseStore.cs b/src/HelmSharp.Release/HelmReleaseStore.cs index c058fa3..e7bc512 100644 --- a/src/HelmSharp.Release/HelmReleaseStore.cs +++ b/src/HelmSharp.Release/HelmReleaseStore.cs @@ -24,38 +24,15 @@ public async Task NextRevisionAsync(string name, string ns, CancellationTok public async Task SaveAsync(HelmReleaseRecord record, CancellationToken cancellationToken) { var secretName = SecretName(record.Name, record.Revision); - var json = JsonSerializer.Serialize(record, JsonDefaults); - var secret = new V1Secret - { - Metadata = new V1ObjectMeta - { - Name = secretName, - NamespaceProperty = record.Namespace, - Labels = new Dictionary - { - ["owner"] = "helm", - ["name"] = record.Name, - ["status"] = record.Status, - ["version"] = record.Revision.ToString() - } - }, - Type = "helm.sh/release.v1", - StringData = new Dictionary - { - ["release.json"] = json, - ["manifest"] = record.Manifest, - ["values.yaml"] = record.ValuesYaml - } - }; - try { var existing = await _client.CoreV1.ReadNamespacedSecretAsync(secretName, record.Namespace, cancellationToken: cancellationToken); - secret.Metadata.ResourceVersion = existing.Metadata.ResourceVersion; + var secret = BuildSecret(record, existing, DateTimeOffset.UtcNow); await _client.CoreV1.ReplaceNamespacedSecretAsync(secret, secretName, record.Namespace, cancellationToken: cancellationToken); } catch (HttpOperationException ex) when ((int)ex.Response.StatusCode == 404) { + var secret = BuildSecret(record, existing: null, DateTimeOffset.UtcNow); await _client.CoreV1.CreateNamespacedSecretAsync(secret, record.Namespace, cancellationToken: cancellationToken); } } @@ -67,9 +44,7 @@ public async Task> ListAsync(string? ns, bool allNamespa : await _client.CoreV1.ListNamespacedSecretAsync(ns ?? "default", labelSelector: "owner=helm", cancellationToken: cancellationToken); return secrets.Items - .Select(TryReadRecord) - .Where(x => x is not null) - .Select(x => x!) + .Select(ReadRecord) .Where(IsActiveRelease) .GroupBy(x => new { x.Namespace, x.Name }) .Select(g => g.OrderByDescending(x => x.Revision).First()) @@ -86,9 +61,7 @@ public async Task> HistoryAsync(string name, string ns, cancellationToken: cancellationToken); return secrets.Items - .Select(TryReadRecord) - .Where(x => x is not null) - .Select(x => x!) + .Select(ReadRecord) .OrderBy(x => x.Revision) .ToList(); } @@ -109,7 +82,8 @@ public async Task MarkUninstalledAsync(HelmReleaseRecord record, CancellationTok { Revision = record.Revision + 1, Status = "uninstalled", - UpdatedAt = updatedAt + UpdatedAt = updatedAt, + DeletedAt = updatedAt }; record.Status = "superseded"; @@ -128,29 +102,124 @@ public async Task MarkStatusAsync(HelmReleaseRecord record, string status, Cance await SaveAsync(record, cancellationToken); } - private static HelmReleaseRecord? TryReadRecord(V1Secret secret) + internal static HelmReleaseRecord ReadRecord(V1Secret secret) { - try + ArgumentNullException.ThrowIfNull(secret); + var secretName = secret.Metadata?.Name ?? ""; + var namespaceName = secret.Metadata?.NamespaceProperty ?? "default"; + + if (TryGetPayload(secret, "release", out var helmPayload)) { - string? json = null; - if (secret.StringData is not null && secret.StringData.TryGetValue("release.json", out var stringData)) - json = stringData; - else if (secret.Data is not null && secret.Data.TryGetValue("release.json", out var data)) - json = Encoding.UTF8.GetString(data); - - return string.IsNullOrWhiteSpace(json) - ? null - : JsonSerializer.Deserialize(json, JsonDefaults); + try + { + return ApplySecretMetadata( + HelmV3ReleaseCodec.Decode(Encoding.UTF8.GetString(helmPayload)), + secret); + } + catch (Exception ex) when (ex is InvalidDataException or JsonException) + { + throw new HelmReleaseStoreException(secretName, namespaceName, "Helm v3 release", ex.Message, ex); + } } - catch + + if (TryGetPayload(secret, "release.json", out var legacyPayload)) { - return null; + try + { + var record = JsonSerializer.Deserialize(legacyPayload, JsonDefaults) + ?? throw new InvalidDataException("The legacy release JSON was empty."); + return ApplySecretMetadata(record, secret); + } + catch (Exception ex) when (ex is InvalidDataException or JsonException) + { + throw new HelmReleaseStoreException(secretName, namespaceName, "legacy release.json", ex.Message, ex); + } } + + throw new HelmReleaseStoreException( + secretName, + namespaceName, + "release", + "Neither data.release nor the legacy release.json key was present."); } - private static string SecretName(string releaseName, int revision) + internal static V1Secret BuildSecret( + HelmReleaseRecord record, + V1Secret? existing, + DateTimeOffset timestamp) + { + ArgumentNullException.ThrowIfNull(record); + var labels = new Dictionary(StringComparer.Ordinal); + MergeCustomLabels(labels, existing?.Metadata?.Labels); + MergeCustomLabels(labels, record.Labels); + labels[existing is null ? "createdAt" : "modifiedAt"] = timestamp.ToUnixTimeSeconds().ToString(); + labels["name"] = record.Name; + labels["owner"] = "helm"; + labels["status"] = record.Status; + labels["version"] = record.Revision.ToString(); + + return new V1Secret + { + Metadata = new V1ObjectMeta + { + Name = SecretName(record.Name, record.Revision), + NamespaceProperty = record.Namespace, + ResourceVersion = existing?.Metadata?.ResourceVersion, + Labels = labels + }, + Type = "helm.sh/release.v1", + Data = new Dictionary + { + ["release"] = Encoding.UTF8.GetBytes(HelmV3ReleaseCodec.Encode(record)) + } + }; + } + + private static bool TryGetPayload(V1Secret secret, string key, out byte[] payload) + { + if (secret.Data is not null && secret.Data.TryGetValue(key, out payload!)) + return true; + if (secret.StringData is not null && secret.StringData.TryGetValue(key, out var text)) + { + payload = Encoding.UTF8.GetBytes(text); + return true; + } + payload = []; + return false; + } + + private static HelmReleaseRecord ApplySecretMetadata(HelmReleaseRecord record, V1Secret secret) + { + var labels = new Dictionary(StringComparer.Ordinal); + MergeCustomLabels(labels, secret.Metadata?.Labels); + return record with + { + Namespace = string.IsNullOrWhiteSpace(record.Namespace) + ? secret.Metadata?.NamespaceProperty ?? "default" + : record.Namespace, + Labels = labels.Count == 0 ? null : labels + }; + } + + private static void MergeCustomLabels( + Dictionary target, + IDictionary? source) + { + if (source is null) + return; + foreach (var (key, value) in source) + { + if (!SystemLabels.Contains(key)) + target[key] = value; + } + } + + internal static string SecretName(string releaseName, int revision) => $"sh.helm.release.v1.{releaseName}.v{revision}"; + private static readonly HashSet SystemLabels = + ["name", "owner", "status", "version", "createdAt", "modifiedAt"]; + private static bool IsActiveRelease(HelmReleaseRecord record) => string.Equals(record.Status, "deployed", StringComparison.OrdinalIgnoreCase); @@ -159,18 +228,3 @@ private static bool IsActiveRelease(HelmReleaseRecord record) WriteIndented = true }; } - -public sealed record HelmReleaseRecord -{ - public string Name { get; init; } = string.Empty; - public string Namespace { get; init; } = "default"; - public int Revision { get; init; } - public string Status { get; set; } = "deployed"; - public string ChartName { get; init; } = string.Empty; - public string ChartVersion { get; init; } = string.Empty; - public string? AppVersion { get; init; } - public string Manifest { get; init; } = string.Empty; - public string ValuesYaml { get; init; } = string.Empty; - public DateTimeOffset UpdatedAt { get; set; } - public Dictionary? Labels { get; init; } -} diff --git a/src/HelmSharp.Release/HelmReleaseStoreException.cs b/src/HelmSharp.Release/HelmReleaseStoreException.cs new file mode 100644 index 0000000..af6c22b --- /dev/null +++ b/src/HelmSharp.Release/HelmReleaseStoreException.cs @@ -0,0 +1,21 @@ +namespace HelmSharp.Release; + +public sealed class HelmReleaseStoreException : Exception +{ + public HelmReleaseStoreException( + string secretName, + string namespaceName, + string format, + string message, + Exception? innerException = null) + : base($"Release Secret {namespaceName}/{secretName} contains an unreadable {format} payload: {message}", innerException) + { + SecretName = secretName; + NamespaceName = namespaceName; + Format = format; + } + + public string SecretName { get; } + public string NamespaceName { get; } + public string Format { get; } +} diff --git a/src/HelmSharp.Release/HelmSharp.Release.csproj b/src/HelmSharp.Release/HelmSharp.Release.csproj index acca7f9..ab895f3 100644 --- a/src/HelmSharp.Release/HelmSharp.Release.csproj +++ b/src/HelmSharp.Release/HelmSharp.Release.csproj @@ -6,6 +6,11 @@ enable + + + + + diff --git a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs new file mode 100644 index 0000000..67aef91 --- /dev/null +++ b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs @@ -0,0 +1,409 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using HelmSharp.Chart; + +namespace HelmSharp.Release; + +internal static class HelmV3ReleaseCodec +{ + private static readonly byte[] GzipMagic = [0x1f, 0x8b, 0x08]; + + public static string Encode(HelmReleaseRecord record) + { + ArgumentNullException.ThrowIfNull(record); + ValidateIdentity(record.Name, record.Namespace, record.Revision); + + var chart = BuildChart(record); + var info = new JsonObject + { + ["first_deployed"] = FormatTime(record.FirstDeployedAt ?? record.UpdatedAt), + ["last_deployed"] = FormatTime(record.UpdatedAt), + ["deleted"] = FormatTime(record.DeletedAt), + ["description"] = record.Description, + ["status"] = record.Status, + ["notes"] = record.Notes + }; + var hooks = new JsonArray(record.Hooks.Select(ToJsonHook).ToArray()); + var release = new JsonObject + { + ["name"] = record.Name, + ["info"] = info, + ["chart"] = chart, + ["config"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(record.ValuesYaml)), + ["manifest"] = record.Manifest, + ["hooks"] = hooks, + ["version"] = record.Revision, + ["namespace"] = record.Namespace + }; + if (!string.IsNullOrWhiteSpace(record.ComputedValuesYaml)) + release["helmsharp_computed_values"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(record.ComputedValuesYaml)); + + var json = Encoding.UTF8.GetBytes(release.ToJsonString()); + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + gzip.Write(json); + return Convert.ToBase64String(output.ToArray()); + } + + public static HelmReleaseRecord Decode(string encodedRelease) + { + if (string.IsNullOrWhiteSpace(encodedRelease)) + throw new InvalidDataException("The Helm release payload is empty."); + + byte[] payload; + try + { + payload = Convert.FromBase64String(encodedRelease); + } + catch (FormatException ex) + { + throw new InvalidDataException("The Helm release payload is not valid Base64.", ex); + } + + if (payload.AsSpan().StartsWith(GzipMagic)) + { + try + { + using var compressed = new MemoryStream(payload, writable: false); + using var gzip = new GZipStream(compressed, CompressionMode.Decompress); + using var decompressed = new MemoryStream(); + gzip.CopyTo(decompressed); + payload = decompressed.ToArray(); + } + catch (InvalidDataException ex) + { + throw new InvalidDataException("The Helm release payload contains invalid gzip data.", ex); + } + } + + try + { + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + var name = GetString(root, "name"); + var namespaceName = GetString(root, "namespace"); + var revision = GetInt32(root, "version"); + ValidateIdentity(name, namespaceName, revision); + + var info = TryGetObject(root, "info"); + var chart = TryGetObject(root, "chart"); + var metadata = chart is { } chartElement ? TryGetObject(chartElement, "metadata") : null; + var firstDeployed = ParseTime(GetString(info, "first_deployed")); + var updatedAt = ParseTime(GetString(info, "last_deployed")) + ?? firstDeployed + ?? DateTimeOffset.MinValue; + var deletedAt = ParseTime(GetString(info, "deleted")); + if (deletedAt == DateTimeOffset.MinValue) + deletedAt = null; + + var values = root.TryGetProperty("config", out var config) && config.ValueKind == JsonValueKind.Object + ? (Dictionary)JsonElementToObject(config)! + : new Dictionary(StringComparer.Ordinal); + var computedValuesYaml = root.TryGetProperty("helmsharp_computed_values", out var computed) && computed.ValueKind == JsonValueKind.Object + ? HelmYaml.Serialize((Dictionary)JsonElementToObject(computed)!) + : string.Empty; + var chartValues = chart is { } chartValue && chartValue.TryGetProperty("values", out var defaults) && defaults.ValueKind == JsonValueKind.Object + ? (Dictionary)JsonElementToObject(defaults)! + : new Dictionary(StringComparer.Ordinal); + var hooks = root.TryGetProperty("hooks", out var hookElements) && hookElements.ValueKind == JsonValueKind.Array + ? hookElements.EnumerateArray().Select(FromJsonHook).ToList() + : []; + + return new HelmReleaseRecord + { + Name = name!, + Namespace = namespaceName!, + Revision = revision, + Status = GetString(info, "status") ?? string.Empty, + ChartName = GetString(metadata, "name") ?? string.Empty, + ChartVersion = GetString(metadata, "version") ?? string.Empty, + AppVersion = GetString(metadata, "appVersion"), + ChartApiVersion = GetString(metadata, "apiVersion"), + ChartDescription = GetString(metadata, "description"), + ChartType = GetString(metadata, "type"), + ChartKubeVersion = GetString(metadata, "kubeVersion"), + ChartValuesYaml = HelmYaml.Serialize(chartValues), + RawChartJson = chart?.GetRawText(), + Manifest = GetString(root, "manifest") ?? string.Empty, + ValuesYaml = HelmYaml.Serialize(values), + ComputedValuesYaml = computedValuesYaml, + FirstDeployedAt = firstDeployed, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Description = GetString(info, "description"), + Notes = GetString(info, "notes"), + Hooks = hooks + }; + } + catch (JsonException ex) + { + throw new InvalidDataException("The Helm release payload does not contain valid release JSON.", ex); + } + } + + + internal static string CreateChartSnapshot(HelmChart chart) + { + ArgumentNullException.ThrowIfNull(chart); + return CreateChartSnapshotNode(chart).ToJsonString(); + } + + private static JsonObject CreateChartSnapshotNode(HelmChart chart) + { + var metadata = new JsonObject + { + ["name"] = chart.Name, + ["home"] = chart.Home, + ["sources"] = JsonSerializer.SerializeToNode(chart.Sources), + ["version"] = chart.Version, + ["description"] = chart.Description, + ["keywords"] = JsonSerializer.SerializeToNode(chart.Keywords), + ["maintainers"] = JsonSerializer.SerializeToNode(chart.Maintainers), + ["icon"] = chart.Icon, + ["apiVersion"] = string.IsNullOrWhiteSpace(chart.ApiVersion) ? "v2" : chart.ApiVersion, + ["appVersion"] = chart.AppVersion, + ["deprecated"] = chart.Deprecated, + ["annotations"] = JsonSerializer.SerializeToNode(chart.Annotations), + ["kubeVersion"] = chart.KubeVersion, + ["dependencies"] = new JsonArray(chart.Dependencies.Select(ToJsonDependency).ToArray()), + ["type"] = chart.Type + }; + + var templates = new JsonArray(chart.Templates + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => ToJsonFile(pair.Key, Encoding.UTF8.GetBytes(pair.Value))) + .ToArray()); + + var schema = chart.Files.FirstOrDefault(pair => + string.Equals(pair.Key, "values.schema.json", StringComparison.OrdinalIgnoreCase)); + var files = new JsonArray(chart.Files + .Where(pair => + !string.Equals(pair.Key, "values.schema.json", StringComparison.OrdinalIgnoreCase) && + !string.Equals(pair.Key, "Chart.lock", StringComparison.OrdinalIgnoreCase)) + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => ToJsonFile(pair.Key, pair.Value)) + .ToArray()); + + var hasLock = chart.LockEntries.Count > 0 || + !string.IsNullOrWhiteSpace(chart.LockDigest) || + !string.IsNullOrWhiteSpace(chart.LockGenerated); + JsonNode? lockNode = hasLock + ? new JsonObject + { + ["generated"] = chart.LockGenerated, + ["digest"] = chart.LockDigest, + ["dependencies"] = new JsonArray(chart.LockEntries.Select(ToJsonLockDependency).ToArray()) + } + : null; + + return new JsonObject + { + ["metadata"] = metadata, + ["lock"] = lockNode, + ["templates"] = templates, + ["values"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(chart.ValuesYaml)), + ["schema"] = schema.Value is null ? null : Convert.ToBase64String(schema.Value), + ["files"] = files, + ["crds"] = new JsonArray(chart.Crds + .Select((crd, index) => ToJsonCrd(crd, index)) + .ToArray()), + ["dependencies"] = new JsonArray(chart.Subcharts + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => CreateChartSnapshotNode(pair.Value)) + .ToArray()) + }; + } + + private static JsonNode ToJsonDependency(HelmChartDependency dependency) + => new JsonObject + { + ["name"] = dependency.Name, + ["version"] = dependency.Version, + ["repository"] = dependency.Repository, + ["condition"] = dependency.Condition, + ["tags"] = JsonSerializer.SerializeToNode(dependency.Tags), + ["enabled"] = dependency.Enabled, + ["import-values"] = JsonSerializer.SerializeToNode(dependency.ImportValues), + ["alias"] = dependency.Alias + }; + + private static JsonNode ToJsonLockDependency(HelmChartLockEntry dependency) + => new JsonObject + { + ["name"] = dependency.Name, + ["version"] = dependency.Version, + ["repository"] = dependency.Repository, + ["digest"] = dependency.Digest + }; + + private static JsonNode ToJsonFile(string name, byte[] data) + => new JsonObject + { + ["name"] = name, + ["data"] = Convert.ToBase64String(data) + }; + + private static JsonNode ToJsonCrd(Dictionary crd, int index) + { + var resourceName = crd.TryGetValue("metadata", out var metadataValue) && + metadataValue is IDictionary metadata && + metadata.TryGetValue("name", out var nameValue) + ? Convert.ToString(nameValue) + : null; + var name = string.IsNullOrWhiteSpace(resourceName) + ? $"crds/crd-{index + 1}.yaml" + : $"crds/{resourceName}.yaml"; + return ToJsonFile(name, Encoding.UTF8.GetBytes(HelmYaml.Serialize(crd))); + } + + private static JsonObject BuildChart(HelmReleaseRecord record) + { + JsonObject chart; + if (string.IsNullOrWhiteSpace(record.RawChartJson)) + { + chart = new JsonObject + { + ["lock"] = null, + ["templates"] = new JsonArray(), + ["schema"] = null, + ["files"] = new JsonArray() + }; + } + else + { + try + { + chart = JsonNode.Parse(record.RawChartJson) as JsonObject + ?? throw new InvalidDataException("The preserved Helm chart payload is not a JSON object."); + } + catch (JsonException ex) + { + throw new InvalidDataException("The preserved Helm chart payload is not valid JSON.", ex); + } + } + + var metadata = chart["metadata"] as JsonObject ?? new JsonObject(); + chart["metadata"] = metadata; + metadata["name"] = record.ChartName; + metadata["version"] = record.ChartVersion; + metadata["appVersion"] = record.AppVersion; + metadata["apiVersion"] = string.IsNullOrWhiteSpace(record.ChartApiVersion) ? "v2" : record.ChartApiVersion; + metadata["description"] = record.ChartDescription; + metadata["type"] = record.ChartType; + metadata["kubeVersion"] = record.ChartKubeVersion; + chart["values"] = JsonSerializer.SerializeToNode( + HelmYaml.DeserializeDictionary(record.ChartValuesYaml)); + return chart; + } + private static JsonNode ToJsonHook(HelmReleaseHookRecord hook) + => new JsonObject + { + ["name"] = hook.Name, + ["kind"] = hook.Kind, + ["path"] = hook.Path, + ["manifest"] = hook.Manifest, + ["events"] = new JsonArray(hook.Events.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray()), + ["last_run"] = new JsonObject + { + ["started_at"] = FormatTime(hook.LastRunStartedAt), + ["completed_at"] = FormatTime(hook.LastRunCompletedAt), + ["phase"] = hook.LastRunPhase ?? "Unknown" + }, + ["weight"] = hook.Weight, + ["delete_policies"] = new JsonArray(hook.DeletePolicies.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray()), + ["output_log_policies"] = new JsonArray(hook.OutputLogPolicies.Select(value => (JsonNode?)JsonValue.Create(value)).ToArray()) + }; + + private static HelmReleaseHookRecord FromJsonHook(JsonElement hook) + { + var lastRun = TryGetObject(hook, "last_run"); + var startedAt = ParseTime(GetString(lastRun, "started_at")); + var completedAt = ParseTime(GetString(lastRun, "completed_at")); + return new HelmReleaseHookRecord + { + Name = GetString(hook, "name") ?? string.Empty, + Kind = GetString(hook, "kind") ?? string.Empty, + Path = GetString(hook, "path") ?? string.Empty, + Manifest = GetString(hook, "manifest") ?? string.Empty, + Events = GetStrings(hook, "events"), + LastRunStartedAt = startedAt == DateTimeOffset.MinValue ? null : startedAt, + LastRunCompletedAt = completedAt == DateTimeOffset.MinValue ? null : completedAt, + LastRunPhase = GetString(lastRun, "phase"), + Weight = GetInt32(hook, "weight"), + DeletePolicies = GetStrings(hook, "delete_policies"), + OutputLogPolicies = GetStrings(hook, "output_log_policies") + }; + } + + private static JsonElement? TryGetObject(JsonElement element, string name) + => element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.Object + ? value + : null; + + private static string? GetString(JsonElement? element, string name) + => element is { ValueKind: JsonValueKind.Object } value + && value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static int GetInt32(JsonElement? element, string name) + => element is { ValueKind: JsonValueKind.Object } value + && value.TryGetProperty(name, out var property) + && property.TryGetInt32(out var result) + ? result + : 0; + + private static List GetStrings(JsonElement element, string name) + => element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array + ? property.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()!) + .ToList() + : []; + + private static void ValidateIdentity(string? name, string? namespaceName, int revision) + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidDataException("The release name is missing."); + if (string.IsNullOrWhiteSpace(namespaceName)) + throw new InvalidDataException("The release namespace is missing."); + if (revision <= 0) + throw new InvalidDataException("The release revision must be greater than zero."); + } + + private static string FormatTime(DateTimeOffset? value) + => value is null + ? string.Empty + : value.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture); + + private static DateTimeOffset? ParseTime(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) + return parsed; + throw new InvalidDataException($"The Helm release contains an invalid timestamp '{value}'."); + } + + private static object? JsonElementToObject(JsonElement element) + => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var integer) ? integer : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null or JsonValueKind.Undefined => null, + JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary( + property => property.Name, + property => JsonElementToObject(property.Value), + StringComparer.Ordinal), + _ => null + }; +} diff --git a/tests/HelmSharp.Tests/ChartOperationsTests.cs b/tests/HelmSharp.Tests/ChartOperationsTests.cs index 875af60..f94a357 100644 --- a/tests/HelmSharp.Tests/ChartOperationsTests.cs +++ b/tests/HelmSharp.Tests/ChartOperationsTests.cs @@ -390,6 +390,43 @@ public void ResolveReleaseRenderState_UsesHistoryForUpgradeAndRevision() Assert.Equal(4, state.Revision); } + [Fact] + public void ResolveReleaseLabels_InheritsLatestAndAppliesRequestedOverrides() + { + var history = new List + { + new() + { + Revision = 1, + Labels = new Dictionary { ["old"] = "ignored" } + }, + new() + { + Revision = 2, + Labels = new Dictionary + { + ["team"] = "platform", + ["environment"] = "staging" + } + } + }; + + var labels = HelmClient.ResolveReleaseLabels( + history, + isUpgrade: true, + new Dictionary + { + ["environment"] = "production", + ["requested"] = "true" + }); + + Assert.Equal("platform", labels!["team"]); + Assert.Equal("production", labels["environment"]); + Assert.Equal("true", labels["requested"]); + Assert.False(labels.ContainsKey("old")); + Assert.Null(HelmClient.ResolveReleaseLabels(history, isUpgrade: false, requestedLabels: null)); + } + [Fact] public void ResolveReleaseRenderState_UninstalledHistoryStartsInstallWithNextRevision() { diff --git a/tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release-secret.json b/tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release-secret.json new file mode 100644 index 0000000..dd9f044 --- /dev/null +++ b/tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release-secret.json @@ -0,0 +1,21 @@ +{ + "kind": "Secret", + "apiVersion": "v1", + "metadata": { + "name": "sh.helm.release.v1.fixture-release.v3", + "namespace": "fixture-ns", + "labels": { + "createdAt": "1784250184", + "environment": "fixture", + "name": "fixture-release", + "owner": "helm", + "status": "deployed", + "team": "sdk", + "version": "3" + } + }, + "data": { + "release": "SDRzSUFBQUFBQUFDLzl4V1hXL2J1Qkw5SzhMY1YxbVc1VGhGQkJTNHJaczQ5cjFOMGFSeGJLK0RnS2JHRW1PSzVKS1VFcmZ3ZjErUS9uYlQ3ajd2bTBRT2grZWNHYzdNRHhDa1JFaGh6bDV0cGJHaGtTTXhDQ0V3TVplUS9vQTUwOFkrWmFpNFhHSUdLU1J4Y3Q2STN6VmE3NzdGclRSTzByZzlnUkE0K2ExWk80M1BuRm1HSEswMzhEK0dhcVlza3dKUzZBdGpDZWNCbGFWeVJoQ0NzY1JXQmxMWU9RNUJTSXR1NldvTk9mRC93VnpMTXJoR1hnWjFPMHBhVVhzcVlCVUNMWWkyamthSmxtVEVFdmQ5d25sdEUwSWgvWEpoclRKcHM0bXZ4T0dJTEJyYlBMVTFzdExVd2Zoalo1OHpXMVN6aU1weWUvVGsxR01JTldxekp0dUtrcWo5a3dUZERmVWpKc0hHVGJDOWZJSExGNmt6Zi90bXp3Zk1vcGJLWFZNU0ppeGhBcld6MlJIZUt2WjV0dzBoWUVrWTM2dngzMFBhRUVLbCthODAyZDhDcThjUUdQVVUzalIxZTVHcGN3aUJLRGJjcVZBbmZrWHRWODZpVG5UdUZvV1FsamhaakF2WmtUZWwwYUN1ZlJaWlhhRUw5S0thNGQ3THRJcmpOcjV2UlVrbmlyM0tDa1dHZ2pJOFVtUWZJTVpkYnUzamswVHQ2QXhDMEtpa1lWYnE1UUUzSHdnVG5VaEZwY2pZSnBBYngxM25OMEpCWnR6bnJpWDVTZFNrSnBTakM5cldLbldVUWlDY0VaZm1IbHBqL2Vka3RrdmxrQk9sT0tOZUlFZWZTN3B3T3VVb1VCTjcvQUxQdnlYdHRIT1J4ckYvZ1N4M2VGTXdCVWs2NStsV0ErZWlzZG44RnltMkNzRmlxVGl4eDBSMmkwMHF4WnpsSlZIUmtwVGNjZmQxQXNhamoycFlEcGMwNGZYc1dlYlo4K1gvU01LcnlTZVpmMDB1cWtuSnhiZUhxNWR1MmVKWjcyb3hIdDBXWDNLWjkzdWRZdlp3Zjk3dlRSUmUzN1pvZVc5cE9UU1RoNnZ2azYveVBhekNOMUFVVWk3K0JzQzR2SXJIU1Y0ZkFya3JMOWcvQVVCNkZ6VjVjWmU3TWtSNGhXWmQzZmVxKzNkMktPa3FoQktOSVRuNkNqd25GYmZyQUx2VTY4cEtXRWhicXhBTUxiQjBpSEU1aUhIMGtmZWZKWnNsZ3o4bkR6ZHhuNzB3ZWoyb2FXKzR6SHFjMDJYL0hKY0RPeG5kZkI4L1pMei9yTjcxeFcySDl1N1pGemI0bmwwUDFLeWtiRDVxWFVBSWM4YVBBM2Q3K2VIVDU4dW96UFpDOVpjZmUyUlV4Tmxvd1B1WE4zSThHc1RkL1AyUjB2L3ZkeTl2N2k3M2gyNUxmcFpkRDVlVHUxWTlLMTg3L2Q2ckdpZkRpaWIzZWRZYm5tVWZ2Rm9ybjZaemxxKzd5RllPV2FQV0xITUpLZEQ0Qi9lemVzZEtKVTVQSXRoOC9md2FqY1pVL0NlNDg1MGtEWTU2UmZOWDJUa1YrL3FaQm5WcktoWk1aR25ROVZhZmlacUtiYWRMcHlJSUhQbTk3MDEzbndyZjdlVGl6WmZ0Tmx5VFljSVZrWUdjUVFpSzJPSzBZemJmenQwRGhvZFFaOFRTb3JrSFBKQ3ozMEoxTGoxT3JGRllYd0tVeGdaYnp3aXVNTGc3SGpkemg2NkUwOTlZb2kxbVQ4UytPYWY0QVdRN1hmelNxdU9zVk9HbW9CVHVLa29STTh4Y2tYMUJsaGNXMGtack84YzhLY25acGtUQ0RPZHlnN3hCTmE1cjgxcm9odG41ZVF4QlZsWlY5b25ML09qOHFlSHFjR0JvaHo1T1JoRjZHQ3hoWVBWWEFBQUEvLzl1ajdFUHhRa0FBQT09" + }, + "type": "helm.sh/release.v1" +} diff --git a/tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release.json b/tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release.json new file mode 100644 index 0000000..dbca2c4 --- /dev/null +++ b/tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release.json @@ -0,0 +1,81 @@ +{ + "name": "fixture-release", + "info": { + "first_deployed": "2026-07-17T01:02:03Z", + "last_deployed": "2026-07-17T01:03:04Z", + "deleted": "", + "description": "Install complete", + "status": "deployed", + "notes": "Fixture notes\n" + }, + "chart": { + "metadata": { + "name": "fixture-chart", + "version": "1.2.3", + "description": "Fixture chart", + "apiVersion": "v2", + "appVersion": "4.5.6", + "kubeVersion": ">=1.25.0", + "type": "application", + "home": "https://example.test/fixture-chart", + "annotations": { + "example.test/preserved": "true" + } + }, + "lock": { + "generated": "2026-07-17T00:00:00Z", + "digest": "sha256:fixture", + "dependencies": [] + }, + "templates": [ + { + "name": "templates/configmap.yaml", + "data": "YXBpVmVyc2lvbjogdjEKa2luZDogQ29uZmlnTWFwCg==" + } + ], + "values": { + "message": "default", + "replicaCount": 1 + }, + "schema": "eyJ0eXBlIjoib2JqZWN0In0=", + "files": [ + { + "name": "README.md", + "data": "IyBGaXh0dXJlIENoYXJ0Cg==" + } + ] + }, + "config": { + "message": "hello", + "nested": { + "enabled": true + }, + "replicaCount": 2 + }, + "manifest": "---\n# Source: fixture-chart/templates/configmap.yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: fixture-release\n", + "hooks": [ + { + "name": "fixture-hook", + "kind": "Job", + "path": "templates/hook.yaml", + "manifest": "apiVersion: batch/v1\nkind: Job\nmetadata:\n name: fixture-hook\n", + "events": [ + "pre-install" + ], + "last_run": { + "started_at": "2026-07-17T01:02:04Z", + "completed_at": "2026-07-17T01:02:05Z", + "phase": "Succeeded" + }, + "weight": -1, + "delete_policies": [ + "before-hook-creation" + ], + "output_log_policies": [ + "hook-succeeded" + ] + } + ], + "version": 3, + "namespace": "fixture-ns" +} diff --git a/tests/HelmSharp.Tests/HelmClientReleaseTests.cs b/tests/HelmSharp.Tests/HelmClientReleaseTests.cs new file mode 100644 index 0000000..4ac3946 --- /dev/null +++ b/tests/HelmSharp.Tests/HelmClientReleaseTests.cs @@ -0,0 +1,62 @@ +using HelmSharp.Action; +using HelmSharp.Release; + +namespace HelmSharp.Tests; + +public class HelmClientReleaseTests +{ + [Fact] + public void GetStoredValuesYaml_AllValues_MergesChartDefaultsWithOverrides() + { + var record = new HelmReleaseRecord + { + ChartValuesYaml = """ + replicaCount: 1 + image: + repository: nginx + tag: stable + """, + ValuesYaml = """ + replicaCount: 2 + image: + tag: 1.2.3 + """ + }; + + var valuesYaml = HelmClient.GetStoredValuesYaml(record, allValues: true); + + Assert.Contains("replicaCount: 2", valuesYaml); + Assert.Contains("repository: nginx", valuesYaml); + Assert.Contains("tag: 1.2.3", valuesYaml); + Assert.Equal(record.ValuesYaml, HelmClient.GetStoredValuesYaml(record, allValues: false)); + } + + [Fact] + public void GetStoredValuesYaml_AllValues_UsesPersistedComputedValues() + { + var record = new HelmReleaseRecord + { + ChartValuesYaml = "root: default\n", + ValuesYaml = "root: override\n", + ComputedValuesYaml = """ + root: override + child: + imported: true + global: propagated + """ + }; + + var valuesYaml = HelmClient.GetStoredValuesYaml(record, allValues: true); + + Assert.Contains("imported: true", valuesYaml); + Assert.Contains("global: propagated", valuesYaml); + } + + [Fact] + public void GetStoredNotes_ReturnsReleaseNotesWhenPresent() + { + var record = new HelmReleaseRecord { Notes = "Installed successfully.\n" }; + + Assert.Equal("Installed successfully.\n", HelmClient.GetStoredNotes(record)); + } +} diff --git a/tests/HelmSharp.Tests/HelmHookTests.cs b/tests/HelmSharp.Tests/HelmHookTests.cs index 9cafcb6..d912d3c 100644 --- a/tests/HelmSharp.Tests/HelmHookTests.cs +++ b/tests/HelmSharp.Tests/HelmHookTests.cs @@ -216,4 +216,84 @@ public void ExtractHooks_MultipleDocuments_OnlyHooksExtracted() Assert.Contains("my-deploy", remaining); Assert.DoesNotContain("my-hook", remaining); } + + [Fact] + public void ResolveStoredManifest_UsesSeparatelyStoredHooks() + { + var mainManifest = """ + apiVersion: v1 + kind: ConfigMap + metadata: + name: main-config + """; + var hookManifest = """ + apiVersion: batch/v1 + kind: Job + metadata: + name: delete-hook + """; + var record = new HelmSharp.Release.HelmReleaseRecord + { + Manifest = mainManifest, + Hooks = + [ + new HelmSharp.Release.HelmReleaseHookRecord + { + Name = "delete-hook", + Kind = "Job", + Path = "templates/delete-hook.yaml", + Manifest = hookManifest, + Events = ["pre-delete", "test"], + Weight = -2, + DeletePolicies = ["hook-succeeded"] + } + ] + }; + + var (resolvedManifest, hooks) = HelmClient.ResolveStoredManifest(record, "default"); + + Assert.Equal(mainManifest, resolvedManifest); + var hook = Assert.Single(hooks); + Assert.Equal("delete-hook", hook.Name); + Assert.Contains(HelmHookEvent.PreDelete, hook.Events); + Assert.Contains(HelmHookEvent.Test, hook.Events); + Assert.Equal(-2, hook.Weight); + Assert.Contains(HelmHookDeletePolicy.HookSucceeded, hook.DeletePolicies); + } + + [Fact] + public void ResolveStoredManifest_FallsBackToLegacyCombinedManifest() + { + var record = new HelmSharp.Release.HelmReleaseRecord + { + Manifest = """ + apiVersion: v1 + kind: ConfigMap + metadata: + name: main-config + --- + apiVersion: batch/v1 + kind: Job + metadata: + name: legacy-test-hook + annotations: + helm.sh/hook: test + spec: + template: + spec: + containers: + - name: test + image: test:latest + restartPolicy: Never + """ + }; + + var (mainManifest, hooks) = HelmClient.ResolveStoredManifest(record, "default"); + + Assert.Contains("main-config", mainManifest); + Assert.DoesNotContain("legacy-test-hook", mainManifest); + var hook = Assert.Single(hooks); + Assert.Equal("legacy-test-hook", hook.Name); + Assert.Contains(HelmHookEvent.Test, hook.Events); + } } diff --git a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs new file mode 100644 index 0000000..f6c64ef --- /dev/null +++ b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs @@ -0,0 +1,533 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using HelmSharp.Release; +using k8s.Models; + +namespace HelmSharp.Tests; + +public class HelmV3ReleaseStorageTests +{ + [Fact] + public void Decode_ReadsFixedHelmV3ReleaseShape() + { + var json = File.ReadAllText(FixturePath()); + + var record = HelmV3ReleaseCodec.Decode(EncodeLikeHelm(json, compress: true)); + + Assert.Equal("fixture-release", record.Name); + Assert.Equal("fixture-ns", record.Namespace); + Assert.Equal(3, record.Revision); + Assert.Equal("deployed", record.Status); + Assert.Equal("fixture-chart", record.ChartName); + Assert.Equal("1.2.3", record.ChartVersion); + Assert.Equal("4.5.6", record.AppVersion); + Assert.Equal("v2", record.ChartApiVersion); + Assert.Equal("Fixture chart", record.ChartDescription); + Assert.Equal("application", record.ChartType); + Assert.Equal(">=1.25.0", record.ChartKubeVersion); + Assert.Equal(DateTimeOffset.Parse("2026-07-17T01:02:03Z"), record.FirstDeployedAt); + Assert.Equal(DateTimeOffset.Parse("2026-07-17T01:03:04Z"), record.UpdatedAt); + Assert.Null(record.DeletedAt); + Assert.Equal("Install complete", record.Description); + Assert.Equal("Fixture notes\n", record.Notes); + Assert.Contains("message: hello", record.ValuesYaml); + Assert.Contains("replicaCount: 2", record.ValuesYaml); + Assert.Contains("message: default", record.ChartValuesYaml); + Assert.Contains("replicaCount: 1", record.ChartValuesYaml); + Assert.Equal(string.Empty, record.ComputedValuesYaml); + Assert.DoesNotContain("fixture-hook", record.Manifest); + + var hook = Assert.Single(record.Hooks); + Assert.Equal("fixture-hook", hook.Name); + Assert.Equal("Job", hook.Kind); + Assert.Equal(["pre-install"], hook.Events); + Assert.Equal("Succeeded", hook.LastRunPhase); + Assert.Equal(-1, hook.Weight); + Assert.Equal(["before-hook-creation"], hook.DeletePolicies); + Assert.Equal(["hook-succeeded"], hook.OutputLogPolicies); + } + + [Fact] + public void Decode_AcceptsHelmPreCompressionPayload() + { + var json = File.ReadAllText(FixturePath()); + + var record = HelmV3ReleaseCodec.Decode(EncodeLikeHelm(json, compress: false)); + + Assert.Equal("fixture-release", record.Name); + Assert.Equal(3, record.Revision); + } + + [Fact] + public void Encode_ProducesHelmV3EnvelopeWithDistinctConfigAndChartValues() + { + var record = CreateRecord(); + + using var document = DecodeLikeHelm(HelmV3ReleaseCodec.Encode(record)); + var root = document.RootElement; + + Assert.Equal("fixture-release", root.GetProperty("name").GetString()); + Assert.False(root.TryGetProperty("labels", out _)); + Assert.Equal("", root.GetProperty("info").GetProperty("deleted").GetString()); + Assert.Equal("override", root.GetProperty("config").GetProperty("message").GetString()); + Assert.Equal("computed", root.GetProperty("helmsharp_computed_values").GetProperty("message").GetString()); + Assert.Equal("default", root.GetProperty("chart").GetProperty("values").GetProperty("message").GetString()); + Assert.Equal("v2", root.GetProperty("chart").GetProperty("metadata").GetProperty("apiVersion").GetString()); + Assert.Equal("Succeeded", root.GetProperty("hooks")[0].GetProperty("last_run").GetProperty("phase").GetString()); + Assert.DoesNotContain("fixture-hook", root.GetProperty("manifest").GetString()); + } + + [Fact] + public void EncodeThenDecode_PreservesDeletionTimestamp() + { + var deletedAt = DateTimeOffset.Parse("2026-07-21T06:00:00Z"); + var record = CreateRecord() with { Status = "uninstalled", DeletedAt = deletedAt }; + + var decoded = HelmV3ReleaseCodec.Decode(HelmV3ReleaseCodec.Encode(record)); + + Assert.Equal(deletedAt, decoded.DeletedAt); + } + + + [Fact] + public void DecodeThenEncode_PreservesOpaqueHelmChartPayload() + { + var json = File.ReadAllText(FixturePath()); + var record = HelmV3ReleaseCodec.Decode(EncodeLikeHelm(json, compress: true)); + + using var document = DecodeLikeHelm(HelmV3ReleaseCodec.Encode(record with { Status = "superseded" })); + var chart = document.RootElement.GetProperty("chart"); + var metadata = chart.GetProperty("metadata"); + + Assert.Equal( + "YXBpVmVyc2lvbjogdjEKa2luZDogQ29uZmlnTWFwCg==", + chart.GetProperty("templates")[0].GetProperty("data").GetString()); + Assert.Equal("eyJ0eXBlIjoib2JqZWN0In0=", chart.GetProperty("schema").GetString()); + Assert.Equal( + "IyBGaXh0dXJlIENoYXJ0Cg==", + chart.GetProperty("files")[0].GetProperty("data").GetString()); + Assert.Equal("sha256:fixture", chart.GetProperty("lock").GetProperty("digest").GetString()); + Assert.Equal( + "true", + metadata.GetProperty("annotations").GetProperty("example.test/preserved").GetString()); + Assert.Equal("https://example.test/fixture-chart", metadata.GetProperty("home").GetString()); + } + + [Fact] + public void Decode_ReportsCorruptGzipPayload() + { + var encoded = Convert.ToBase64String([0x1f, 0x8b, 0x08, 0xff]); + + var exception = Assert.Throws( + () => HelmV3ReleaseCodec.Decode(encoded)); + + Assert.Contains("gzip", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Decode_ReportsInvalidJsonInsideGzip() + { + var encoded = EncodeLikeHelm("{not-json", compress: true); + + var exception = Assert.Throws( + () => HelmV3ReleaseCodec.Decode(encoded)); + + Assert.Contains("release JSON", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] + public void ReadRecord_FallsBackToLegacyReleaseJson() + { + var record = CreateRecord(); + var legacyJson = JsonSerializer.Serialize(record, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var secret = new V1Secret + { + Metadata = new V1ObjectMeta + { + Name = "sh.helm.release.v1.fixture-release.v3", + NamespaceProperty = "fixture-ns", + Labels = new Dictionary + { + ["owner"] = "helm", + ["team"] = "sdk" + } + }, + Data = new Dictionary + { + ["release.json"] = Encoding.UTF8.GetBytes(legacyJson) + } + }; + + var restored = HelmReleaseStore.ReadRecord(secret); + + Assert.Equal(record.Name, restored.Name); + Assert.Equal(record.Revision, restored.Revision); + Assert.Equal("sdk", restored.Labels!["team"]); + Assert.False(restored.Labels.ContainsKey("owner")); + } + + [Fact] + public void ReadRecord_ReportsMalformedPayloadWithSecretIdentity() + { + var secret = new V1Secret + { + Metadata = new V1ObjectMeta + { + Name = "sh.helm.release.v1.broken.v1", + NamespaceProperty = "broken-ns" + }, + Data = new Dictionary + { + ["release"] = Encoding.UTF8.GetBytes("not-base64") + } + }; + + var exception = Assert.Throws( + () => HelmReleaseStore.ReadRecord(secret)); + + Assert.Equal("sh.helm.release.v1.broken.v1", exception.SecretName); + Assert.Equal("broken-ns", exception.NamespaceName); + Assert.Equal("Helm v3 release", exception.Format); + Assert.Contains("Base64", exception.Message); + } + + [Fact] + public void ReadRecord_ReportsMissingPayload() + { + var secret = new V1Secret + { + Metadata = new V1ObjectMeta + { + Name = "missing", + NamespaceProperty = "test" + } + }; + + var exception = Assert.Throws( + () => HelmReleaseStore.ReadRecord(secret)); + + Assert.Contains("Neither data.release nor the legacy release.json key was present", exception.Message); + } + + [Fact] + public void BuildSecret_CreatesHelmV3SecretWithSystemAndCustomLabels() + { + var timestamp = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000); + var record = CreateRecord() with + { + Labels = new Dictionary + { + ["team"] = "sdk", + ["status"] = "forged" + } + }; + + var secret = HelmReleaseStore.BuildSecret(record, existing: null, timestamp); + + Assert.Equal("sh.helm.release.v1.fixture-release.v3", secret.Metadata.Name); + Assert.Equal("fixture-ns", secret.Metadata.NamespaceProperty); + Assert.Equal("helm.sh/release.v1", secret.Type); + Assert.Equal("helm", secret.Metadata.Labels["owner"]); + Assert.Equal("deployed", secret.Metadata.Labels["status"]); + Assert.Equal("3", secret.Metadata.Labels["version"]); + Assert.Equal("1700000000", secret.Metadata.Labels["createdAt"]); + Assert.False(secret.Metadata.Labels.ContainsKey("modifiedAt")); + Assert.Equal("sdk", secret.Metadata.Labels["team"]); + Assert.Equal(["release"], secret.Data.Keys); + Assert.Null(secret.StringData); + + using var decoded = DecodeLikeHelm(Encoding.UTF8.GetString(secret.Data["release"])); + Assert.Equal("fixture-release", decoded.RootElement.GetProperty("name").GetString()); + } + + [Fact] + public void BuildSecret_UpdateUsesModifiedAtAndPreservesResourceVersion() + { + var existing = new V1Secret + { + Metadata = new V1ObjectMeta + { + ResourceVersion = "42", + Labels = new Dictionary + { + ["createdAt"] = "1600000000", + ["team"] = "existing" + } + } + }; + + var secret = HelmReleaseStore.BuildSecret( + CreateRecord(), + existing, + DateTimeOffset.FromUnixTimeSeconds(1_700_000_000)); + + Assert.Equal("42", secret.Metadata.ResourceVersion); + Assert.Equal("1700000000", secret.Metadata.Labels["modifiedAt"]); + Assert.False(secret.Metadata.Labels.ContainsKey("createdAt")); + Assert.Equal("existing", secret.Metadata.Labels["team"]); + } + + + [Fact] + public void CreateChartSnapshot_MapsCompleteFreshHelmChartShape() + { + var chart = new HelmSharp.Chart.HelmChart + { + ApiVersion = "v2", + Name = "fresh-chart", + Version = "2.3.4", + AppVersion = "5.6.7", + Description = "Fresh chart", + Home = "https://example.test/fresh", + Icon = "https://example.test/icon.svg", + Sources = ["https://example.test/source"], + Keywords = ["sdk", "helm"], + Maintainers = + [ + new Dictionary + { + ["name"] = "Maintainer", + ["email"] = "maintainer@example.test" + } + ], + Type = "application", + Deprecated = true, + KubeVersion = ">=1.28.0", + Annotations = new Dictionary + { + ["example.test/annotation"] = "preserved" + }, + LockDigest = "sha256:lock", + LockGenerated = "2026-07-17T00:00:00Z", + ValuesYaml = "message: default\nnested:\n enabled: true\n" + }; + chart.Dependencies.Add(new HelmSharp.Chart.HelmChartDependency + { + Name = "child", + Version = "1.2.0", + Repository = "https://example.test/charts", + Condition = "child.enabled", + Tags = ["backend"], + Enabled = true, + ImportValues = ["exports.data"], + Alias = "child-alias" + }); + chart.LockEntries.Add(new HelmSharp.Chart.HelmChartLockEntry + { + Name = "child", + Version = "1.2.0", + Repository = "https://example.test/charts", + Digest = "sha256:child" + }); + chart.Templates["templates/configmap.yaml"] = "apiVersion: v1\nkind: ConfigMap\n"; + chart.Files["values.schema.json"] = Encoding.UTF8.GetBytes("{\"type\":\"object\"}"); + chart.Files["README.md"] = Encoding.UTF8.GetBytes("# Fresh chart\n"); + chart.Files["assets/blob.bin"] = [0x00, 0x7f, 0xff]; + chart.Files["Chart.lock"] = Encoding.UTF8.GetBytes("digest: sha256:lock\n"); + var child = new HelmSharp.Chart.HelmChart + { + ApiVersion = "v2", + Name = "child", + Version = "1.2.0", + ValuesYaml = "childDefault: true\n" + }; + child.Templates["templates/child.yaml"] = "apiVersion: v1\nkind: ConfigMap\n"; + child.Files["README.md"] = Encoding.UTF8.GetBytes("# Child chart\n"); + chart.Subcharts["child-alias"] = child; + chart.Crds.Add(new Dictionary + { + ["apiVersion"] = "apiextensions.k8s.io/v1", + ["kind"] = "CustomResourceDefinition", + ["metadata"] = new Dictionary + { + ["name"] = "widgets.example.test" + } + }); + + using var document = JsonDocument.Parse(HelmV3ReleaseCodec.CreateChartSnapshot(chart)); + var root = document.RootElement; + var metadata = root.GetProperty("metadata"); + + Assert.Equal("fresh-chart", metadata.GetProperty("name").GetString()); + Assert.Equal("https://example.test/fresh", metadata.GetProperty("home").GetString()); + Assert.Equal("https://example.test/source", metadata.GetProperty("sources")[0].GetString()); + Assert.Equal("sdk", metadata.GetProperty("keywords")[0].GetString()); + Assert.Equal( + "maintainer@example.test", + metadata.GetProperty("maintainers")[0].GetProperty("email").GetString()); + Assert.Equal("https://example.test/icon.svg", metadata.GetProperty("icon").GetString()); + Assert.True(metadata.GetProperty("deprecated").GetBoolean()); + Assert.Equal( + "preserved", + metadata.GetProperty("annotations").GetProperty("example.test/annotation").GetString()); + var dependency = metadata.GetProperty("dependencies")[0]; + Assert.Equal("child-alias", dependency.GetProperty("alias").GetString()); + Assert.Equal("exports.data", dependency.GetProperty("import-values")[0].GetString()); + + var lockElement = root.GetProperty("lock"); + Assert.Equal("sha256:lock", lockElement.GetProperty("digest").GetString()); + Assert.Equal("2026-07-17T00:00:00Z", lockElement.GetProperty("generated").GetString()); + Assert.Equal( + "sha256:child", + lockElement.GetProperty("dependencies")[0].GetProperty("digest").GetString()); + Assert.Equal( + Convert.ToBase64String(Encoding.UTF8.GetBytes(chart.Templates["templates/configmap.yaml"])), + root.GetProperty("templates")[0].GetProperty("data").GetString()); + Assert.Equal( + Convert.ToBase64String(chart.Files["values.schema.json"]), + root.GetProperty("schema").GetString()); + Assert.Equal("default", root.GetProperty("values").GetProperty("message").GetString()); + var childSnapshot = Assert.Single(root.GetProperty("dependencies").EnumerateArray()); + Assert.Equal("child", childSnapshot.GetProperty("metadata").GetProperty("name").GetString()); + Assert.True(childSnapshot.GetProperty("values").GetProperty("childDefault").GetBoolean()); + Assert.Single(childSnapshot.GetProperty("templates").EnumerateArray()); + Assert.Single(childSnapshot.GetProperty("files").EnumerateArray()); + var crd = Assert.Single(root.GetProperty("crds").EnumerateArray()); + Assert.Equal("crds/widgets.example.test.yaml", crd.GetProperty("name").GetString()); + Assert.Contains("CustomResourceDefinition", Encoding.UTF8.GetString(Convert.FromBase64String(crd.GetProperty("data").GetString()!))); + + var files = root.GetProperty("files").EnumerateArray().ToList(); + Assert.Equal(2, files.Count); + Assert.DoesNotContain(files, file => file.GetProperty("name").GetString() == "values.schema.json"); + Assert.DoesNotContain(files, file => file.GetProperty("name").GetString() == "Chart.lock"); + var binary = Assert.Single(files, file => file.GetProperty("name").GetString() == "assets/blob.bin"); + Assert.Equal(Convert.ToBase64String(chart.Files["assets/blob.bin"]), binary.GetProperty("data").GetString()); + } + + [Fact] + public void ReadRecord_ReadsOfficialHelmV3213RawSecretFixture() + { + // Generated with helm.sh/helm/v3 v3.21.3 official release/chart types. + var path = Path.Combine( + AppContext.BaseDirectory, + "Fixtures", + "Releases", + "helm-v3.21.3-release-secret.json"); + using var fixture = JsonDocument.Parse(File.ReadAllText(path)); + var root = fixture.RootElement; + var metadata = root.GetProperty("metadata"); + var outerData = Convert.FromBase64String(root.GetProperty("data").GetProperty("release").GetString()!); + var innerRelease = Encoding.UTF8.GetString(outerData); + Assert.StartsWith("H4sI", innerRelease); + + var labels = metadata.GetProperty("labels") + .EnumerateObject() + .ToDictionary(property => property.Name, property => property.Value.GetString()!); + var secret = new V1Secret + { + Metadata = new V1ObjectMeta + { + Name = metadata.GetProperty("name").GetString(), + NamespaceProperty = metadata.GetProperty("namespace").GetString(), + Labels = labels + }, + Type = root.GetProperty("type").GetString(), + Data = new Dictionary { ["release"] = outerData } + }; + + var record = HelmReleaseStore.ReadRecord(secret); + + Assert.Equal("fixture-release", record.Name); + Assert.Equal("fixture-ns", record.Namespace); + Assert.Equal(3, record.Revision); + Assert.Equal("deployed", record.Status); + Assert.Equal("fixture-chart", record.ChartName); + Assert.Equal("1.2.3", record.ChartVersion); + Assert.Equal("4.5.6", record.AppVersion); + Assert.Equal("Complete Helm v3.21.3 fixture chart", record.ChartDescription); + Assert.Equal("Install complete", record.Description); + Assert.Equal("Fixture notes from Helm v3.21.3\n", record.Notes); + Assert.Contains("message: override", record.ValuesYaml); + Assert.Contains("message: default", record.ChartValuesYaml); + Assert.DoesNotContain("fixture-hook", record.Manifest); + Assert.Equal("fixture", record.Labels!["environment"]); + Assert.Equal("sdk", record.Labels["team"]); + Assert.False(record.Labels.ContainsKey("owner")); + var hook = Assert.Single(record.Hooks); + Assert.Equal(["pre-install", "test"], hook.Events); + + using var chartDocument = JsonDocument.Parse(record.RawChartJson!); + var chart = chartDocument.RootElement; + Assert.Equal(2, chart.GetProperty("templates").GetArrayLength()); + Assert.Equal(2, chart.GetProperty("files").GetArrayLength()); + Assert.Equal("sha256:fixture-lock-digest", chart.GetProperty("lock").GetProperty("digest").GetString()); + Assert.Equal( + "true", + chart.GetProperty("metadata").GetProperty("annotations").GetProperty("example.test/preserved").GetString()); + + using var roundTrip = DecodeLikeHelm(HelmV3ReleaseCodec.Encode(record with { Status = "superseded" })); + var roundTripChart = roundTrip.RootElement.GetProperty("chart"); + Assert.Equal(2, roundTripChart.GetProperty("templates").GetArrayLength()); + Assert.Equal(2, roundTripChart.GetProperty("files").GetArrayLength()); + Assert.Equal( + "sha256:fixture-lock-digest", + roundTripChart.GetProperty("lock").GetProperty("digest").GetString()); + } + private static HelmReleaseRecord CreateRecord() + => new() + { + Name = "fixture-release", + Namespace = "fixture-ns", + Revision = 3, + Status = "deployed", + ChartName = "fixture-chart", + ChartVersion = "1.2.3", + AppVersion = "4.5.6", + ChartApiVersion = "v2", + ChartDescription = "Fixture chart", + ChartType = "application", + ChartKubeVersion = ">=1.25.0", + ChartValuesYaml = "message: default\nreplicaCount: 1\n", + ValuesYaml = "message: override\nreplicaCount: 2\n", + ComputedValuesYaml = "message: computed\nreplicaCount: 2\n", + Manifest = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: fixture-release\n", + FirstDeployedAt = DateTimeOffset.Parse("2026-07-17T01:02:03Z"), + UpdatedAt = DateTimeOffset.Parse("2026-07-17T01:03:04Z"), + Description = "Install complete", + Notes = "Fixture notes\n", + Hooks = + [ + new HelmReleaseHookRecord + { + Name = "fixture-hook", + Kind = "Job", + Path = "templates/hook.yaml", + Manifest = "apiVersion: batch/v1\nkind: Job\nmetadata:\n name: fixture-hook\n", + Events = ["pre-install"], + LastRunStartedAt = DateTimeOffset.Parse("2026-07-17T01:02:04Z"), + LastRunCompletedAt = DateTimeOffset.Parse("2026-07-17T01:02:05Z"), + LastRunPhase = "Succeeded", + Weight = -1, + DeletePolicies = ["before-hook-creation"], + OutputLogPolicies = ["hook-succeeded"] + } + ] + }; + + private static string FixturePath() + => Path.Combine( + AppContext.BaseDirectory, + "Fixtures", + "Releases", + "helm-v3.21.3-release.json"); + + private static string EncodeLikeHelm(string json, bool compress) + { + var bytes = Encoding.UTF8.GetBytes(json); + if (!compress) + return Convert.ToBase64String(bytes); + + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + gzip.Write(bytes); + return Convert.ToBase64String(output.ToArray()); + } + + private static JsonDocument DecodeLikeHelm(string encoded) + { + var bytes = Convert.FromBase64String(encoded); + using var compressed = new MemoryStream(bytes); + using var gzip = new GZipStream(compressed, CompressionMode.Decompress); + return JsonDocument.Parse(gzip); + } +}