File: Deployment\HelmDeploymentEngine.cs
Web Access
Project: src\src\Aspire.Hosting.Kubernetes\Aspire.Hosting.Kubernetes.csproj (Aspire.Hosting.Kubernetes)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
#pragma warning disable ASPIREPIPELINES002
 
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Kubernetes.Extensions;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Aspire.Hosting.Yaml;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Kubernetes;
 
/// <summary>
/// Provides the Helm deployment engine that creates pipeline steps for deploying
/// Aspire applications to Kubernetes using Helm charts.
/// </summary>
internal static partial class HelmDeploymentEngine
{
    private const string HelmDeployTag = "helm-deploy";
    private const string HelmUninstallTag = "helm-uninstall";
    internal const string PrintSummaryTag = "print-summary";
 
    /// <summary>
    /// Gets the environment-specific values file name, mirroring Docker Compose's .env.{envName} pattern.
    /// </summary>
    internal static string GetDeployValuesFileName(string environmentName) => $"values.{environmentName}.yaml";
 
    /// <summary>
    /// Resolves the Helm release name. Uses the explicit annotation if set,
    /// otherwise falls back to the deployment environment name (<c>aspire deploy -e name</c>)
    /// lowercased for Helm compatibility.
    /// </summary>
    private static async Task<string> ResolveReleaseNameAsync(
        PipelineStepContext context,
        KubernetesEnvironmentResource environment)
    {
        if (environment.TryGetLastAnnotation<HelmReleaseNameAnnotation>(out var releaseAnnotation))
        {
            var resolvedRelease = await releaseAnnotation.ReleaseName.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
            if (!string.IsNullOrEmpty(resolvedRelease))
            {
                ValidateHelmReleaseName(resolvedRelease);
                return resolvedRelease;
            }
        }
 
        // Default to the deployment environment name (aspire deploy -e <name>), not the resource name.
        var hostEnvironment = context.Services.GetRequiredService<IHostEnvironment>();
        var releaseName = hostEnvironment.EnvironmentName.ToLowerInvariant();
        ValidateHelmReleaseName(releaseName);
        return releaseName;
    }
 
    /// <summary>
    /// Resolves the target Kubernetes namespace from the annotation, defaulting to "default".
    /// </summary>
    private static async Task<string> ResolveNamespaceAsync(
        PipelineStepContext context,
        KubernetesEnvironmentResource environment)
    {
        if (environment.TryGetLastAnnotation<KubernetesNamespaceAnnotation>(out var nsAnnotation))
        {
            var resolvedNs = await nsAnnotation.Namespace.GetValueAsync(context.CancellationToken).ConfigureAwait(false);
            if (!string.IsNullOrEmpty(resolvedNs))
            {
                ValidateKubernetesNamespace(resolvedNs);
                return resolvedNs;
            }
        }
 
        return "default";
    }
 
    private const int HelmReleaseNameMaxLength = 53;
    private const int KubernetesNamespaceMaxLength = 63;
 
    private static void ValidateHelmReleaseName(string releaseName)
    {
        if (releaseName.Length > HelmReleaseNameMaxLength || !DnsLabelPattern().IsMatch(releaseName))
        {
            throw new InvalidOperationException(
                $"Helm release name '{releaseName}' is invalid. Use lowercase letters, numbers, and hyphens, " +
                $"start and end with an alphanumeric character, and stay within {HelmReleaseNameMaxLength} characters. " +
                "Set an explicit release name with .WithHelm(h => h.WithReleaseName(\"my-release\")).");
        }
    }
 
    private static void ValidateKubernetesNamespace(string @namespace)
    {
        if (@namespace.Length > KubernetesNamespaceMaxLength || !DnsLabelPattern().IsMatch(@namespace))
        {
            throw new InvalidOperationException(
                $"Kubernetes namespace '{@namespace}' is invalid. Use lowercase letters, numbers, and hyphens, " +
                $"start and end with an alphanumeric character, and stay within {KubernetesNamespaceMaxLength} characters. " +
                "Set an explicit namespace with .WithHelm(h => h.WithNamespace(\"my-namespace\")).");
        }
    }
 
    [GeneratedRegex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")]
    private static partial Regex DnsLabelPattern();
 
    /// <summary>
    /// Creates the deployment pipeline steps for the Helm engine.
    /// </summary>
    internal static Task<IReadOnlyList<PipelineStep>> CreateStepsAsync(
        KubernetesEnvironmentResource environment,
        PipelineStepFactoryContext factoryContext)
    {
        var model = factoryContext.PipelineContext.Model;
        var steps = new List<PipelineStep>();
 
        // Step 0: Check prerequisites — verify Helm CLI is available and meets the
        // minimum supported version. Doing this once per environment, before any
        // helm invocation in either the main chart deploy or AddHelmChart(...) flows,
        // turns confusing low-level errors (unknown-flag, deprecated-flag, raw
        // spawn errors) into a single actionable message.
        //
        // The validator drives everything through IHelmRunner: a missing binary
        // surfaces as a spawn failure that the validator wraps with the same
        // "install Helm" hint, so we deliberately don't do a separate
        // PathLookupHelper probe here. That also lets tests inject a fake runner
        // without needing real Helm on PATH.
        var checkPrereqStep = new PipelineStep
        {
            Name = $"check-helm-prereqs-{environment.Name}",
            Description = $"Verifies Helm CLI is available for {environment.Name}.",
            Action = async ctx =>
            {
                var helmRunner = ctx.Services.GetRequiredService<IHelmRunner>();
                await HelmVersionValidator.EnsureMinimumVersionAsync(helmRunner, ctx.CancellationToken).ConfigureAwait(false);
            }
        };
        steps.Add(checkPrereqStep);
 
        // Step 1: Prepare - resolve values.yaml with actual image references and parameter values
        var prepareStep = new PipelineStep
        {
            Name = $"prepare-{environment.Name}",
            Description = $"Prepares Helm chart values for {environment.Name}.",
            Action = ctx => PrepareAsync(ctx, environment)
        };
        prepareStep.DependsOn(WellKnownPipelineSteps.Publish);
        prepareStep.DependsOn(WellKnownPipelineSteps.Build);
        prepareStep.DependsOn($"check-helm-prereqs-{environment.Name}");
        steps.Add(prepareStep);
 
        // Step 2: Helm deploy - run helm upgrade --install
        var helmDeployStep = new PipelineStep
        {
            Name = $"helm-deploy-{environment.Name}",
            Description = $"Deploys {environment.Name} to Kubernetes via Helm.",
            Tags = [HelmDeployTag],
            Action = ctx => HelmDeployAsync(ctx, environment)
        };
        helmDeployStep.DependsOn($"prepare-{environment.Name}");
        helmDeployStep.RequiredBy(WellKnownPipelineSteps.Deploy);
        steps.Add(helmDeployStep);
 
        // Step 3: Print deployment instructions (dashboard access, Helm commands)
        var instructionsStep = new PipelineStep
        {
            Name = $"print-{environment.Name}-instructions",
            Description = $"Prints access instructions for {environment.Name}.",
            Tags = [PrintSummaryTag],
            Action = ctx => PrintDeploymentInstructionsAsync(ctx, environment)
        };
        instructionsStep.DependsOn($"helm-deploy-{environment.Name}");
        instructionsStep.RequiredBy(WellKnownPipelineSteps.Deploy);
        steps.Add(instructionsStep);
 
        // Step 4: Destroy confirmation + uninstall (used by aspire destroy)
        var helmDestroyStep = new PipelineStep
        {
            Name = $"destroy-helm-{environment.Name}",
            Description = $"Confirms and destroys the Helm deployment for {environment.Name}.",
            Action = async ctx =>
            {
                // Check deployment state to verify this environment was actually deployed
                var deploymentStateManager = ctx.Services.GetRequiredService<IDeploymentStateManager>();
                var stateSection = await deploymentStateManager.AcquireSectionAsync($"Helm:{environment.Name}", ctx.CancellationToken).ConfigureAwait(false);
                var savedReleaseName = stateSection.Data["ReleaseName"]?.ToString();
                var savedNamespace = stateSection.Data["Namespace"]?.ToString();
 
                if (string.IsNullOrEmpty(savedReleaseName))
                {
                    await ctx.ReportingStep.CompleteAsync(
                        $"No Helm deployment state found for '{environment.Name}'. Nothing to destroy.",
                        CompletionState.Completed,
                        ctx.CancellationToken).ConfigureAwait(false);
                    return;
                }
 
                // Use saved state for the confirmation message (more accurate than recomputing)
                var @namespace = savedNamespace ?? "default";
                await ConfirmDestroyAsync(ctx, $"Uninstall Helm release '{savedReleaseName}' from namespace '{@namespace}'? This action cannot be undone.").ConfigureAwait(false);
 
                var helmRunner = ctx.Services.GetRequiredService<IHelmRunner>();
                // Defer the prereq check until state exists so `aspire destroy` against a
                // never-deployed environment can still report "Nothing to destroy" without
                // requiring Helm on PATH.
                await HelmVersionValidator.EnsureMinimumVersionAsync(helmRunner, ctx.CancellationToken).ConfigureAwait(false);
                await HelmUninstallAsync(ctx, environment, savedReleaseName, @namespace).ConfigureAwait(false);
 
                ctx.Summary.Add("🗑️ Helm Release", savedReleaseName);
                ctx.Summary.Add("☸️ Namespace", @namespace);
 
                // Clean up deployment state for this environment
                await deploymentStateManager.DeleteSectionAsync(stateSection, ctx.CancellationToken).ConfigureAwait(false);
            },
            DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq]
        };
        helmDestroyStep.RequiredBy(WellKnownPipelineSteps.Destroy);
        steps.Add(helmDestroyStep);
 
        // Step 5: Helm uninstall (teardown, callable directly via aspire do without confirmation)
        var helmUninstallStep = new PipelineStep
        {
            Name = $"helm-uninstall-{environment.Name}",
            Description = $"Uninstalls the Helm release for {environment.Name}.",
            Tags = [HelmUninstallTag],
            Action = ctx => HelmUninstallAsync(ctx, environment)
        };
        helmUninstallStep.DependsOn($"check-helm-prereqs-{environment.Name}");
        steps.Add(helmUninstallStep);
 
        return Task.FromResult<IReadOnlyList<PipelineStep>>(steps);
    }
 
    private static async Task PrepareAsync(PipelineStepContext context, KubernetesEnvironmentResource environment)
    {
        var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
        var valuesFilePath = Path.Combine(outputPath, "values.yaml");
 
        if (!File.Exists(valuesFilePath))
        {
            context.Logger.LogDebug("No values.yaml found at {Path}, skipping prepare step.", valuesFilePath);
            return;
        }
 
        var prepareTask = await context.ReportingStep.CreateTaskAsync(
            new MarkdownString($"Preparing Helm chart values for **{environment.Name}**"),
            context.CancellationToken).ConfigureAwait(false);
 
        await using (prepareTask.ConfigureAwait(false))
        {
            try
            {
                // Resolve captured parameter/secret values and write a deploy override file.
                // During publish, secrets and parameters without defaults are written as empty
                // placeholders in values.yaml. During deploy, we resolve them and provide the
                // actual values via a separate override file passed to helm.
                await ResolveAndWriteDeployValuesAsync(outputPath, environment, context.CancellationToken).ConfigureAwait(false);
 
                await prepareTask.CompleteAsync(
                    new MarkdownString($"Helm chart values prepared for **{environment.Name}**"),
                    CompletionState.Completed,
                    context.CancellationToken).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                await prepareTask.CompleteAsync(
                    $"Failed to prepare Helm chart values: {ex.Message}",
                    CompletionState.CompletedWithError,
                    context.CancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    /// <summary>
    /// Resolves captured parameter/secret values, cross-resource references, and container image references
    /// from the publish step, then writes an environment-specific values override file for use during helm upgrade --install.
    /// </summary>
    internal static async Task ResolveAndWriteDeployValuesAsync(
        string outputPath,
        KubernetesEnvironmentResource environment,
        CancellationToken cancellationToken)
    {
        if (environment.CapturedHelmValues.Count == 0
            && environment.CapturedHelmCrossReferences.Count == 0
            && environment.CapturedHelmImageReferences.Count == 0
            && environment.CapturedHelmValueProviders.Count == 0)
        {
            return;
        }
 
        // Build the override structure: { section: { resourceKey: { valueKey: resolvedValue } } }
        var overrideValues = new Dictionary<string, Dictionary<string, Dictionary<string, object>>>();
 
        // Phase 1: Resolve direct ParameterResource values
        // Also build a flat lookup for cross-reference substitution: "section.resourceKey.valueKey" → resolvedValue
        var resolvedLookup = new Dictionary<string, string>();
 
        foreach (var captured in environment.CapturedHelmValues)
        {
            var resolvedValue = await captured.Parameter.GetValueAsync(cancellationToken).ConfigureAwait(false);
            if (resolvedValue is null)
            {
                continue;
            }
 
            SetOverrideValue(overrideValues, captured.Section, captured.ResourceKey, captured.ValueKey, resolvedValue);
            resolvedLookup[$"{captured.Section}.{captured.ResourceKey}.{captured.ValueKey}"] = resolvedValue;
        }
 
        // Phase 2: Resolve cross-resource secret references by substituting Helm expressions
        // in the template value with values resolved in Phase 1.
        foreach (var crossRef in environment.CapturedHelmCrossReferences)
        {
            var resolvedValue = ResolveHelmExpressions(crossRef.TemplateValue, resolvedLookup);
            SetOverrideValue(overrideValues, crossRef.Section, crossRef.ResourceKey, crossRef.ValueKey, resolvedValue);
        }
 
        // Phase 3: Resolve container image references with registry-prefixed names.
        // During publish, images are written as "server:latest". At deploy time, we resolve
        // the full image name including the container registry (e.g., "myregistry.azurecr.io/server:latest")
        // using the same ContainerImageReference pattern as Docker Compose.
        foreach (var imageRef in environment.CapturedHelmImageReferences)
        {
            IValueProvider cir = new ContainerImageReference(imageRef.Resource);
            var resolvedImage = await cir.GetValueAsync(cancellationToken).ConfigureAwait(false);
            if (resolvedImage is not null)
            {
                SetOverrideValue(overrideValues, imageRef.Section, imageRef.ResourceKey, imageRef.ValueKey, resolvedImage);
            }
        }
 
        // Phase 4: Resolve generic IValueProvider references.
        // During publish, values backed by IValueProvider (e.g., Bicep output references,
        // connection strings) are written as empty placeholders. At deploy time, we call
        // GetValueAsync() to resolve the actual values from external sources.
        // This is cloud-provider agnostic — any IValueProvider implementation works.
        foreach (var valueProviderRef in environment.CapturedHelmValueProviders)
        {
            var resolvedValue = await valueProviderRef.ValueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false);
            if (resolvedValue is not null)
            {
                SetOverrideValue(overrideValues, valueProviderRef.Section, valueProviderRef.ResourceKey, valueProviderRef.ValueKey, resolvedValue);
            }
        }
 
        if (overrideValues.Count > 0)
        {
            var serializer = new YamlDotNet.Serialization.SerializerBuilder()
                // Parameter values are strings. Quote them so Helm does not reinterpret values such
                // as "01", "1.0", or "True" as numeric or boolean YAML scalars.
                .WithEventEmitter(nextEmitter => new ForceQuotedStringsEventEmitter(nextEmitter))
                .WithNewLine("\n")
                .Build();
            var overrideContent = serializer.Serialize(overrideValues);
            var overrideFilePath = Path.Combine(outputPath, GetDeployValuesFileName(environment.Name));
            await File.WriteAllTextAsync(overrideFilePath, overrideContent, cancellationToken).ConfigureAwait(false);
        }
    }
 
    private static void SetOverrideValue(
        Dictionary<string, Dictionary<string, Dictionary<string, object>>> overrideValues,
        string section, string resourceKey, string valueKey, object value)
    {
        if (!overrideValues.TryGetValue(section, out var sectionDict))
        {
            sectionDict = [];
            overrideValues[section] = sectionDict;
        }
 
        if (!sectionDict.TryGetValue(resourceKey, out var resourceValues))
        {
            resourceValues = [];
            sectionDict[resourceKey] = resourceValues;
        }
 
        resourceValues[valueKey] = value;
    }
 
    /// <summary>
    /// Substitutes Helm value expressions (e.g., <c>{{ .Values.secrets.cache.password }}</c>) in a template
    /// string with resolved values from the lookup dictionary.
    /// </summary>
    internal static string ResolveHelmExpressions(string template, Dictionary<string, string> resolvedLookup)
    {
        // Match Helm expressions like {{ .Values.secrets.cache.password }} or {{ .Values.config.myapp.key }}
        return HelmValuesExpressionRegex().Replace(template, match =>
        {
            var path = match.Groups[1].Value.Trim();
 
            // Path is like ".Values.secrets.cache.password" → normalize to "secrets.cache.password"
            if (path.StartsWith(".Values.", StringComparison.Ordinal))
            {
                path = path[".Values.".Length..];
            }
 
            // Convert to the same key format used in resolvedLookup (underscore-based)
            path = path.Replace("-", "_");
 
            return resolvedLookup.TryGetValue(path, out var resolved) ? resolved : match.Value;
        });
    }
 
    [GeneratedRegex(@"\{\{\s*(\.Values\.[a-zA-Z0-9_.]+)\s*\}\}")]
    private static partial Regex HelmValuesExpressionRegex();
 
    private static async Task HelmDeployAsync(PipelineStepContext context, KubernetesEnvironmentResource environment)
    {
        var outputPath = PublishingContextUtils.GetEnvironmentOutputPath(context, environment);
 
        var @namespace = await ResolveNamespaceAsync(context, environment).ConfigureAwait(false);
        var releaseName = await ResolveReleaseNameAsync(context, environment).ConfigureAwait(false);
 
        var deployTask = await context.ReportingStep.CreateTaskAsync(
            new MarkdownString($"Deploying **{environment.Name}** to Kubernetes namespace **{@namespace}** as Helm release **{releaseName}**"),
            context.CancellationToken).ConfigureAwait(false);
 
        await using (deployTask.ConfigureAwait(false))
        {
            try
            {
                var helmRunner = context.Services.GetRequiredService<IHelmRunner>();
 
                // Helm presence + version validation already ran in
                // check-helm-prereqs-{env}, which is a transitive predecessor of this
                // step. No need to re-verify here.
 
                var valuesFilePath = Path.Combine(outputPath, "values.yaml");
                var arguments = new StringBuilder();
                arguments.Append(CultureInfo.InvariantCulture, $"upgrade --install {releaseName} \"{outputPath}\"");
                arguments.Append(CultureInfo.InvariantCulture, $" --namespace {@namespace}");
                arguments.Append(" --create-namespace");
                arguments.Append(" --wait");
 
                if (environment.KubeConfigPath is not null)
                {
                    arguments.Append(CultureInfo.InvariantCulture, $" --kubeconfig \"{environment.KubeConfigPath}\"");
                }
 
                if (File.Exists(valuesFilePath))
                {
                    arguments.Append(CultureInfo.InvariantCulture, $" -f \"{valuesFilePath}\"");
                }
 
                var deployValuesFilePath = Path.Combine(outputPath, GetDeployValuesFileName(environment.Name));
                if (File.Exists(deployValuesFilePath))
                {
                    arguments.Append(CultureInfo.InvariantCulture, $" -f \"{deployValuesFilePath}\"");
                }
 
                context.Logger.LogDebug("Running helm {Arguments}", arguments);
 
                var stderrBuilder = new StringBuilder();
 
                var exitCode = await helmRunner.RunAsync(
                    arguments.ToString(),
                    workingDirectory: outputPath,
                    onOutputData: output => context.Logger.LogDebug("helm (stdout): {Output}", output),
                    onErrorData: error =>
                    {
                        stderrBuilder.AppendLine(error);
                        context.Logger.LogDebug("helm (stderr): {Error}", error);
                    },
                    cancellationToken: context.CancellationToken).ConfigureAwait(false);
 
                if (exitCode != 0)
                {
                    var errorOutput = stderrBuilder.ToString().Trim();
                    var message = string.IsNullOrEmpty(errorOutput)
                        ? $"helm upgrade --install failed with exit code {exitCode}"
                        : $"helm upgrade --install failed: {errorOutput}";
 
                    throw new InvalidOperationException(message);
                }
                else
                {
                    // Persist deployment state so destroy can find the release
                    var deploymentStateManager = context.Services.GetRequiredService<IDeploymentStateManager>();
                    var stateSection = await deploymentStateManager.AcquireSectionAsync($"Helm:{environment.Name}", context.CancellationToken).ConfigureAwait(false);
                    stateSection.Data["ReleaseName"] = releaseName;
                    stateSection.Data["Namespace"] = @namespace;
                    await deploymentStateManager.SaveSectionAsync(stateSection, context.CancellationToken).ConfigureAwait(false);
 
                    await deployTask.CompleteAsync(
                        new MarkdownString($"Helm release **{releaseName}** deployed to namespace **{@namespace}**"),
                        CompletionState.Completed,
                        context.CancellationToken).ConfigureAwait(false);
                }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                await deployTask.CompleteAsync(
                    $"Helm deployment failed: {ex.Message}",
                    CompletionState.CompletedWithError,
                    context.CancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    internal static async Task PrintResourceSummaryAsync(
        PipelineStepContext context,
        KubernetesEnvironmentResource environment,
        IResource computeResource,
        KubernetesResource k8sResource)
    {
        // Only print summaries for resources with external-facing services
        if (k8sResource.Service is null)
        {
            return;
        }
 
        var @namespace = await ResolveNamespaceAsync(context, environment).ConfigureAwait(false);
 
        try
        {
            var endpoints = await GetServiceEndpointsAsync(computeResource.Name.ToServiceName(), @namespace, environment.KubeConfigPath, context.Logger, context.CancellationToken).ConfigureAwait(false);
 
            if (endpoints.Count > 0)
            {
                var endpointText = string.Join(", ", endpoints.Select(e => $"[{e}]({e})"));
                context.Summary.Add(computeResource.Name, endpointText);
                context.Logger.LogInformation("Resource {ResourceName}: {Endpoints}", computeResource.Name, endpointText);
            }
            else
            {
                context.Logger.LogDebug("No external endpoints found for {ResourceName}", computeResource.Name);
            }
        }
        catch (Exception ex)
        {
            context.Logger.LogWarning(ex, "Failed to retrieve endpoints for {ResourceName}", computeResource.Name);
        }
    }
 
    private static async Task PrintDeploymentInstructionsAsync(
        PipelineStepContext context,
        KubernetesEnvironmentResource environment)
    {
        var @namespace = await ResolveNamespaceAsync(context, environment).ConfigureAwait(false);
        var releaseName = await ResolveReleaseNameAsync(context, environment).ConfigureAwait(false);
 
        // Dashboard port-forward instructions
        if (environment.DashboardEnabled && environment.Dashboard?.Resource is KubernetesAspireDashboardResource)
        {
            var dashboardServiceName = environment.Dashboard.Resource.Name.ToKubernetesResourceName() + "-service";
            context.Summary.Add(
                "📊 Dashboard",
                new MarkdownString($"`kubectl port-forward -n {@namespace} svc/{dashboardServiceName} 18888:18888` then open [http://localhost:18888](http://localhost:18888)"));
 
            var dashboardDeploymentName = environment.Dashboard.Resource.Name.ToKubernetesResourceName();
            context.Summary.Add(
                "🔑 Dashboard login",
                new MarkdownString($"`kubectl logs -n {@namespace} -l app.kubernetes.io/component={dashboardDeploymentName} --tail=50` to retrieve the login token"));
        }
 
        // Helm status and resource inspection
        context.Summary.Add(
            "📋 Release status",
            new MarkdownString($"`helm status {releaseName} -n {@namespace}`"));
 
        context.Summary.Add(
            "📦 View resources",
            new MarkdownString($"`kubectl get all -n {@namespace} -l app.kubernetes.io/instance={releaseName}`"));
 
        // Helm uninstall
        context.Summary.Add(
            "🗑️ Uninstall",
            new MarkdownString($"`helm uninstall {releaseName} -n {@namespace}`"));
    }
 
    private static async Task HelmUninstallAsync(PipelineStepContext context, KubernetesEnvironmentResource environment)
    {
        var @namespace = await ResolveNamespaceAsync(context, environment).ConfigureAwait(false);
        var releaseName = await ResolveReleaseNameAsync(context, environment).ConfigureAwait(false);
        await HelmUninstallAsync(context, environment, releaseName, @namespace).ConfigureAwait(false);
    }
 
    private static async Task HelmUninstallAsync(PipelineStepContext context, KubernetesEnvironmentResource environment, string releaseName, string @namespace)
    {
        var uninstallTask = await context.ReportingStep.CreateTaskAsync(
            new MarkdownString($"Uninstalling Helm release **{releaseName}** from namespace **{@namespace}**"),
            context.CancellationToken).ConfigureAwait(false);
 
        await using (uninstallTask.ConfigureAwait(false))
        {
            try
            {
                var helmRunner = context.Services.GetRequiredService<IHelmRunner>();
                var arguments = $"uninstall {releaseName} --namespace {@namespace}";
 
                if (environment.KubeConfigPath is not null)
                {
                    arguments += $" --kubeconfig \"{environment.KubeConfigPath}\"";
                }
 
                context.Logger.LogDebug("Running helm {Arguments}", arguments);
 
                var exitCode = await helmRunner.RunAsync(
                    arguments,
                    onOutputData: output => context.Logger.LogDebug("helm (stdout): {Output}", output),
                    onErrorData: error => context.Logger.LogDebug("helm (stderr): {Error}", error),
                    cancellationToken: context.CancellationToken).ConfigureAwait(false);
 
                if (exitCode != 0)
                {
                    throw new InvalidOperationException($"helm uninstall failed with exit code {exitCode}");
                }
                else
                {
                    await uninstallTask.CompleteAsync(
                        new MarkdownString($"Helm release **{releaseName}** uninstalled from namespace **{@namespace}**"),
                        CompletionState.Completed,
                        context.CancellationToken).ConfigureAwait(false);
                }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                await uninstallTask.CompleteAsync(
                    $"Helm uninstall failed: {ex.Message}",
                    CompletionState.CompletedWithError,
                    context.CancellationToken).ConfigureAwait(false);
                throw;
            }
        }
    }
 
    private static async Task ConfirmDestroyAsync(PipelineStepContext context, string message)
    {
        var options = context.Services.GetRequiredService<IOptions<PipelineOptions>>();
 
        if (!options.Value.SkipConfirmation)
        {
            var interactionService = context.Services.GetRequiredService<IInteractionService>();
 
            if (!interactionService.IsAvailable)
            {
                throw new InvalidOperationException(
                    "Cannot perform destructive operation without confirmation. Use --yes to skip the confirmation prompt in non-interactive mode.");
            }
 
            var result = await interactionService.PromptNotificationAsync(
                "Destroy environment",
                message,
                new NotificationInteractionOptions
                {
                    Intent = MessageIntent.Confirmation,
                    ShowSecondaryButton = true,
                    ShowDismiss = false,
                    PrimaryButtonText = "Destroy",
                    SecondaryButtonText = "Cancel"
                },
                context.CancellationToken).ConfigureAwait(false);
 
            if (result.Canceled || !result.Data)
            {
                context.Logger.LogInformation("User canceled the destroy operation.");
                throw new OperationCanceledException("Destroy operation canceled by user.");
            }
        }
    }
 
    private static async Task<List<string>> GetServiceEndpointsAsync(
        string serviceName,
        string @namespace,
        string? kubeConfigPath,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        var endpoints = new List<string>();
 
        var arguments = $"get service {serviceName} --namespace {@namespace} -o json";
 
        if (kubeConfigPath is not null)
        {
            arguments += $" --kubeconfig \"{kubeConfigPath}\"";
        }
        var stdoutBuilder = new StringBuilder();
 
        var spec = new ProcessSpec("kubectl")
        {
            Arguments = arguments,
            ThrowOnNonZeroReturnCode = false,
            InheritEnv = true,
            OnOutputData = output => stdoutBuilder.AppendLine(output),
            OnErrorData = error => logger.LogDebug("kubectl (stderr): {Error}", error),
        };
 
        var (pendingProcessResult, processDisposable) = ProcessUtil.Run(spec);
 
        await using (processDisposable.ConfigureAwait(false))
        {
            var processResult = await pendingProcessResult
                .WaitAsync(cancellationToken)
                .ConfigureAwait(false);
 
            if (processResult.ExitCode != 0)
            {
                return endpoints;
            }
        }
 
        try
        {
            var json = stdoutBuilder.ToString();
            using var document = JsonDocument.Parse(json);
            var root = document.RootElement;
 
            var serviceType = root.GetProperty("spec").GetProperty("type").GetString();
 
            if (serviceType is "LoadBalancer")
            {
                if (root.TryGetProperty("status", out var status) &&
                    status.TryGetProperty("loadBalancer", out var lb) &&
                    lb.TryGetProperty("ingress", out var ingress))
                {
                    foreach (var entry in ingress.EnumerateArray())
                    {
                        var host = entry.TryGetProperty("ip", out var ip) ? ip.GetString()
                            : entry.TryGetProperty("hostname", out var hostname) ? hostname.GetString()
                            : null;
 
                        if (host is not null)
                        {
                            foreach (var port in root.GetProperty("spec").GetProperty("ports").EnumerateArray())
                            {
                                var portNumber = port.GetProperty("port").GetInt32();
                                var scheme = portNumber == 443 ? "https" : "http";
                                endpoints.Add($"{scheme}://{host}:{portNumber}");
                            }
                        }
                    }
                }
            }
            else if (serviceType is "NodePort")
            {
                foreach (var port in root.GetProperty("spec").GetProperty("ports").EnumerateArray())
                {
                    if (port.TryGetProperty("nodePort", out var nodePort))
                    {
                        var portNumber = nodePort.GetInt32();
                        endpoints.Add($"http://localhost:{portNumber}");
                    }
                }
            }
        }
        catch (JsonException ex)
        {
            logger.LogDebug(ex, "Failed to parse kubectl output for service {ServiceName}", serviceName);
        }
 
        return endpoints;
    }
}