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.
 
using System.Globalization;
using System.Text;
using System.Text.Json;
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";
 
    // 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 issuer and managed EAB Secret cleanup runs while
        // the controller is still alive. ACME account keys are retained.
        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 an ACME directory endpoint, such as Let's Encrypt,
    /// ZeroSSL, or a private ACME server.
    /// </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>
    [AspireExportIgnore(Reason = "Handled by the withAcmeServer union export.")]
    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 External Account Binding (EAB) with a key identifier and secret HMAC key resolved at deploy time.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="keyId">A parameter containing the key identifier supplied by the ACME provider.</param>
    /// <param name="hmacKey">A secret parameter containing the provider's base64url-encoded HMAC key.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{CertManagerIssuerResource}"/> for chaining.</returns>
    /// <ats-returns>The resource builder.</ats-returns>
    /// <remarks>
    /// Aspire applies an issuer-specific Kubernetes Secret in cert-manager's cluster resource namespace
    /// at deploy time and attempts to delete it on destroy. The HMAC key is not included in published artifacts.
    /// EAB authorizes account registration, not domain ownership. Preserve cert-manager's separate account-key Secret.
    /// See <see href="https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.4"/> and
    /// <see href="https://cert-manager.io/docs/configuration/acme/#external-account-bindings"/>.
    /// Supply fresh credential pairs from a deployment pipeline when registering a new account with
    /// a provider that uses expiring or single-use EAB credentials. Aspire does not acquire or refresh
    /// provider credentials. Existing ACME accounts are reused by cert-manager.
    /// </remarks>
    /// <exception cref="ArgumentNullException">A required argument is null.</exception>
    /// <exception cref="ArgumentException">The HMAC parameter is not secret.</exception>
    [AspireExport]
    public static IResourceBuilder<CertManagerIssuerResource> WithExternalAccountBinding(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        IResourceBuilder<ParameterResource> keyId,
        IResourceBuilder<ParameterResource> hmacKey)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(keyId);
        ArgumentNullException.ThrowIfNull(hmacKey);
 
        if (!hmacKey.Resource.Secret)
        {
            throw new ArgumentException("The EAB HMAC key must be a secret parameter.", nameof(hmacKey));
        }
 
        builder.Resource.ExternalAccountBinding = new CertManagerExternalAccountBinding(ReferenceExpression.Create($"{keyId.Resource}"), hmacKey.Resource);
        return builder;
    }
 
    /// <summary>
    /// Configures the issuer with an ACME directory URL and contact email resolved from parameters at deploy time.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="serverUrl">A parameter containing the ACME directory URL.</param>
    /// <param name="email">A parameter containing 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>
    /// <exception cref="ArgumentNullException">A required argument is null.</exception>
    [AspireExportIgnore(Reason = "Handled by the withAcmeServer union export.")]
    public static IResourceBuilder<CertManagerIssuerResource> WithAcmeServer(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        IResourceBuilder<ParameterResource> serverUrl,
        IResourceBuilder<ParameterResource> email)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(serverUrl);
        ArgumentNullException.ThrowIfNull(email);
 
        builder.Resource.Spec = new CertManagerAcmeIssuerSpec(
            ReferenceExpression.Create($"{serverUrl.Resource}"),
            ReferenceExpression.Create($"{email.Resource}"));
 
        return builder;
    }
 
    /// <summary>
    /// Configures the ACME directory URL and contact email using either two strings or two parameters.
    /// </summary>
    /// <param name="builder">The issuer resource builder.</param>
    /// <param name="serverUrl">The ACME directory URL, as a string or parameter resource builder.</param>
    /// <param name="email">The account contact email, as a string or parameter resource builder.</param>
    /// <returns>The resource builder.</returns>
    [AspireExport("withAcmeServer")]
    internal static IResourceBuilder<CertManagerIssuerResource> WithAcmeServerForPolyglot(
        this IResourceBuilder<CertManagerIssuerResource> builder,
        [AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object serverUrl,
        [AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object email)
        => (serverUrl, email) switch
        {
            (string url, string contact) => builder.WithAcmeServer(url, contact),
            (IResourceBuilder<ParameterResource> url, IResourceBuilder<ParameterResource> contact) => builder.WithAcmeServer(url, contact),
            _ => throw new ArgumentException("Server URL and email must both be strings or both be parameter resource builders.")
        };
 
    /// <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],
                Tags = [HelmDeploymentEngine.GetKubernetesDestroyTag(certManager.Parent.Name)]
            };
 
            // Run while cert-manager and its CRDs are still available for issuer cleanup.
            // Aspire deletes its EAB Secret but preserves the separate ACME account key
            // managed by cert-manager.
            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 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);
 
        if (issuer.ExternalAccountBinding is not null)
        {
            var resourceVersion = await GetExternalAccountBindingSecretResourceVersionAsync(issuer, context.CancellationToken).ConfigureAwait(false);
            var secretSpec = await BuildExternalAccountBindingSecretApplySpecAsync(issuer, resourceVersion, context.CancellationToken).ConfigureAwait(false);
            var (secretResult, secretDisposable) = ProcessUtil.Run(secretSpec);
            await using (secretDisposable.ConfigureAwait(false))
            {
                var result = await secretResult.WaitAsync(context.CancellationToken).ConfigureAwait(false);
                if (result.ExitCode != 0)
                {
                    // kubectl errors can echo the complete Secret, including its encoded data.
                    // Report the failed operation without retaining or logging either output stream.
                    throw new InvalidOperationException(
                        $"Applying the EAB Secret for ClusterIssuer '{issuer.Name}' failed with exit code {result.ExitCode}. " +
                        "Check the Kubernetes context, namespace, permissions, and Secret ownership.");
                }
            }
        }
 
        context.Logger.LogInformation(
            "Applying cert-manager ClusterIssuer '{IssuerName}'.", issuer.Name);
 
        var arguments = new List<string> { "apply", "-f", "-" };
        AddKubeConfigArgument(arguments, environment);
        var stderr = new StringBuilder();
        var (resultTask, disposable) = ProcessUtil.Run(new ProcessSpec("kubectl")
        {
            ArgumentList = arguments,
            StandardInputContent = manifest,
            ThrowOnNonZeroReturnCode = false,
            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);
    }
 
    private static async Task DeleteClusterIssuerAsync(
        PipelineStepContext context,
        CertManagerResource certManager,
        CertManagerIssuerResource issuer)
    {
        var environment = certManager.Parent;
        if (environment.SkipDestroyCleanup)
        {
            context.Logger.LogInformation(
                "Skipping cert-manager cleanup for Kubernetes environment '{EnvironmentName}' because the cluster no longer exists.",
                environment.Name);
            return;
        }
 
        // 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());
            }
            else
            {
                context.Logger.LogInformation("ClusterIssuer '{IssuerName}' deleted.", issuer.Name);
            }
        }
 
        // Clean up even after a failed issuer deletion or removal of EAB from the model.
        await DeleteExternalAccountBindingSecretAsync(context, issuer).ConfigureAwait(false);
    }
 
    internal static async Task DeleteExternalAccountBindingSecretAsync(
        PipelineStepContext context, CertManagerIssuerResource issuer)
    {
        var certManager = issuer.Parent;
        var environment = certManager.Parent;
        var secretName = GetExternalAccountBindingSecretName(issuer);
        string @namespace;
        try
        {
            @namespace = GetClusterResourceNamespace(certManager, validateEabConfiguration: issuer.ExternalAccountBinding is not null);
        }
        catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
        {
            context.Logger.LogWarning(
                "Could not determine the namespace for EAB Secret '{SecretName}': {Error}. Remove the Secret manually from cert-manager's cluster resource namespace. Continuing destroy.",
                secretName, ex.Message);
            return;
        }
        var arguments = new List<string>
        {
            "delete", "secrets",
            "--field-selector", $"metadata.name={secretName}",
            "--selector", "app.kubernetes.io/managed-by=aspire-cert-manager",
            "--namespace", @namespace, "--ignore-not-found"
        };
        AddKubeConfigArgument(arguments, environment);
        var (deleteResult, deleteDisposable) = ProcessUtil.Run(new ProcessSpec("kubectl")
        {
            ArgumentList = arguments,
            ThrowOnNonZeroReturnCode = false
        });
        await using (deleteDisposable.ConfigureAwait(false))
        {
            var result = await deleteResult.WaitAsync(context.CancellationToken).ConfigureAwait(false);
            if (result.ExitCode != 0)
            {
                context.Logger.LogWarning(
                    "Deleting EAB Secret '{SecretName}' in namespace '{Namespace}' failed with exit code {ExitCode}. Remove the Secret manually. Continuing destroy.",
                    secretName, @namespace, result.ExitCode);
            }
        }
    }
 
    internal static async Task<ProcessSpec> BuildExternalAccountBindingSecretApplySpecAsync(
        CertManagerIssuerResource issuer,
        string? resourceVersion,
        CancellationToken cancellationToken)
    {
        var binding = issuer.ExternalAccountBinding
            ?? throw new InvalidOperationException($"ClusterIssuer '{issuer.Name}' has no External Account Binding.");
        var hmacKey = await binding.HmacKey.GetValueAsync(cancellationToken).ConfigureAwait(false);
        if (string.IsNullOrEmpty(hmacKey))
        {
            throw new InvalidOperationException($"The EAB HMAC parameter for ClusterIssuer '{issuer.Name}' has no value.");
        }
 
        // Secret.data is base64 of the provider's base64url text, not of the decoded MAC key.
        // See https://cert-manager.io/docs/configuration/acme/#external-account-bindings.
        var metadata = new Dictionary<string, object>
        {
            ["name"] = GetExternalAccountBindingSecretName(issuer),
            ["namespace"] = GetClusterResourceNamespace(issuer.Parent, validateEabConfiguration: true),
            ["labels"] = new Dictionary<string, string>
            {
                ["app.kubernetes.io/managed-by"] = "aspire-cert-manager"
            }
        };
        if (resourceVersion is not null)
        {
            metadata["resourceVersion"] = resourceVersion;
        }
        var manifest = JsonSerializer.Serialize(new
        {
            apiVersion = "v1",
            kind = "Secret",
            metadata,
            type = "Opaque",
            data = new Dictionary<string, string>
            {
                ["hmac-key"] = Convert.ToBase64String(Encoding.UTF8.GetBytes(hmacKey))
            }
        });
        // Create cannot adopt an object that appeared after our read. Replace checks the
        // resourceVersion, rejecting an object changed or recreated since ownership was checked.
        var arguments = new List<string> { resourceVersion is null ? "create" : "replace", "-f", "-" };
        AddKubeConfigArgument(arguments, issuer.Parent.Parent);
 
        return new ProcessSpec("kubectl")
        {
            ArgumentList = arguments,
            StandardInputContent = manifest,
            ThrowOnNonZeroReturnCode = false,
            RetainedOutputLineCount = 0
        };
    }
 
    private static async Task<string?> GetExternalAccountBindingSecretResourceVersionAsync(
        CertManagerIssuerResource issuer,
        CancellationToken cancellationToken)
    {
        var secretName = GetExternalAccountBindingSecretName(issuer);
        var arguments = new List<string>
        {
            "get", "secret", secretName, "--namespace", GetClusterResourceNamespace(issuer.Parent, validateEabConfiguration: true),
            "--ignore-not-found", "-o", @"jsonpath={.metadata.name}:{.metadata.labels.app\.kubernetes\.io/managed-by}:{.metadata.resourceVersion}"
        };
        AddKubeConfigArgument(arguments, issuer.Parent.Parent);
        var output = new StringBuilder();
        var (resultTask, disposable) = ProcessUtil.Run(new ProcessSpec("kubectl")
        {
            ArgumentList = arguments,
            ThrowOnNonZeroReturnCode = false,
            OnOutputData = line => output.Append(line)
        });
        await using (disposable.ConfigureAwait(false))
        {
            var result = await resultTask.WaitAsync(cancellationToken).ConfigureAwait(false);
            if (result.ExitCode != 0)
            {
                throw new InvalidOperationException(
                    $"Checking ownership of EAB Secret '{secretName}' failed with exit code {result.ExitCode}.");
            }
        }
 
        return ParseExternalAccountBindingSecretResourceVersion(secretName, output.ToString());
    }
 
    internal static string? ParseExternalAccountBindingSecretResourceVersion(string secretName, string output)
    {
        // jsonpath returns "issuer-eab:aspire-cert-manager:12345", or no output if absent.
        if (output.Length == 0)
        {
            return null;
        }
        var separator = output.LastIndexOf(':');
        if (separator < 0 || separator == output.Length - 1)
        {
            throw new InvalidOperationException($"EAB Secret '{secretName}' has no resource version.");
        }
        ValidateExternalAccountBindingSecretOwnership(secretName, output[..separator]);
        return output[(separator + 1)..];
    }
 
    internal static void ValidateExternalAccountBindingSecretOwnership(string secretName, string output)
    {
        // jsonpath returns "issuer-eab:aspire-cert-manager" for an owned Secret and
        // "issuer-eab:" for an unlabelled one. --ignore-not-found returns no output.
        if (output.Length > 0 && output != $"{secretName}:aspire-cert-manager")
        {
            throw new InvalidOperationException(
                $"EAB Secret '{secretName}' already exists and is not managed by Aspire cert-manager. " +
                "Use a different issuer name rather than overwriting or deleting this Secret.");
        }
    }
 
    internal static string GetClusterResourceNamespace(CertManagerResource certManager, bool validateEabConfiguration)
    {
        var chart = certManager.HelmChart;
        if (validateEabConfiguration && chart.Values.Any(pair => pair.Key.StartsWith("extraArgs", StringComparison.Ordinal)
            && pair.Value.Contains("--cluster-resource-namespace", StringComparison.Ordinal)))
        {
            throw new InvalidOperationException(
                "Configure cert-manager's cluster resource namespace using the 'clusterResourceNamespace' Helm value, " +
                "not 'extraArgs', so Aspire can place EAB Secrets in the same namespace.");
        }
 
        // The chart's command-line flag takes precedence over controller config and defaults
        // to POD_NAMESPACE. See the upstream chart's templates/deployment.yaml.
        var @namespace = chart.Values.GetValueOrDefault("clusterResourceNamespace");
        if (string.IsNullOrEmpty(@namespace))
        {
            var chartNamespace = chart.Values.GetValueOrDefault("namespace");
            @namespace = string.IsNullOrEmpty(chartNamespace) ? chart.Namespace ?? chart.Name : chartNamespace;
        }
 
        HelmChartOptions.ValidateNamespace(@namespace, nameof(KubernetesHelmChartResource.Namespace));
        return @namespace;
    }
 
    private static string GetExternalAccountBindingSecretName(CertManagerIssuerResource issuer)
        => $"{issuer.Name.ToKubernetesResourceName()}-eab";
 
    private static void AddKubeConfigArgument(List<string> arguments, KubernetesEnvironmentResource environment)
    {
        if (environment.KubeConfigPath is not null)
        {
            arguments.Add("--kubeconfig");
            arguments.Add(environment.KubeConfigPath);
        }
    }
 
    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:");
                    // Parameter values must stay scalars, even when they contain YAML syntax.
                    sb.Append("    server: ").AppendLine(JsonSerializer.Serialize(server));
                    sb.Append("    email: ").AppendLine(JsonSerializer.Serialize(email));
                    sb.AppendLine("    privateKeySecretRef:");
                    sb.Append("      name: ").Append(k8sIssuerName).AppendLine("-account-key");
                    if (issuer.ExternalAccountBinding is { } binding)
                    {
                        var keyId = await binding.KeyId.GetValueAsync(cancellationToken).ConfigureAwait(false);
                        if (string.IsNullOrEmpty(keyId) || !keyId.All(char.IsAscii))
                        {
                            throw new InvalidOperationException(
                                $"The EAB key identifier for ClusterIssuer '{issuer.Name}' must be a nonempty ASCII string.");
                        }
 
                        sb.AppendLine("    externalAccountBinding:");
                        // JSON string escaping is also valid for YAML double-quoted scalars.
                        sb.Append("      keyID: ").AppendLine(JsonSerializer.Serialize(keyId));
                        sb.AppendLine("      keySecretRef:");
                        sb.Append("        name: ").AppendLine(GetExternalAccountBindingSecretName(issuer));
                        sb.AppendLine("        key: hmac-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:");
 
                    // 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)
                    {
                        // A bare YAML key is null, not an empty solver object. Account-only
                        // issuers still need a valid solver configuration to pass admission.
                        sb.AppendLine("        gatewayHTTPRoute: {}");
                        // 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("        gatewayHTTPRoute:");
                    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}'.");
        }
    }
}