File: CertManagerExtensions.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.
 
using System.Globalization;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Kubernetes;
using Aspire.Hosting.Kubernetes.Extensions;
using Aspire.Hosting.Pipelines;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for installing cert-manager into a Kubernetes environment
/// and declaring <c>ClusterIssuer</c> resources against it.
/// </summary>
public static class CertManagerExtensions
{
    // The pinned default cert-manager chart version. Bump deliberately when validating against
    // a newer release; the Helm chart's API and CRDs evolve across minor versions.
    private const string DefaultChartReference = "oci://quay.io/jetstack/charts/cert-manager";
    private const string DefaultChartVersion = "v1.20.2";
 
    // Well-known ACME directory endpoints. See https://letsencrypt.org/docs/acme-protocol-updates/
    // for the current canonical URLs.
    private const string LetsEncryptProductionUrl = "https://acme-v02.api.letsencrypt.org/directory";
    private const string LetsEncryptStagingUrl = "https://acme-staging-v02.api.letsencrypt.org/directory";
 
    // The annotation cert-manager watches on Gateway / Ingress resources to auto-provision
    // a Certificate from the named ClusterIssuer.
    // See https://cert-manager.io/docs/usage/gateway/ and
    // https://cert-manager.io/docs/usage/ingress/.
    internal const string ClusterIssuerAnnotationKey = "cert-manager.io/cluster-issuer";
 
    // Aspire resource names cap at 64 chars. The helm chart we register beside the wrapper
    // is "{name}-chart" (6 extra chars), so the user-facing name has to stay under this
    // bound or AddHelmChart will reject it with a confusing downstream error.
    private const int MaxResourceNameLength = 64;
    private const string ChartNameSuffix = "-chart";
 
    /// <summary>
    /// Installs cert-manager into the Kubernetes environment and returns a typed
    /// <see cref="CertManagerResource"/> that can host issuer resources.
    /// </summary>
    /// <ats-summary>Installs cert-manager into a Kubernetes environment</ats-summary>
    /// <param name="builder">The Kubernetes environment resource builder.</param>
    /// <param name="name">The Aspire resource name for the cert-manager installation. Each call
    /// adds a uniquely-named resource to the application model, so multiple Kubernetes environments
    /// must each pass distinct names.</param>
    /// <param name="chartVersion">The cert-manager Helm chart version to install.
    /// Defaults to a pinned version validated against this Aspire build.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    /// <remarks>
    /// <para>
    /// Internally creates a <see cref="KubernetesHelmChartResource"/> via
    /// <see cref="KubernetesHelmChartExtensions.AddHelmChart(IResourceBuilder{KubernetesEnvironmentResource}, string, string, string)"/>
    /// pointed at <c>oci://quay.io/jetstack/charts/cert-manager</c>. The chart is configured with:
    /// </para>
    /// <list type="bullet">
    ///   <item><c>crds.enabled = true</c> — installs the cert-manager CRDs (<c>ClusterIssuer</c>, <c>Certificate</c>, ...) so issuer manifests can be applied immediately afterwards.</item>
    ///   <item><c>config.enableGatewayAPI = true</c> — lets cert-manager watch Gateway API <c>Gateway</c>/<c>HTTPRoute</c> resources for the cluster-issuer annotation.</item>
    ///   <item><c>WithForceConflicts()</c> — works around the AKS Azure Policy add-on mutating cert-manager's <c>ValidatingWebhookConfiguration</c> after install.</item>
    ///   <item><c>WithDestroy()</c> — uninstalls the Helm release on <c>aspire destroy</c>.</item>
    /// </list>
    /// <para>
    /// Issuer manifests are applied directly via <c>kubectl apply</c> at deploy time (not as
    /// part of the Helm release), and are deleted via <c>kubectl delete</c> on
    /// <c>aspire destroy</c> before the cert-manager Helm release itself is uninstalled.
    /// </para>
    /// <para>
    /// To customise additional Helm values, access the underlying chart via
    /// <see cref="CertManagerResource.HelmChart"/>.
    /// </para>
    /// </remarks>
    /// <ats-remarks />
    [AspireExport]
    public static IResourceBuilder<CertManagerResource> AddCertManager(
        this IResourceBuilder<KubernetesEnvironmentResource> builder,
        [ResourceName] string name,
        string? chartVersion = null)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(name);
 
        // Keep room for the "-chart" suffix we append for the underlying helm chart resource.
        // Without this guard, an otherwise-valid 64-char name would build successfully here
        // and then explode inside AddHelmChart with a less-clear "name too long" message.
        if (name.Length > MaxResourceNameLength - ChartNameSuffix.Length)
        {
            throw new ArgumentException(
                $"cert-manager resource name '{name}' is too long. The maximum length is {MaxResourceNameLength - ChartNameSuffix.Length} characters because " +
                $"a companion Helm chart resource is registered as '{{name}}{ChartNameSuffix}' which must itself fit within the {MaxResourceNameLength}-character Aspire resource name limit.",
                nameof(name));
        }
 
        var version = chartVersion ?? DefaultChartVersion;
 
        // The helm chart is exposed in the model under "{name}-chart" so the user-facing
        // CertManagerResource can keep the natural "{name}" identifier without colliding.
        // Both show up in the dashboard / generated artifacts: the chart is what actually
        // installs cert-manager, and the wrapper is what hosts the typed issuer children.
        var chartName = $"{name}{ChartNameSuffix}";
 
        var chartBuilder = builder
            .AddHelmChart(chartName, DefaultChartReference, version)
            .WithHelmValue("crds.enabled", "true")
            // Gateway API support is opt-in in the cert-manager chart. Without these values
            // cert-manager will not provision Certificates for Gateway listeners.
            // See https://cert-manager.io/docs/usage/gateway/.
            .WithHelmValue("config.apiVersion", "controller.config.cert-manager.io/v1alpha1")
            .WithHelmValue("config.kind", "ControllerConfiguration")
            .WithHelmValue("config.enableGatewayAPI", "true")
            .WithForceConflicts()
            .WithDestroy();
 
        var resource = new CertManagerResource(name, builder.Resource, chartBuilder.Resource);
 
        if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
        {
            return builder.ApplicationBuilder.CreateResourceBuilder(resource);
        }
 
        var resourceBuilder = builder.ApplicationBuilder.AddResource(resource).WithIconName("Certificate").ExcludeFromManifest();
 
        // Emit one kubectl-apply step per ClusterIssuer at deploy time, plus one kubectl-delete
        // step per ClusterIssuer at destroy time. The annotation factory closures capture the
        // CertManagerResource and read .Issuers when the pipeline is assembled, so issuers added
        // via AddIssuer(...) after this call are still picked up.
        // Apply steps depend on helm-install-{chartName} so cert-manager (and its admission
        // webhook) is up before we apply CRD instances. Delete steps run before
        // helm-uninstall-{chartName} so we delete the ClusterIssuers (and let cert-manager
        // clean up its account secrets) while the controller is still alive.
        resourceBuilder.WithAnnotation(new PipelineStepAnnotation(_ =>
            BuildIssuerApplySteps(resource, chartName)));
        resourceBuilder.WithAnnotation(new PipelineStepAnnotation(_ =>
            BuildIssuerDeleteSteps(resource, chartName)));
 
        return resourceBuilder;
    }
 
    /// <summary>
    /// Adds a cert-manager <c>ClusterIssuer</c> to this cert-manager installation.
    /// </summary>
    /// <param name="builder">The cert-manager resource builder.</param>
    /// <param name="name">The Aspire resource name. Also used as the <c>metadata.name</c>
    /// of the generated <c>ClusterIssuer</c>, so it must be a valid DNS-1123 label.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    public static IResourceBuilder<CertManagerIssuerResource> AddIssuer(
        this IResourceBuilder<CertManagerResource> builder,
        [ResourceName] string name)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(name);
 
        var issuer = new CertManagerIssuerResource(name, builder.Resource);
        builder.Resource.Issuers.Add(issuer);
 
        if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
        {
            return builder.ApplicationBuilder.CreateResourceBuilder(issuer);
        }
 
        return builder.ApplicationBuilder.AddResource(issuer).WithIconName("ContactCard").ExcludeFromManifest();
    }
 
    /// <summary>
    /// Configures the issuer to use the Let's Encrypt production ACME endpoint.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="email">The contact email registered with the ACME account. Let's Encrypt
    /// uses this address for expiry notifications and rate-limit appeals.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    /// <remarks>
    /// Production certificates are subject to strict per-domain rate limits
    /// (<see href="https://letsencrypt.org/docs/rate-limits/"/>). For development workflows,
    /// prefer <see cref="WithLetsEncryptStaging(IResourceBuilder{CertManagerIssuerResource}, string)"/>
    /// which uses untrusted staging certificates with much higher rate limits.
    /// </remarks>
    /// <ats-remarks />
    [AspireExport]
    public static IResourceBuilder<CertManagerIssuerResource> WithLetsEncryptProduction(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        string email)
        => WithAcmeServer(builder, LetsEncryptProductionUrl, email);
 
    /// <summary>
    /// Configures the issuer to use the Let's Encrypt production ACME endpoint, with the
    /// contact email supplied via a parameter resolved at deploy time.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="email">A parameter resource builder whose value is the contact email registered with the ACME account.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport("withLetsEncryptProductionParam")]
    public static IResourceBuilder<CertManagerIssuerResource> WithLetsEncryptProduction(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        IResourceBuilder<ParameterResource> email)
        => WithAcmeServer(builder, LetsEncryptProductionUrl, email);
 
    /// <summary>
    /// Configures the issuer to use the Let's Encrypt staging ACME endpoint. Certificates issued
    /// from staging are not trusted by browsers, but the endpoint has much higher rate limits,
    /// making it the right choice for development and CI workflows.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="email">The contact email registered with the ACME account.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    public static IResourceBuilder<CertManagerIssuerResource> WithLetsEncryptStaging(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        string email)
        => WithAcmeServer(builder, LetsEncryptStagingUrl, email);
 
    /// <summary>
    /// Configures the issuer to use the Let's Encrypt staging ACME endpoint, with the
    /// contact email supplied via a parameter resolved at deploy time.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="email">A parameter resource builder whose value is the contact email registered with the ACME account.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport("withLetsEncryptStagingParam")]
    public static IResourceBuilder<CertManagerIssuerResource> WithLetsEncryptStaging(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        IResourceBuilder<ParameterResource> email)
        => WithAcmeServer(builder, LetsEncryptStagingUrl, email);
 
    /// <summary>
    /// Configures the issuer to use a custom ACME directory endpoint (e.g., a private ACME
    /// server such as ZeroSSL or step-ca).
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="serverUrl">The ACME directory URL (e.g., <c>https://acme.example.com/directory</c>).</param>
    /// <param name="email">The contact email registered with the ACME account.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport]
    public static IResourceBuilder<CertManagerIssuerResource> WithAcmeServer(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        string serverUrl,
        string email)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(serverUrl);
        ArgumentException.ThrowIfNullOrEmpty(email);
 
        builder.Resource.Spec = new CertManagerAcmeIssuerSpec(
            ReferenceExpression.Create($"{serverUrl}"),
            ReferenceExpression.Create($"{email}"));
 
        return builder;
    }
 
    /// <summary>
    /// Configures the issuer to use a custom ACME directory endpoint with a parameterized email.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="serverUrl">The ACME directory URL (e.g., <c>https://acme.example.com/directory</c>).</param>
    /// <param name="email">A parameter resource builder whose value is the contact email registered with the ACME account.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    [AspireExport("withAcmeServerParam")]
    public static IResourceBuilder<CertManagerIssuerResource> WithAcmeServer(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        string serverUrl,
        IResourceBuilder<ParameterResource> email)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(serverUrl);
        ArgumentNullException.ThrowIfNull(email);
 
        builder.Resource.Spec = new CertManagerAcmeIssuerSpec(
            ReferenceExpression.Create($"{serverUrl}"),
            ReferenceExpression.Create($"{email.Resource}"));
 
        return builder;
    }
 
    /// <summary>
    /// Adds an HTTP-01 ACME challenge solver to the issuer. cert-manager will satisfy the
    /// challenge by provisioning a temporary HTTP route at
    /// <c>/.well-known/acme-challenge/{token}</c> on the same hostname being validated.
    /// This requires the hostname to be publicly reachable on port 80.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    /// <remarks>
    /// HTTP-01 is the right choice for gateways exposed via Azure Application Gateway for
    /// Containers (AGC) or any ingress controller that publishes a publicly addressable
    /// hostname. Wildcard certificates require a DNS-01 solver, which is not yet supported.
    /// </remarks>
    [AspireExport]
    public static IResourceBuilder<CertManagerIssuerResource> WithHttp01Solver(
        this IResourceBuilder<CertManagerIssuerResource> builder)
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        builder.Resource.Solvers.Add(new CertManagerHttp01SolverConfig());
        return builder;
    }
 
    /// <summary>
    /// Adds an HTTPS listener to the gateway and wires it to the supplied cert-manager
    /// <c>ClusterIssuer</c>. This adds the <c>cert-manager.io/cluster-issuer</c> annotation
    /// to the generated Gateway resource, causing cert-manager to provision and renew a
    /// certificate for each gateway listener hostname.
    /// </summary>
    /// <param name="builder">The gateway resource builder.</param>
    /// <param name="issuer">The cert-manager <c>ClusterIssuer</c> to issue certificates from.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{KubernetesGatewayResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    /// <remarks>
    /// Equivalent to calling <c>WithTls()</c> followed by
    /// <c>WithGatewayAnnotation("cert-manager.io/cluster-issuer", issuer.Resource.Name)</c>,
    /// but type-safe and refactor-friendly. Throws if the gateway and the issuer's
    /// cert-manager installation are not part of the same Kubernetes environment, since
    /// cert-manager is per-cluster and would otherwise silently produce an unsatisfiable
    /// TLS configuration.
    /// </remarks>
    [AspireExport("withGatewayTlsIssuer")]
    public static IResourceBuilder<KubernetesGatewayResource> WithTls(
        this IResourceBuilder<KubernetesGatewayResource> builder,
        IResourceBuilder<CertManagerIssuerResource> issuer)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(issuer);
 
        var gatewayEnvironment = builder.Resource.Parent;
        var issuerEnvironment = issuer.Resource.Parent.Parent;
        var nameComparer = new ResourceNameComparer();
        if (!nameComparer.Equals(gatewayEnvironment, issuerEnvironment))
        {
            throw new InvalidOperationException(
                $"Gateway '{builder.Resource.Name}' is in Kubernetes environment '{gatewayEnvironment.Name}' but issuer " +
                $"'{issuer.Resource.Name}' belongs to cert-manager installation '{issuer.Resource.Parent.Name}' in environment " +
                $"'{issuerEnvironment.Name}'. cert-manager is per-cluster, so an issuer can only be used by gateways in the " +
                "same Kubernetes environment.");
        }
 
        return builder
            .WithTls()
            .WithGatewayAnnotation(ClusterIssuerAnnotationKey, issuer.Resource.Name);
    }
 
    private static Task<IEnumerable<PipelineStep>> BuildIssuerApplySteps(
        CertManagerResource certManager,
        string chartName)
    {
        var steps = new List<PipelineStep>();
 
        foreach (var issuer in certManager.Issuers)
        {
            // Capture each issuer in its own local so the closure below doesn't see the
            // foreach iteration variable.
            var captured = issuer;
 
            var step = new PipelineStep
            {
                Name = $"cm-issuer-apply-{captured.Name}",
                Description = $"Applies cert-manager ClusterIssuer '{captured.Name}'",
                Action = ctx => ApplyClusterIssuerAsync(ctx, certManager, captured)
            };
 
            // Wait for cert-manager itself to be installed and ready (helm install uses
            // --wait, so the validating webhook is guaranteed to be Available before this
            // step runs). Without this dep we'd race the webhook and get
            // 'failed calling webhook "webhook.cert-manager.io": no endpoints available'.
            step.DependsOn($"helm-install-{chartName}");
            step.RequiredBy(WellKnownPipelineSteps.Deploy);
            steps.Add(step);
        }
 
        return Task.FromResult<IEnumerable<PipelineStep>>(steps);
    }
 
    private static Task<IEnumerable<PipelineStep>> BuildIssuerDeleteSteps(
        CertManagerResource certManager,
        string chartName)
    {
        var steps = new List<PipelineStep>();
 
        foreach (var issuer in certManager.Issuers)
        {
            var captured = issuer;
 
            var step = new PipelineStep
            {
                Name = $"cm-issuer-delete-{captured.Name}",
                Description = $"Deletes cert-manager ClusterIssuer '{captured.Name}'",
                Action = ctx => DeleteClusterIssuerAsync(ctx, certManager, captured),
                DependsOnSteps = [WellKnownPipelineSteps.DestroyPrereq]
            };
 
            // Run before the cert-manager helm chart is uninstalled. Once the chart goes,
            // the cert-manager.io CRDs are removed and the API server forgets ClusterIssuer
            // exists, so a delete attempted afterwards either silently no-ops (object kind
            // not found) or fails. Deleting while cert-manager is still alive also lets the
            // controller clean up the ACME account secret it created in the cert-manager
            // namespace.
            step.RequiredBy($"helm-uninstall-{chartName}");
            step.RequiredBy(WellKnownPipelineSteps.Destroy);
            steps.Add(step);
        }
 
        return Task.FromResult<IEnumerable<PipelineStep>>(steps);
    }
 
    private static async Task ApplyClusterIssuerAsync(
        PipelineStepContext context,
        CertManagerResource certManager,
        CertManagerIssuerResource issuer)
    {
        if (issuer.Spec is null)
        {
            throw new InvalidOperationException(
                $"ClusterIssuer '{issuer.Name}' has no spec. Configure it with WithLetsEncryptProduction(), " +
                "WithLetsEncryptStaging(), or WithAcmeServer().");
        }
 
        if (issuer.Solvers.Count == 0)
        {
            throw new InvalidOperationException(
                $"ClusterIssuer '{issuer.Name}' has no solvers configured. Add at least one " +
                "solver via WithHttp01Solver().");
        }
 
        var environment = certManager.Parent;
 
        var manifest = await BuildClusterIssuerManifestAsync(context.Model, certManager, issuer, context.Logger, context.CancellationToken)
            .ConfigureAwait(false);
 
        context.Logger.LogInformation(
            "Applying cert-manager ClusterIssuer '{IssuerName}'.", issuer.Name);
 
        // Write to a temp file and apply. kubectl apply -f - via stdin would avoid the
        // temp file but ProcessUtil.Run doesn't expose a stdin pipe; Directory.CreateTempSubdirectory
        // is the standard temp pattern in this codebase (see EnsureBootstrapTlsSecretAsync).
        var tempDir = Directory.CreateTempSubdirectory(".aspire-cm-issuer");
        try
        {
            var manifestPath = Path.Combine(tempDir.FullName, $"{issuer.Name}.yaml");
            await File.WriteAllTextAsync(manifestPath, manifest, context.CancellationToken).ConfigureAwait(false);
 
            var args = new StringBuilder();
            args.Append(CultureInfo.InvariantCulture, $"apply -f \"{manifestPath}\"");
            if (environment.KubeConfigPath is not null)
            {
                args.Append(CultureInfo.InvariantCulture, $" --kubeconfig \"{environment.KubeConfigPath}\"");
            }
 
            var stderr = new StringBuilder();
            var (resultTask, disposable) = ProcessUtil.Run(new ProcessSpec("kubectl")
            {
                Arguments = args.ToString(),
                ThrowOnNonZeroReturnCode = false,
                InheritEnv = true,
                OnOutputData = line => context.Logger.LogDebug("kubectl: {Line}", line),
                OnErrorData = line =>
                {
                    stderr.AppendLine(line);
                    context.Logger.LogDebug("kubectl: {Line}", line);
                }
            });
 
            await using (disposable.ConfigureAwait(false))
            {
                var result = await resultTask.WaitAsync(context.CancellationToken).ConfigureAwait(false);
                if (result.ExitCode != 0)
                {
                    var errOut = stderr.ToString().Trim();
                    throw new InvalidOperationException(
                        $"kubectl apply for ClusterIssuer '{issuer.Name}' failed with exit code {result.ExitCode}: {errOut}");
                }
            }
 
            context.Logger.LogInformation("ClusterIssuer '{IssuerName}' applied.", issuer.Name);
        }
        finally
        {
            try { tempDir.Delete(recursive: true); }
            catch (IOException) { /* best-effort cleanup */ }
            catch (UnauthorizedAccessException) { /* best-effort cleanup */ }
        }
    }
 
    private static async Task DeleteClusterIssuerAsync(
        PipelineStepContext context,
        CertManagerResource certManager,
        CertManagerIssuerResource issuer)
    {
        var environment = certManager.Parent;
        // Match the lowercase normalization used at apply time so we target the same object.
        var k8sIssuerName = issuer.Name.ToKubernetesResourceName();
 
        context.Logger.LogInformation(
            "Deleting cert-manager ClusterIssuer '{IssuerName}'.", issuer.Name);
 
        var args = new StringBuilder();
        // --ignore-not-found makes destroy idempotent: re-running aspire destroy after a
        // partially-completed prior destroy (or after the issuer was deleted manually) is a
        // no-op rather than a failure.
        args.Append(CultureInfo.InvariantCulture, $"delete clusterissuer {k8sIssuerName} --ignore-not-found");
        if (environment.KubeConfigPath is not null)
        {
            args.Append(CultureInfo.InvariantCulture, $" --kubeconfig \"{environment.KubeConfigPath}\"");
        }
 
        var stderr = new StringBuilder();
        var (resultTask, disposable) = ProcessUtil.Run(new ProcessSpec("kubectl")
        {
            Arguments = args.ToString(),
            ThrowOnNonZeroReturnCode = false,
            InheritEnv = true,
            OnOutputData = line => context.Logger.LogDebug("kubectl: {Line}", line),
            OnErrorData = line =>
            {
                stderr.AppendLine(line);
                context.Logger.LogDebug("kubectl: {Line}", line);
            }
        });
 
        await using (disposable.ConfigureAwait(false))
        {
            var result = await resultTask.WaitAsync(context.CancellationToken).ConfigureAwait(false);
            if (result.ExitCode != 0)
            {
                // Don't fail destroy if delete encountered an error — log and continue so the
                // rest of the destroy pipeline (helm-uninstall, infra teardown) can still run.
                // The cluster is most likely about to be torn down anyway.
                context.Logger.LogWarning(
                    "kubectl delete for ClusterIssuer '{IssuerName}' returned exit code {ExitCode}: {StdErr}",
                    issuer.Name,
                    result.ExitCode,
                    stderr.ToString().Trim());
                return;
            }
        }
 
        context.Logger.LogInformation("ClusterIssuer '{IssuerName}' deleted.", issuer.Name);
    }
 
    internal static async Task<string> BuildClusterIssuerManifestAsync(
        ApplicationModel.DistributedApplicationModel model,
        CertManagerResource certManager,
        CertManagerIssuerResource issuer,
        ILogger logger,
        CancellationToken cancellationToken)
    {
        var sb = new StringBuilder();
        sb.AppendLine("apiVersion: cert-manager.io/v1");
        sb.AppendLine("kind: ClusterIssuer");
        sb.AppendLine("metadata:");
        // Kubernetes object metadata.name must be a valid DNS-1123 subdomain (lowercase).
        // Aspire resource names allow uppercase, so normalise here to match the same rule the
        // rest of the Kubernetes integration uses for generated object names.
        var k8sIssuerName = issuer.Name.ToKubernetesResourceName();
        sb.Append("  name: ").AppendLine(k8sIssuerName);
        sb.AppendLine("spec:");
 
        switch (issuer.Spec)
        {
            case CertManagerAcmeIssuerSpec acme:
                {
                    var server = await acme.ServerUrl.GetValueAsync(cancellationToken).ConfigureAwait(false);
                    var email = await acme.Email.GetValueAsync(cancellationToken).ConfigureAwait(false);
                    sb.AppendLine("  acme:");
                    sb.Append("    server: ").AppendLine(server);
                    sb.Append("    email: ").AppendLine(email);
                    sb.AppendLine("    privateKeySecretRef:");
                    sb.Append("      name: ").Append(k8sIssuerName).AppendLine("-account-key");
                    sb.AppendLine("    solvers:");
                    foreach (var solver in issuer.Solvers)
                    {
                        AppendSolver(sb, solver, model, certManager, issuer, logger);
                    }
                    break;
                }
            default:
                throw new InvalidOperationException(
                    $"Unknown issuer spec type '{issuer.Spec?.GetType().Name}' on issuer '{issuer.Name}'.");
        }
 
        return sb.ToString();
    }
 
    private static void AppendSolver(
        StringBuilder sb,
        CertManagerSolverConfig solver,
        ApplicationModel.DistributedApplicationModel model,
        CertManagerResource certManager,
        CertManagerIssuerResource issuer,
        ILogger logger)
    {
        switch (solver)
        {
            case CertManagerHttp01SolverConfig:
                {
                    sb.AppendLine("    - http01:");
                    sb.AppendLine("        gatewayHTTPRoute:");
 
                    // cert-manager's HTTP-01 Gateway API solver creates an HTTPRoute that has to
                    // attach to the Gateway being validated. Without parentRefs, the route is
                    // orphaned and the ACME challenge URL is unreachable.
                    // See https://cert-manager.io/docs/configuration/acme/http01/#configuring-the-http01-gateway-api-solver
                    // Route-less gateways are excluded because they are skipped during
                    // materialization: a parentRef to a Gateway that is never created is just as
                    // orphaned, and it would also mask the warning below.
                    var nameComparer = new ResourceNameComparer();
                    var parentGateways = model.Resources
                        .OfType<KubernetesGatewayResource>()
                        .Where(g => nameComparer.Equals(g.Parent, certManager.Parent)
                                    && g.ShouldMaterialize
                                    && g.GatewayAnnotations.TryGetValue(ClusterIssuerAnnotationKey, out var v)
                                    && string.Equals(v.Format, issuer.Name, StringComparison.Ordinal))
                        .ToList();
 
                    if (parentGateways.Count == 0)
                    {
                        // No eligible gateway found — either none is annotated for this issuer, or
                        // the annotated ones have no routes and are therefore skipped during
                        // materialization. Either way cert-manager will accept this manifest but the
                        // HTTP-01 challenge can never be satisfied, because there's no parent Gateway
                        // for the solver's HTTPRoute to attach to. Emit a warning so the
                        // misconfiguration is visible at deploy time instead of leaving the user
                        // to discover it via Certificates stuck in 'Pending' indefinitely.
                        logger.LogWarning(
                            "ClusterIssuer '{IssuerName}' has an HTTP-01 solver but no Gateway in environment '{EnvironmentName}' is both annotated with " +
                            ClusterIssuerAnnotationKey + "={IssuerName} and configured with at least one route. cert-manager will not be able to satisfy " +
                            "ACME challenges until at least one routed Gateway adopts this issuer (e.g. via WithRoute(...) and WithTls(issuer)).",
                            issuer.Name,
                            certManager.Parent.Name,
                            issuer.Name);
                        return;
                    }
 
                    sb.AppendLine("          parentRefs:");
                    foreach (var gateway in parentGateways)
                    {
                        sb.AppendLine("            - group: gateway.networking.k8s.io");
                        sb.AppendLine("              kind: Gateway");
                        // Match the metadata.name normalization used by BuildGatewayObjects so
                        // the parentRef resolves to the actual Gateway in the cluster.
                        sb.Append("              name: ").AppendLine(gateway.Name.ToKubernetesResourceName());
                    }
                    break;
                }
            default:
                throw new InvalidOperationException(
                    $"Unknown solver type '{solver.GetType().Name}' on issuer '{issuer.Name}'.");
        }
    }
}