File: KubernetesManifestTests.cs
Web Access
Project: src\tests\Aspire.Hosting.RemoteHost.Tests\Aspire.Hosting.RemoteHost.Tests.csproj (Aspire.Hosting.RemoteHost.Tests)
// 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 ASPIRECOMPUTE002
#pragma warning disable ASPIREPIPELINES003
 
using System.Reflection;
using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Kubernetes;
using Aspire.Hosting.RemoteHost.Ats;
using Aspire.Hosting.RemoteHost.CodeGeneration;
using Aspire.Tests.Utils;
using Aspire.TypeSystem;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
 
namespace Aspire.Hosting.RemoteHost.Tests;
 
public class KubernetesManifestTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task WithField_DispatchesStructuredValuesToPublishedYaml()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
        {
            Args = ["AppHost:Operation=publish", $"Pipeline:OutputPath={workspace.Path}", "Pipeline:Step=publish"],
            DisableDashboard = true
        });
        builder.AddKubernetesEnvironment("env");
        await using var handles = new HandleRegistry();
        var dispatcher = CreateDispatcher(handles);
 
        builder.AddContainer("api", "nginx:alpine")
            .PublishAsKubernetesService(service =>
            {
                var serviceHandle = handles.Marshal(service, "Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.KubernetesResource");
                var manifestHandle = dispatcher.Invoke("Aspire.Hosting.Kubernetes/addManifest", new JsonObject
                {
                    ["context"] = serviceHandle,
                    ["apiVersion"] = "cilium.io/v2",
                    ["kind"] = "CiliumNetworkPolicy",
                    ["name"] = "api-netpol"
                });
                Assert.NotNull(manifestHandle);
 
                void WithField(string path, JsonNode value)
                {
                    var result = dispatcher.Invoke("Aspire.Hosting.Kubernetes/withField", new JsonObject
                    {
                        ["context"] = manifestHandle.DeepClone(),
                        ["path"] = path,
                        ["value"] = value
                    });
                    Assert.NotNull(result);
                    Assert.True(handles.TryGet(manifestHandle["$handle"]!.GetValue<string>(), out var manifest, out _));
                    Assert.True(handles.TryGet(result["$handle"]!.GetValue<string>(), out var returnedManifest, out _));
                    Assert.Same(manifest, returnedManifest);
                }
 
                WithField("spec.endpointSelector.matchLabels", JsonNode.Parse("""{"app.kubernetes.io/component":"api"}""")!);
                WithField("spec.ingress", JsonNode.Parse("""
                    [
                      {
                        "fromEndpoints": [
                          { "matchLabels": { "app.kubernetes.io/component": "frontend" } }
                        ],
                        "toPorts": [
                          { "ports": [ { "port": "8080", "protocol": "TCP" } ] }
                        ]
                      }
                    ]
                    """)!);
                WithField("spec.egress", new JsonArray());
                WithField("spec.enableDefaultDeny.ingress", JsonValue.Create(true)!);
                WithField("spec.description", JsonValue.Create("API access")!);
                WithField("spec.description", JsonValue.Create("Frontend access to API")!);
            });
 
        using var app = builder.Build();
        app.Run();
 
        await Verify(await File.ReadAllTextAsync(Path.Combine(workspace.Path, "templates", "api", "netpol.yaml")), "yaml");
    }
 
    [Theory]
    [InlineData("direct")]
    [InlineData("object")]
    [InlineData("array")]
    [InlineData("nested")]
    public async Task WithField_DispatchRejectsNonJsonHandleValues(string shape)
    {
        await using var handles = new HandleRegistry();
        var dispatcher = CreateDispatcher(handles);
        var service = new KubernetesResource("api", new ContainerResource("api"), new KubernetesEnvironmentResource("env"));
        var manifestHandle = dispatcher.Invoke("Aspire.Hosting.Kubernetes/addManifest", new JsonObject
        {
            ["context"] = handles.Marshal(service, "Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.KubernetesResource"),
            ["apiVersion"] = "example.com/v1",
            ["kind"] = "Example",
            ["name"] = "example"
        });
 
        dispatcher.Invoke("Aspire.Hosting.Kubernetes/withField", new JsonObject
        {
            ["context"] = manifestHandle!.DeepClone(),
            ["path"] = "spec",
            ["value"] = "unchanged"
        });
        var handle = handles.Marshal(new ContainerResource("invalid"), "Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource");
        JsonNode value = shape switch
        {
            "object" => new JsonObject { ["label"] = handle },
            "array" => new JsonArray(handle),
            "nested" => new JsonObject { ["items"] = new JsonArray(new JsonObject { ["label"] = handle }) },
            _ => handle
        };
 
        var exception = Assert.Throws<CapabilityException>(() => dispatcher.Invoke(
            "Aspire.Hosting.Kubernetes/withField", new JsonObject
            {
                ["context"] = manifestHandle,
                ["path"] = "spec",
                ["value"] = value
            }));
 
        Assert.Equal(AtsErrorCodes.InvalidArgument, exception.Error.Code);
        Assert.StartsWith("Manifest field values must be JSON-compatible primitives, dictionaries, or arrays.", exception.Message);
        var preservedValueException = Assert.Throws<CapabilityException>(() => dispatcher.Invoke(
            "Aspire.Hosting.Kubernetes/withField", new JsonObject
            {
                ["context"] = manifestHandle.DeepClone(),
                ["path"] = "spec.child",
                ["value"] = true
            }));
        Assert.Equal(AtsErrorCodes.InvalidArgument, preservedValueException.Error.Code);
        Assert.StartsWith("Cannot set nested manifest field 'spec.child' because 'spec' already has a scalar value.", preservedValueException.Message);
    }
 
    [Theory]
    [InlineData("1e400")]
    [InlineData("""{"nested":[1e400]}""")]
    [InlineData("""[{"nested":-1e400}]""")]
    public async Task WithField_DispatchRejectsNonFiniteJsonNumbers(string json)
    {
        await using var handles = new HandleRegistry();
        var dispatcher = CreateDispatcher(handles);
        var service = new KubernetesResource("api", new ContainerResource("api"), new KubernetesEnvironmentResource("env"));
        var manifestHandle = dispatcher.Invoke("Aspire.Hosting.Kubernetes/addManifest", new JsonObject
        {
            ["context"] = handles.Marshal(service, "Aspire.Hosting.Kubernetes/Aspire.Hosting.Kubernetes.KubernetesResource"),
            ["apiVersion"] = "example.com/v1",
            ["kind"] = "Example",
            ["name"] = "example"
        });
 
        var exception = Assert.Throws<CapabilityException>(() => dispatcher.Invoke(
            "Aspire.Hosting.Kubernetes/withField", new JsonObject
            {
                ["context"] = manifestHandle,
                ["path"] = "spec",
                ["value"] = JsonNode.Parse(json)
            }));
 
        Assert.Equal(AtsErrorCodes.InvalidArgument, exception.Error.Code);
        Assert.StartsWith("Manifest field numeric values must be finite.", exception.Message);
    }
 
    [Theory]
    [InlineData("Go")]
    [InlineData("TypeScript")]
    [InlineData("Python")]
    [InlineData("Java")]
    [InlineData("Rust")]
    public async Task WithField_GeneratedSdkAcceptsStructuredValues(string language)
    {
        var scannedContext = AtsCapabilityScanner.ScanAssemblies([typeof(KubernetesResource).Assembly]).ToAtsContext();
        var capability = Assert.Single(scannedContext.Capabilities,
            capability => capability.CapabilityId == "Aspire.Hosting.Kubernetes/withField");
        var context = new AtsContext
        {
            Capabilities = [capability],
            HandleTypes = scannedContext.HandleTypes.Where(type => type.AtsTypeId.EndsWith(".KubernetesManifestResource", StringComparison.Ordinal)).ToList(),
            DtoTypes = [],
            EnumTypes = []
        };
        using var services = new ServiceCollection().BuildServiceProvider();
        var resolver = new CodeGeneratorResolver(services,
            () => [Assembly.Load($"Aspire.Hosting.CodeGeneration.{language}")],
            NullLogger<CodeGeneratorResolver>.Instance);
        var generator = Assert.IsAssignableFrom<ICodeGenerator>(resolver.GetCodeGenerator(language));
        var files = generator.GenerateDistributedApplication(context);
        var generated = Assert.Single(files, file => file.Value.Contains(capability.CapabilityId, StringComparison.Ordinal));
 
        var source = generated.Value;
        if (language == "Python")
        {
            // Python embeds its shared runtime in aspire_app.py; snapshot only the generated wrappers.
            var start = source.IndexOf("\nclass BaseKubernetesObject:", StringComparison.Ordinal);
            var end = source.IndexOf("\ndef create_builder(", start, StringComparison.Ordinal);
            source = source[start..end];
        }
 
        var extension = Path.GetExtension(generated.Key).TrimStart('.');
        await Verify(source.TrimEnd(), extension is "mts" ? "ts" : extension)
            .UseParameters(language);
    }
 
    private static CapabilityDispatcher CreateDispatcher(HandleRegistry handles)
    {
        var context = AtsCapabilityScanner.ScanAssemblies([typeof(KubernetesResource).Assembly]).ToAtsContext();
        var marshaller = new AtsMarshaller(handles, context, new CancellationTokenRegistry(),
            new Lazy<AtsCallbackProxyFactory>(() => throw new NotImplementedException()));
 
        return new CapabilityDispatcher(handles, marshaller, [typeof(KubernetesResource).Assembly]);
    }
}