File: CertManagerTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj (Aspire.Hosting.Kubernetes.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Hosting.Kubernetes.Tests;
 
public class CertManagerTests
{
    [Fact]
    public void AddCertManager_RegistersWrapperAndHelmChartUnderSeparateNames()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var certManager = k8s.AddCertManager("cert-manager");
 
        Assert.Equal("cert-manager", certManager.Resource.Name);
        Assert.Same(k8s.Resource, certManager.Resource.Parent);
 
        // The wrapper keeps the natural "{name}" identifier; the helm chart is registered
        // under "{name}-chart" so both can coexist in the model without colliding.
        Assert.Equal("cert-manager-chart", certManager.Resource.HelmChart.Name);
        Assert.Equal("oci://quay.io/jetstack/charts/cert-manager", certManager.Resource.HelmChart.ChartReference);
        Assert.StartsWith("v", certManager.Resource.HelmChart.ChartVersion);
 
        // CRDs and Gateway API support are required for cert-manager to issue certificates
        // for Aspire-modeled Gateway resources, so they must be on by default.
        Assert.Equal("true", certManager.Resource.HelmChart.Values["crds.enabled"]);
        Assert.Equal("true", certManager.Resource.HelmChart.Values["config.enableGatewayAPI"]);
 
        var app = builder.Build();
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        Assert.Contains(appModel.Resources, r => r is CertManagerResource c && c.Name == "cert-manager");
        Assert.Contains(appModel.Resources, r => r is KubernetesHelmChartResource c && c.Name == "cert-manager-chart");
    }
 
    [Fact]
    public void AddIssuer_WithLetsEncryptProductionAndHttp01_PopulatesSpecAndSolver()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var certManager = k8s.AddCertManager("cert-manager");
 
        var issuer = certManager.AddIssuer("letsencrypt-prod")
            .WithLetsEncryptProduction("ops@contoso.com")
            .WithHttp01Solver();
 
        Assert.Equal("letsencrypt-prod", issuer.Resource.Name);
        Assert.Same(certManager.Resource, issuer.Resource.Parent);
        Assert.Contains(issuer.Resource, certManager.Resource.Issuers);
 
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Equal("https://acme-v02.api.letsencrypt.org/directory", spec.ServerUrl.Format);
        Assert.Equal("ops@contoso.com", spec.Email.Format);
 
        var solver = Assert.Single(issuer.Resource.Solvers);
        Assert.IsType<CertManagerHttp01SolverConfig>(solver);
    }
 
    [Fact]
    public void WithLetsEncryptStaging_UsesStagingDirectoryUrl()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
 
        var issuer = certManager.AddIssuer("le-staging")
            .WithLetsEncryptStaging("ops@contoso.com");
 
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Equal("https://acme-staging-v02.api.letsencrypt.org/directory", spec.ServerUrl.Format);
    }
 
    [Fact]
    public void WithAcmeServer_AllowsCustomDirectoryUrl()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var certManager = builder.AddKubernetesEnvironment("env").AddCertManager("cert-manager");
 
        var issuer = certManager.AddIssuer("custom-acme")
            .WithAcmeServer("https://acme.example.com/directory", "ops@contoso.com");
 
        var spec = Assert.IsType<CertManagerAcmeIssuerSpec>(issuer.Resource.Spec);
        Assert.Equal("https://acme.example.com/directory", spec.ServerUrl.Format);
    }
 
    [Fact]
    public void Gateway_WithTls_Issuer_AddsClusterIssuerAnnotation()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("letsencrypt-prod")
            .WithLetsEncryptProduction("ops@contoso.com")
            .WithHttp01Solver();
 
        var gateway = k8s.AddGateway("public")
            .WithGatewayClass("nginx")
            .WithTls(issuer);
 
        // The typed WithTls(issuer) overload should be equivalent to WithTls() + setting
        // the cert-manager.io/cluster-issuer annotation to the issuer's name.
        Assert.Single(gateway.Resource.TlsConfigs);
        Assert.True(gateway.Resource.GatewayAnnotations.TryGetValue(
            CertManagerExtensions.ClusterIssuerAnnotationKey, out var value));
        Assert.Equal("letsencrypt-prod", value!.Format);
    }
 
    [Fact]
    public async Task BuildClusterIssuerManifest_EmitsExpectedYamlForLetsEncryptHttp01()
    {
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("LetsEncrypt-Prod") // intentionally mixed-case to verify DNS-1123 normalization
            .WithLetsEncryptProduction("ops@contoso.com")
            .WithHttp01Solver();
 
        // A gateway that adopts this issuer must end up referenced as a parentRef on the
        // generated solver, so cert-manager's HTTP-01 HTTPRoute can attach to it. The gateway
        // needs a route, otherwise it is skipped during materialization and referencing it
        // would produce a parentRef to a Gateway that never exists in the cluster.
        var api = builder.AddContainer("myapi", "nginx")
            .WithHttpEndpoint(targetPort: 8080)
            .WithExternalHttpEndpoints();
 
        k8s.AddGateway("PUBLIC-GW")
            .WithGatewayClass("nginx")
            .WithRoute("/", api.GetEndpoint("http"))
            .WithTls(issuer);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var certManagerResource = model.Resources.OfType<CertManagerResource>().Single();
        var issuerResource = certManagerResource.Issuers.Single();
 
        var yaml = await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            model,
            certManagerResource,
            issuerResource,
            NullLogger.Instance,
            TestContext.Current.CancellationToken);
 
        // Object names go through ToKubernetesResourceName() (lowercasing) so they're valid
        // DNS-1123 subdomains regardless of the casing the user picked for the Aspire name.
        Assert.Contains("apiVersion: cert-manager.io/v1", yaml);
        Assert.Contains("kind: ClusterIssuer", yaml);
        Assert.Contains("name: letsencrypt-prod", yaml);
        Assert.Contains("server: https://acme-v02.api.letsencrypt.org/directory", yaml);
        Assert.Contains("email: ops@contoso.com", yaml);
        Assert.Contains("name: letsencrypt-prod-account-key", yaml);
        Assert.Contains("- http01:", yaml);
        Assert.Contains("gatewayHTTPRoute:", yaml);
        // Gateway parentRef must also be lowercase to match the actual emitted Gateway name.
        Assert.Contains("name: public-gw", yaml);
    }
 
    [Fact]
    public async Task BuildClusterIssuerManifest_RouteLessGateway_IsNotEmittedAsParentRef()
    {
        // A gateway with no routes is skipped during materialization, so emitting it as a
        // solver parentRef would point cert-manager's HTTP-01 HTTPRoute at a Gateway that
        // never exists in the cluster, leaving Certificates stuck in 'Pending' forever.
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("le-prod")
            .WithLetsEncryptProduction("ops@contoso.com")
            .WithHttp01Solver();
 
        k8s.AddGateway("public")
            .WithGatewayClass("nginx")
            .WithTls(issuer);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var certManagerResource = model.Resources.OfType<CertManagerResource>().Single();
        var issuerResource = certManagerResource.Issuers.Single();
 
        var yaml = await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            model,
            certManagerResource,
            issuerResource,
            NullLogger.Instance,
            TestContext.Current.CancellationToken);
 
        // The solver is still emitted, but with no parentRefs block at all, which is also what
        // makes the "no routed Gateway adopts this issuer" warning fire.
        Assert.Contains("- http01:", yaml);
        Assert.Contains("gatewayHTTPRoute:", yaml);
        Assert.DoesNotContain("parentRefs:", yaml);
        Assert.DoesNotContain("name: public", yaml);
    }
 
    [Fact]
    public async Task BuildClusterIssuerManifest_RouteLessGateway_WarnsThatChallengesCannotBeSatisfied()
    {
        // Without a parentRef the manifest is still valid YAML and cert-manager accepts it, so the
        // warning is the only thing that tells the user why their Certificates never leave
        // 'Pending'. Assert it explicitly so it cannot be dropped without a test failing.
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var k8s = builder.AddKubernetesEnvironment("env");
        var issuer = k8s.AddCertManager("cert-manager")
            .AddIssuer("le-prod")
            .WithLetsEncryptProduction("ops@contoso.com")
            .WithHttp01Solver();
 
        k8s.AddGateway("public")
            .WithGatewayClass("nginx")
            .WithTls(issuer);
 
        using var app = builder.Build();
        var model = app.Services.GetRequiredService<DistributedApplicationModel>();
        var certManagerResource = model.Resources.OfType<CertManagerResource>().Single();
        var issuerResource = certManagerResource.Issuers.Single();
 
        var testSink = new TestSink();
        using var loggerProvider = new TestLoggerProvider(testSink);
 
        await CertManagerExtensions.BuildClusterIssuerManifestAsync(
            model,
            certManagerResource,
            issuerResource,
            loggerProvider.CreateLogger("test"),
            TestContext.Current.CancellationToken);
 
        var warning = Assert.Single(testSink.Writes, w => w.LogLevel == LogLevel.Warning);
 
        Assert.Equal(
            "ClusterIssuer 'le-prod' has an HTTP-01 solver but no Gateway in environment 'env' is both annotated with " +
            "cert-manager.io/cluster-issuer=le-prod 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)).",
            warning.Message);
    }
 
    [Fact]
    public void Gateway_WithTls_Issuer_FromDifferentEnvironment_Throws()
    {
        // cert-manager is per-cluster, so an issuer can only TLS-protect gateways inside the
        // same Kubernetes environment. Cross-env wiring is a configuration bug we want to
        // catch at app-host build time, not at deploy time when nothing comes up.
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        var envA = builder.AddKubernetesEnvironment("env-a");
        var envB = builder.AddKubernetesEnvironment("env-b");
 
        var issuerInA = envA.AddCertManager("cert-manager")
            .AddIssuer("le-prod")
            .WithLetsEncryptProduction("ops@contoso.com")
            .WithHttp01Solver();
 
        var gatewayInB = envB.AddGateway("public").WithGatewayClass("nginx");
 
        Assert.Throws<InvalidOperationException>(() => gatewayInB.WithTls(issuerInA));
    }
 
    [Fact]
    public void AddCertManager_RunMode_DoesNotRegisterResources()
    {
        // In run mode neither the wrapper nor its helm chart get added to the model
        // (helm install only runs at deploy time, and AddHelmChart already suppresses
        // its resource in run mode).
        var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
        var k8s = builder.AddKubernetesEnvironment("env");
 
        var certManager = k8s.AddCertManager("cert-manager");
        Assert.NotNull(certManager.Resource);
 
        var app = builder.Build();
        var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
        Assert.DoesNotContain(appModel.Resources, r => r is CertManagerResource);
    }
}