Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 191 additions & 14 deletions src/HelmSharp.Action/HelmClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ public async IAsyncEnumerable<string> 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)
{
Expand Down Expand Up @@ -289,6 +290,10 @@ public async IAsyncEnumerable<string> 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,
Expand All @@ -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),
Comment thread
GaTTGeng marked this conversation as resolved.
ComputedValuesYaml = HelmValues.ToYaml(values),
Comment thread
GaTTGeng marked this conversation as resolved.
FirstDeployedAt = firstDeployedAt,
UpdatedAt = deployedAt,
Description = request.Description ?? (isUpgrade ? "Upgrade complete" : "Install complete"),
Notes = renderer.RenderNotes(),
Hooks = hooks.Select(ToReleaseHook).ToList(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record hook execution results in release snapshots

When an install or upgrade contains hooks, this stores each hook through ToReleaseHook, which unconditionally writes LastRunPhase as Unknown and leaves both run timestamps unset. ExecuteHooksAsync does not mutate the hook objects, so every HelmSharp-created Helm v3 Secret reports unknown hook status even after its hooks have run; consequently Helm-compatible consumers such as helm get hooks cannot report the actual last-run result. Capture the start/completion times and succeeded/failed phase while executing hooks before persisting the release.

Useful? React with 👍 / 👎.

Labels = ResolveReleaseLabels(existingHistory, isUpgrade, request.Labels)
}, cancellationToken);

// Enforce max history
Expand All @@ -313,6 +330,31 @@ await store.SaveAsync(new HelmReleaseRecord
yield return $"Release {request.ReleaseName} revision {revision} deployed ({applied} resources)";
}

internal static Dictionary<string, string>? ResolveReleaseLabels(
IReadOnlyCollection<HelmReleaseRecord> history,
bool isUpgrade,
IDictionary<string, string>? requestedLabels)
{
var labels = new Dictionary<string, string>(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<HelmReleaseRecord> history)
{
Expand Down Expand Up @@ -372,7 +414,7 @@ public async Task<CommandResult> 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
Expand Down Expand Up @@ -456,8 +498,7 @@ public async Task<CommandResult> 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();
Expand Down Expand Up @@ -488,6 +529,7 @@ public async Task<CommandResult> RollbackAsync(
}

var newRevision = await store.NextRevisionAsync(releaseName, ns, cancellationToken);
var deployedAt = DateTimeOffset.UtcNow;
await store.SaveAsync(new HelmReleaseRecord
{
Name = releaseName,
Expand All @@ -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);
Expand Down Expand Up @@ -600,7 +654,7 @@ public async Task<CommandResult> 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<CommandResult> GetManifestAsync(
Expand Down Expand Up @@ -649,7 +703,7 @@ public async Task<CommandResult> 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))
Expand All @@ -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));
Comment thread
GaTTGeng marked this conversation as resolved.
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<CommandResult> GetHooksAsync(
Expand All @@ -682,7 +758,7 @@ public async Task<CommandResult> 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.");

Expand Down Expand Up @@ -752,7 +828,7 @@ public async Task<CommandResult> 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)
Expand Down Expand Up @@ -1826,6 +1902,107 @@ private static void ValidateUpgradeRequest(HelmUpgradeInstallRequest request)
throw new ArgumentException("Chart is required.", nameof(request));
}

internal static (string MainManifest, List<HelmHook> 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<k8s.Kubernetes> CreateKubernetesClientAsync(
HelmExecutionOptions options,
string? requestKubeConfigPath,
Expand Down
4 changes: 4 additions & 0 deletions src/HelmSharp.Chart/HelmChartLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public sealed class HelmChart
public Dictionary<string, object?>? Annotations { get; set; }
public List<HelmChartDependency> Dependencies { get; } = new();
public List<HelmChartLockEntry> LockEntries { get; } = new();
public string? LockDigest { get; set; }
public string? LockGenerated { get; set; }
public string ValuesYaml { get; init; } = string.Empty;
public Dictionary<string, string> Templates { get; } = new(StringComparer.Ordinal);
public Dictionary<string, byte[]> Files { get; } = new(StringComparer.Ordinal);
Expand Down Expand Up @@ -80,6 +82,8 @@ private static async Task<HelmChart> 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<object?> lockDepsList)
{
foreach (var lockDep in lockDepsList)
Expand Down
27 changes: 26 additions & 1 deletion src/HelmSharp.Chart/HelmValues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ public static class HelmValues
Dictionary<string, string>? setStringValues,
Dictionary<string, string>? setJsonValues,
CancellationToken cancellationToken)
{
var overrides = await BuildOverridesAsync(
valuesFiles,
valuesContent,
setValues,
setFileValues,
setStringValues,
setJsonValues,
cancellationToken);
return BuildFromOverrides(chart, overrides);
}

internal static async Task<Dictionary<string, object?>> BuildOverridesAsync(
IEnumerable<string>? valuesFiles,
string? valuesContent,
Dictionary<string, string>? setValues,
Dictionary<string, string>? setFileValues,
Dictionary<string, string>? setStringValues,
Dictionary<string, string>? setJsonValues,
CancellationToken cancellationToken)
{
var overrides = new Dictionary<string, object?>(StringComparer.Ordinal);

Expand Down Expand Up @@ -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<string, object?> BuildFromOverrides(
HelmChart chart,
Dictionary<string, object?> overrides)
=> ProcessOverrides(chart, overrides, out _);

internal static Dictionary<string, object?> PrepareForRender(
HelmChart chart,
Dictionary<string, object?> values,
Expand Down
Loading