From 2e59fdb7522ed0f2cb93d6ea7677f6f9b952740c Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Thu, 16 Jul 2026 20:51:19 -0700 Subject: [PATCH 1/7] feat(release): support Helm v3 release secrets Persist managed releases using Helm v3's encoded Secret payload while retaining legacy HelmSharp read compatibility and actionable diagnostics. Preserve chart, values, notes, labels, and hooks across lifecycle operations, backed by an official Helm v3.21.3 fixture and multi-target regression coverage. --- CHANGELOG.md | 5 +- src/HelmSharp.Action/HelmClient.cs | 174 ++++++- src/HelmSharp.Chart/HelmChartLoader.cs | 4 + src/HelmSharp.Chart/HelmValues.cs | 27 +- src/HelmSharp.Release/HelmReleaseRecord.cs | 42 ++ src/HelmSharp.Release/HelmReleaseStore.cs | 173 +++--- .../HelmReleaseStoreException.cs | 21 + .../HelmSharp.Release.csproj | 5 + src/HelmSharp.Release/HelmV3ReleaseCodec.cs | 379 ++++++++++++++ tests/HelmSharp.Tests/ChartOperationsTests.cs | 37 ++ .../Releases/helm-v3.21.3-release-secret.json | 21 + .../Releases/helm-v3.21.3-release.json | 81 +++ tests/HelmSharp.Tests/HelmHookTests.cs | 80 +++ .../HelmV3ReleaseStorageTests.cs | 492 ++++++++++++++++++ 14 files changed, 1468 insertions(+), 73 deletions(-) create mode 100644 src/HelmSharp.Release/HelmReleaseRecord.cs create mode 100644 src/HelmSharp.Release/HelmReleaseStoreException.cs create mode 100644 src/HelmSharp.Release/HelmV3ReleaseCodec.cs create mode 100644 tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release-secret.json create mode 100644 tests/HelmSharp.Tests/Fixtures/Releases/helm-v3.21.3-release.json create mode 100644 tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs 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..a32aa04 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,20 @@ 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), + 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 +329,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 +413,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 +497,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 +528,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 +538,19 @@ 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 + FirstDeployedAt = targetRecord.FirstDeployedAt, + UpdatedAt = deployedAt, + Description = "Rollback complete", + Notes = targetRecord.Notes, + Hooks = hooks.Select(ToReleaseHook).ToList() }, cancellationToken); await store.MarkStatusAsync(current, "superseded", cancellationToken); @@ -682,7 +733,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 +803,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 +1877,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..3c507b4 --- /dev/null +++ b/src/HelmSharp.Release/HelmReleaseRecord.cs @@ -0,0 +1,42 @@ +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; + 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..f38107e 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(); } @@ -128,29 +101,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 +227,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..bc1d48a --- /dev/null +++ b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs @@ -0,0 +1,379 @@ +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 + }; + + 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 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), + 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); + + 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 + }.ToJsonString(); + } + + 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 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/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..38b992d --- /dev/null +++ b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs @@ -0,0 +1,492 @@ +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.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("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 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"); + + 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 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", + 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); + } +} From 828122e73cf2b52081a81dd964bfec49fa1a8570 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Thu, 16 Jul 2026 22:59:52 -0700 Subject: [PATCH 2/7] fix(release): preserve computed values and notes Merge stored chart defaults with release overrides for --all values output and fall back to persisted Helm v3 release notes when chart resolution is unavailable. Add regression coverage for both paths. --- src/HelmSharp.Action/HelmClient.cs | 23 ++++++++++- .../HelmSharp.Tests/HelmClientReleaseTests.cs | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/HelmSharp.Tests/HelmClientReleaseTests.cs diff --git a/src/HelmSharp.Action/HelmClient.cs b/src/HelmSharp.Action/HelmClient.cs index a32aa04..73f4d18 100644 --- a/src/HelmSharp.Action/HelmClient.cs +++ b/src/HelmSharp.Action/HelmClient.cs @@ -651,7 +651,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( @@ -712,7 +712,26 @@ 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; + + 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( diff --git a/tests/HelmSharp.Tests/HelmClientReleaseTests.cs b/tests/HelmSharp.Tests/HelmClientReleaseTests.cs new file mode 100644 index 0000000..bf9f8f6 --- /dev/null +++ b/tests/HelmSharp.Tests/HelmClientReleaseTests.cs @@ -0,0 +1,41 @@ +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 GetStoredNotes_ReturnsReleaseNotesWhenPresent() + { + var record = new HelmReleaseRecord { Notes = "Installed successfully.\n" }; + + Assert.Equal("Installed successfully.\n", HelmClient.GetStoredNotes(record)); + } +} \ No newline at end of file From 2cef1321d2551f7c00fcb5d08075ad6bed233bb6 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Mon, 20 Jul 2026 22:50:55 -0700 Subject: [PATCH 3/7] fix(release): retain dependency-computed values Persist fully computed values alongside Helm v3 release config so all-values queries preserve enabled subchart defaults, imports, and propagated globals while config remains user overrides. --- src/HelmSharp.Action/HelmClient.cs | 5 ++++ src/HelmSharp.Release/HelmReleaseRecord.cs | 2 ++ src/HelmSharp.Release/HelmV3ReleaseCodec.cs | 5 ++++ .../HelmSharp.Tests/HelmClientReleaseTests.cs | 23 ++++++++++++++++++- .../HelmV3ReleaseStorageTests.cs | 2 ++ 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/HelmSharp.Action/HelmClient.cs b/src/HelmSharp.Action/HelmClient.cs index 73f4d18..9402bab 100644 --- a/src/HelmSharp.Action/HelmClient.cs +++ b/src/HelmSharp.Action/HelmClient.cs @@ -311,6 +311,7 @@ await store.SaveAsync(new HelmReleaseRecord 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"), @@ -546,6 +547,7 @@ await store.SaveAsync(new HelmReleaseRecord RawChartJson = targetRecord.RawChartJson, Manifest = mainManifest, ValuesYaml = targetRecord.ValuesYaml, + ComputedValuesYaml = targetRecord.ComputedValuesYaml, FirstDeployedAt = targetRecord.FirstDeployedAt, UpdatedAt = deployedAt, Description = "Rollback complete", @@ -721,6 +723,9 @@ internal static string GetStoredValuesYaml(HelmReleaseRecord record, bool allVal 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); diff --git a/src/HelmSharp.Release/HelmReleaseRecord.cs b/src/HelmSharp.Release/HelmReleaseRecord.cs index 3c507b4..d53e928 100644 --- a/src/HelmSharp.Release/HelmReleaseRecord.cs +++ b/src/HelmSharp.Release/HelmReleaseRecord.cs @@ -17,6 +17,8 @@ public sealed record HelmReleaseRecord 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; } diff --git a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs index bc1d48a..556af5a 100644 --- a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs +++ b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs @@ -33,6 +33,7 @@ public static string Encode(HelmReleaseRecord record) ["info"] = info, ["chart"] = chart, ["config"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(record.ValuesYaml)), + ["helmsharp_computed_values"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(record.ComputedValuesYaml)), ["manifest"] = record.Manifest, ["hooks"] = hooks, ["version"] = record.Revision, @@ -100,6 +101,9 @@ public static HelmReleaseRecord Decode(string encodedRelease) var values = root.TryGetProperty("config", out var config) && config.ValueKind == JsonValueKind.Object ? (Dictionary)JsonElementToObject(config)! : new Dictionary(StringComparer.Ordinal); + var computedValues = root.TryGetProperty("helmsharp_computed_values", out var computed) && computed.ValueKind == JsonValueKind.Object + ? (Dictionary)JsonElementToObject(computed)! + : new Dictionary(StringComparer.Ordinal); var chartValues = chart is { } chartValue && chartValue.TryGetProperty("values", out var defaults) && defaults.ValueKind == JsonValueKind.Object ? (Dictionary)JsonElementToObject(defaults)! : new Dictionary(StringComparer.Ordinal); @@ -124,6 +128,7 @@ public static HelmReleaseRecord Decode(string encodedRelease) RawChartJson = chart?.GetRawText(), Manifest = GetString(root, "manifest") ?? string.Empty, ValuesYaml = HelmYaml.Serialize(values), + ComputedValuesYaml = HelmYaml.Serialize(computedValues), FirstDeployedAt = firstDeployed, UpdatedAt = updatedAt, DeletedAt = deletedAt, diff --git a/tests/HelmSharp.Tests/HelmClientReleaseTests.cs b/tests/HelmSharp.Tests/HelmClientReleaseTests.cs index bf9f8f6..4ac3946 100644 --- a/tests/HelmSharp.Tests/HelmClientReleaseTests.cs +++ b/tests/HelmSharp.Tests/HelmClientReleaseTests.cs @@ -31,6 +31,27 @@ public void GetStoredValuesYaml_AllValues_MergesChartDefaultsWithOverrides() 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() { @@ -38,4 +59,4 @@ public void GetStoredNotes_ReturnsReleaseNotesWhenPresent() Assert.Equal("Installed successfully.\n", HelmClient.GetStoredNotes(record)); } -} \ No newline at end of file +} diff --git a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs index 38b992d..0f22e8e 100644 --- a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs +++ b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs @@ -70,6 +70,7 @@ public void Encode_ProducesHelmV3EnvelopeWithDistinctConfigAndChartValues() 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()); @@ -439,6 +440,7 @@ private static HelmReleaseRecord CreateRecord() 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"), From 41675c12a96a1e6468599ad4a1d1e836263b97a1 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Mon, 20 Jul 2026 22:58:29 -0700 Subject: [PATCH 4/7] fix(release): preserve subcharts and labels Serialize full child chart payloads in Helm v3 release snapshots and carry custom labels into rollback revisions. --- src/HelmSharp.Action/HelmClient.cs | 3 ++- src/HelmSharp.Release/HelmV3ReleaseCodec.cs | 12 ++++++++++-- .../HelmSharp.Tests/HelmV3ReleaseStorageTests.cs | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/HelmSharp.Action/HelmClient.cs b/src/HelmSharp.Action/HelmClient.cs index 9402bab..52e82b8 100644 --- a/src/HelmSharp.Action/HelmClient.cs +++ b/src/HelmSharp.Action/HelmClient.cs @@ -552,7 +552,8 @@ await store.SaveAsync(new HelmReleaseRecord UpdatedAt = deployedAt, Description = "Rollback complete", Notes = targetRecord.Notes, - Hooks = hooks.Select(ToReleaseHook).ToList() + Hooks = hooks.Select(ToReleaseHook).ToList(), + Labels = targetRecord.Labels }, cancellationToken); await store.MarkStatusAsync(current, "superseded", cancellationToken); diff --git a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs index 556af5a..24d1cd2 100644 --- a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs +++ b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs @@ -147,7 +147,11 @@ public static HelmReleaseRecord Decode(string encodedRelease) 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, @@ -201,8 +205,12 @@ internal static string CreateChartSnapshot(HelmChart chart) ["templates"] = templates, ["values"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(chart.ValuesYaml)), ["schema"] = schema.Value is null ? null : Convert.ToBase64String(schema.Value), - ["files"] = files - }.ToJsonString(); + ["files"] = files, + ["dependencies"] = new JsonArray(chart.Subcharts + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => CreateChartSnapshotNode(pair.Value)) + .ToArray()) + }; } private static JsonNode ToJsonDependency(HelmChartDependency dependency) diff --git a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs index 0f22e8e..59c836a 100644 --- a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs +++ b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs @@ -312,6 +312,16 @@ public void CreateChartSnapshot_MapsCompleteFreshHelmChartShape() 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; using var document = JsonDocument.Parse(HelmV3ReleaseCodec.CreateChartSnapshot(chart)); var root = document.RootElement; @@ -346,6 +356,11 @@ public void CreateChartSnapshot_MapsCompleteFreshHelmChartShape() 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 files = root.GetProperty("files").EnumerateArray().ToList(); Assert.Equal(2, files.Count); From 751abfa5084c35881e17d4b7e1e3c855b0be69aa Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Mon, 20 Jul 2026 23:08:35 -0700 Subject: [PATCH 5/7] fix(release): retain values and deletion time Render stored notes with computed release values and record the deletion timestamp on Helm v3 uninstall revisions. --- src/HelmSharp.Action/HelmClient.cs | 2 +- src/HelmSharp.Release/HelmReleaseStore.cs | 3 ++- tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs | 11 +++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/HelmSharp.Action/HelmClient.cs b/src/HelmSharp.Action/HelmClient.cs index 52e82b8..62f3403 100644 --- a/src/HelmSharp.Action/HelmClient.cs +++ b/src/HelmSharp.Action/HelmClient.cs @@ -703,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)) diff --git a/src/HelmSharp.Release/HelmReleaseStore.cs b/src/HelmSharp.Release/HelmReleaseStore.cs index f38107e..e7bc512 100644 --- a/src/HelmSharp.Release/HelmReleaseStore.cs +++ b/src/HelmSharp.Release/HelmReleaseStore.cs @@ -82,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"; diff --git a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs index 59c836a..50da84e 100644 --- a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs +++ b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs @@ -77,6 +77,17 @@ public void Encode_ProducesHelmV3EnvelopeWithDistinctConfigAndChartValues() 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() From fca968e34740d75b532ba42ac11e43333d367768 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Mon, 20 Jul 2026 23:17:55 -0700 Subject: [PATCH 6/7] fix(release): preserve crd payloads Serialize chart CRDs into Helm v3 release snapshots so consumers can recover installed CRD files. --- src/HelmSharp.Release/HelmV3ReleaseCodec.cs | 16 ++++++++++++++++ .../HelmSharp.Tests/HelmV3ReleaseStorageTests.cs | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs index 24d1cd2..7c475f9 100644 --- a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs +++ b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs @@ -206,6 +206,9 @@ private static JsonObject CreateChartSnapshotNode(HelmChart chart) ["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)) @@ -242,6 +245,19 @@ private static JsonNode ToJsonFile(string name, byte[] data) ["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; diff --git a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs index 50da84e..e8cae91 100644 --- a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs +++ b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs @@ -333,6 +333,15 @@ public void CreateChartSnapshot_MapsCompleteFreshHelmChartShape() 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; @@ -372,6 +381,9 @@ public void CreateChartSnapshot_MapsCompleteFreshHelmChartShape() 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); From 9b90dbc4388d48a03bb991ac8ddcb883246e7f89 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Mon, 20 Jul 2026 23:31:31 -0700 Subject: [PATCH 7/7] fix(release): retain missing computed value fallback Keep computed values empty for standard Helm release payloads that lack the HelmSharp extension. --- src/HelmSharp.Release/HelmV3ReleaseCodec.cs | 11 ++++++----- tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs index 7c475f9..67aef91 100644 --- a/src/HelmSharp.Release/HelmV3ReleaseCodec.cs +++ b/src/HelmSharp.Release/HelmV3ReleaseCodec.cs @@ -33,12 +33,13 @@ public static string Encode(HelmReleaseRecord record) ["info"] = info, ["chart"] = chart, ["config"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(record.ValuesYaml)), - ["helmsharp_computed_values"] = JsonSerializer.SerializeToNode(HelmYaml.DeserializeDictionary(record.ComputedValuesYaml)), ["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(); @@ -101,9 +102,9 @@ public static HelmReleaseRecord Decode(string encodedRelease) var values = root.TryGetProperty("config", out var config) && config.ValueKind == JsonValueKind.Object ? (Dictionary)JsonElementToObject(config)! : new Dictionary(StringComparer.Ordinal); - var computedValues = root.TryGetProperty("helmsharp_computed_values", out var computed) && computed.ValueKind == JsonValueKind.Object - ? (Dictionary)JsonElementToObject(computed)! - : 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); @@ -128,7 +129,7 @@ public static HelmReleaseRecord Decode(string encodedRelease) RawChartJson = chart?.GetRawText(), Manifest = GetString(root, "manifest") ?? string.Empty, ValuesYaml = HelmYaml.Serialize(values), - ComputedValuesYaml = HelmYaml.Serialize(computedValues), + ComputedValuesYaml = computedValuesYaml, FirstDeployedAt = firstDeployed, UpdatedAt = updatedAt, DeletedAt = deletedAt, diff --git a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs index e8cae91..f6c64ef 100644 --- a/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs +++ b/tests/HelmSharp.Tests/HelmV3ReleaseStorageTests.cs @@ -35,6 +35,7 @@ public void Decode_ReadsFixedHelmV3ReleaseShape() 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);