File: ManifestGenerationTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using Aspire.Components.Common.Tests;
using Aspire.Hosting.Postgres;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Redis;
using Aspire.Hosting.Tests.Helpers;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
 
namespace Aspire.Hosting.Tests;
 
public class ManifestGenerationTests
{
    [Fact]
    public void EnsureAddParameterWithSecretFalseDoesntEmitSecretField()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
        program.AppBuilder.AddParameter("x", secret: false);
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
        var x = resources.GetProperty("x");
        var inputs = x.GetProperty("inputs");
        var value = inputs.GetProperty("value");
        Assert.False(value.TryGetProperty("secret", out _));
    }
 
    [Fact]
    public void EnsureAddParameterWithSecretDefaultDoesntEmitSecretField()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
        program.AppBuilder.AddParameter("x");
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
        var x = resources.GetProperty("x");
        var inputs = x.GetProperty("inputs");
        var value = inputs.GetProperty("value");
        Assert.False(value.TryGetProperty("secret", out _));
    }
 
    [Fact]
    public void EnsureAddParameterWithSecretTrueDoesEmitSecretField()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
        program.AppBuilder.AddParameter("x", secret: true);
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
        var x = resources.GetProperty("x");
        var inputs = x.GetProperty("inputs");
        var value = inputs.GetProperty("value");
        Assert.True(value.TryGetProperty("secret", out var secret));
        Assert.True(secret.GetBoolean());
    }
 
    [Fact]
    public void EnsureWorkerProjectDoesNotGetBindingsGenerated()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        var workerA = resources.GetProperty("workera");
        Assert.False(workerA.TryGetProperty("bindings", out _));
    }
 
    [Fact]
    public async Task WithContainerRegistryUpdatesContainerImageAnnotationsDuringPublish()
    {
        var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
        {
            Args = GetManifestArgs(),
            ContainerRegistryOverride = "myprivateregistry.company.com"
        });
 
        var redis = builder.AddContainer("redis", "redis");
        builder.Build().Run();
 
        var redisManifest = await ManifestUtils.GetManifest(redis.Resource).DefaultTimeout();
        var expectedManifest = $$"""
            {
              "type": "container.v0",
              "image": "myprivateregistry.company.com/redis:latest"
            }
            """;
        Assert.Equal(expectedManifest, redisManifest.ToString());
    }
 
    [Fact]
    public void EnsureExecutablesWithDockerfileProduceDockerfilev0Manifest()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
        program.AppBuilder
            .AddNodeApp("nodeapp", "fakePath")
            .WithHttpsEndpoint(targetPort: 3000, env: "HTTPS_PORT")
            .PublishAsDockerFile();
        program.AppBuilder
            .AddNpmApp("npmapp", "fakeDirectory");
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        // NPM app should still be executable.v0
        var npmapp = resources.GetProperty("npmapp");
        Assert.Equal("executable.v0", npmapp.GetProperty("type").GetString());
        Assert.DoesNotContain("\\", npmapp.GetProperty("workingDirectory").GetString());
 
        // Node app should now be dockerfile.v0
        var nodeapp = resources.GetProperty("nodeapp");
        Assert.Equal("dockerfile.v0", nodeapp.GetProperty("type").GetString());
        Assert.True(nodeapp.TryGetProperty("path", out _));
        Assert.True(nodeapp.TryGetProperty("context", out _));
        Assert.True(nodeapp.TryGetProperty("env", out var env));
        Assert.True(nodeapp.TryGetProperty("bindings", out var bindings));
 
        Assert.Equal(3000, bindings.GetProperty("https").GetProperty("targetPort").GetInt32());
        Assert.Equal("https", bindings.GetProperty("https").GetProperty("scheme").GetString());
        Assert.Equal("{nodeapp.bindings.https.targetPort}", env.GetProperty("HTTPS_PORT").GetString());
    }
 
    [Fact]
    public void ExcludeLaunchProfileOmitsBindings()
    {
        var appBuilder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
        { Args = GetManifestArgs(), DisableDashboard = true, AssemblyName = typeof(ManifestGenerationTests).Assembly.FullName });
 
        appBuilder.AddProject<Projects.ServiceA>("servicea", launchProfileName: null);
 
        appBuilder.Services.AddKeyedSingleton<IDistributedApplicationPublisher, JsonDocumentManifestPublisher>("manifest");
 
        using var program = appBuilder.Build();
        var publisher = program.Services.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        Assert.False(
            resources.GetProperty("servicea").TryGetProperty("bindings", out _),
            "Service has no bindings because they weren't populated from the launch profile.");
    }
 
    [Theory]
    [InlineData(new string[] { "args1", "args2" }, new string[] { "withArgs1", "withArgs2" })]
    [InlineData(new string[] { }, new string[] { "withArgs1", "withArgs2" })]
    [InlineData(new string[] { "args1", "args2" }, new string[] { })]
    public void EnsureExecutableWithArgsEmitsExecutableArgs(string[] addExecutableArgs, string[] withArgsArgs)
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
 
        var resourceBuilder = program.AppBuilder.AddExecutable("program", "run program", "c:/", addExecutableArgs);
        if (withArgsArgs.Length > 0)
        {
            resourceBuilder.WithArgs(withArgsArgs);
        }
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        var resource = resources.GetProperty("program");
        var args = resource.GetProperty("args");
        Assert.Equal(addExecutableArgs.Length + withArgsArgs.Length, args.GetArrayLength());
 
        var verify = new List<Action<JsonElement>>();
        foreach (var addExecutableArg in addExecutableArgs)
        {
            verify.Add(arg => Assert.Equal(addExecutableArg, arg.GetString()));
        }
 
        foreach (var withArgsArg in withArgsArgs)
        {
            verify.Add(arg => Assert.Equal(withArgsArg, arg.GetString()));
        }
 
        Assert.Collection(args.EnumerateArray(), [.. verify]);
    }
 
    [Fact]
    public void ExecutableManifestNotIncludeArgsWhenEmpty()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
 
        program.AppBuilder.AddExecutable("program", "run program", "c:/");
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        var resource = resources.GetProperty("program");
        var exists = resource.TryGetProperty("args", out _);
        Assert.False(exists);
    }
 
    [Fact]
    public void EnsureAllRedisManifestTypesHaveVersion0Suffix()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
 
        program.AppBuilder.AddRedis("rediscontainer");
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        var container = resources.GetProperty("rediscontainer");
        Assert.Equal("container.v0", container.GetProperty("type").GetString());
    }
 
    [Fact]
    public void PublishingRedisResourceAsContainerResultsInConnectionStringProperty()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
 
        program.AppBuilder.AddRedis("rediscontainer");
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        var container = resources.GetProperty("rediscontainer");
        Assert.Equal("container.v0", container.GetProperty("type").GetString());
        Assert.Equal("{rediscontainer.bindings.tcp.host}:{rediscontainer.bindings.tcp.port}", container.GetProperty("connectionString").GetString());
    }
 
    [Fact]
    public void EnsureAllPostgresManifestTypesHaveVersion0Suffix()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
 
        program.AppBuilder.AddPostgres("postgrescontainer").AddDatabase("postgresdatabase");
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        var server = resources.GetProperty("postgrescontainer");
        Assert.Equal("container.v0", server.GetProperty("type").GetString());
 
        var db = resources.GetProperty("postgresdatabase");
        Assert.Equal("value.v0", db.GetProperty("type").GetString());
    }
 
    [Fact]
    public void MetadataPropertyNotEmittedWhenMetadataNotAdded()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher();
 
        program.AppBuilder.AddContainer("testresource", "testresource");
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var resources = publisher.ManifestDocument.RootElement.GetProperty("resources");
 
        var container = resources.GetProperty("testresource");
        Assert.False(container.TryGetProperty("metadata", out var _));
    }
 
    [Fact]
    public void VerifyTestProgramFullManifest()
    {
        using var program = CreateTestProgramJsonDocumentManifestPublisher(includeIntegrationServices: true);
 
        program.AppBuilder.Services.Configure<PublishingOptions>(options =>
        {
            // set the output path so the paths are relative to the AppHostDirectory
            options.OutputPath = program.AppBuilder.AppHostDirectory;
        });
 
        // Build AppHost so that publisher can be resolved.
        program.Build();
        var publisher = program.GetManifestPublisher();
 
        program.Run();
 
        var expectedManifest = $$"""
            {
              "$schema": "{{SchemaUtils.SchemaVersion}}",
              "resources": {
                "servicea": {
                  "type": "project.v0",
                  "path": "testproject/TestProject.ServiceA/TestProject.ServiceA.csproj",
                  "env": {
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
                    "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
                    "HTTP_PORTS": "{servicea.bindings.http.targetPort}"
                  },
                  "bindings": {
                    "http": {
                      "scheme": "http",
                      "protocol": "tcp",
                      "transport": "http"
                    },
                    "https": {
                      "scheme": "https",
                      "protocol": "tcp",
                      "transport": "http"
                    }
                  }
                },
                "serviceb": {
                  "type": "project.v0",
                  "path": "testproject/TestProject.ServiceB/TestProject.ServiceB.csproj",
                  "env": {
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
                    "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
                    "HTTP_PORTS": "{serviceb.bindings.http.targetPort}"
                  },
                  "bindings": {
                    "http": {
                      "scheme": "http",
                      "protocol": "tcp",
                      "transport": "http"
                    },
                    "https": {
                      "scheme": "https",
                      "protocol": "tcp",
                      "transport": "http"
                    }
                  }
                },
                "servicec": {
                  "type": "project.v0",
                  "path": "testproject/TestProject.ServiceC/TestProject.ServiceC.csproj",
                  "env": {
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
                    "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
                    "Kestrel__Endpoints__http__Url": "http://*:{servicec.bindings.http.targetPort}"
                  },
                  "bindings": {
                    "http": {
                      "scheme": "http",
                      "protocol": "tcp",
                      "transport": "http",
                      "targetPort": 5271
                    },
                    "https": {
                      "scheme": "https",
                      "protocol": "tcp",
                      "transport": "http",
                      "targetPort": 5271
                    }
                  }
                },
                "workera": {
                  "type": "project.v0",
                  "path": "testproject/TestProject.WorkerA/TestProject.WorkerA.csproj",
                  "env": {
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory"
                  }
                },
                "integrationservicea": {
                  "type": "project.v0",
                  "path": "testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj",
                  "env": {
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
                    "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
                    "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
                    "HTTP_PORTS": "{integrationservicea.bindings.http.targetPort}",
                    "SKIP_RESOURCES": "None",
                    "ConnectionStrings__redis": "{redis.connectionString}",
                    "ConnectionStrings__postgresdb": "{postgresdb.connectionString}"
                  },
                  "bindings": {
                    "http": {
                      "scheme": "http",
                      "protocol": "tcp",
                      "transport": "http"
                    },
                    "https": {
                      "scheme": "https",
                      "protocol": "tcp",
                      "transport": "http"
                    }
                  }
                },
                "redis": {
                  "type": "container.v0",
                  "connectionString": "{redis.bindings.tcp.host}:{redis.bindings.tcp.port}",
                  "image": "{{ComponentTestConstants.AspireTestContainerRegistry}}/{{RedisContainerImageTags.Image}}:{{RedisContainerImageTags.Tag}}",
                  "bindings": {
                    "tcp": {
                      "scheme": "tcp",
                      "protocol": "tcp",
                      "transport": "tcp",
                      "targetPort": 6379
                    }
                  }
                },
                "postgres": {
                  "type": "container.v0",
                  "connectionString": "Host={postgres.bindings.tcp.host};Port={postgres.bindings.tcp.port};Username=postgres;Password={postgres-password.value}",
                  "image": "{{ComponentTestConstants.AspireTestContainerRegistry}}/{{PostgresContainerImageTags.Image}}:{{PostgresContainerImageTags.Tag}}",
                  "env": {
                    "POSTGRES_HOST_AUTH_METHOD": "scram-sha-256",
                    "POSTGRES_INITDB_ARGS": "--auth-host=scram-sha-256 --auth-local=scram-sha-256",
                    "POSTGRES_USER": "postgres",
                    "POSTGRES_PASSWORD": "{postgres-password.value}",
                    "POSTGRES_DB": "postgresdb"
                  },
                  "bindings": {
                    "tcp": {
                      "scheme": "tcp",
                      "protocol": "tcp",
                      "transport": "tcp",
                      "targetPort": 5432
                    }
                  }
                },
                "postgresdb": {
                  "type": "value.v0",
                  "connectionString": "{postgres.connectionString};Database=postgresdb"
                },
                "postgres-password": {
                  "type": "parameter.v0",
                  "value": "{postgres-password.inputs.value}",
                  "inputs": {
                    "value": {
                      "type": "string",
                      "secret": true,
                      "default": {
                        "generate": {
                          "minLength": 22
                        }
                      }
                    }
                  }
                }
              }
            }
            """;
        Assert.Equal(expectedManifest, publisher.ManifestDocument.RootElement.ToString());
    }
 
    [Fact]
    public async Task ParameterInputDefaultValuesGenerateCorrectly()
    {
        var appBuilder = DistributedApplication.CreateBuilder();
        var param = appBuilder.AddParameter("param");
        param.Resource.Default = new GenerateParameterDefault()
        {
            MinLength = 16,
            Lower = false,
            Upper = false,
            Numeric = false,
            Special = false,
            MinLower = 1,
            MinUpper = 2,
            MinNumeric = 3,
            MinSpecial = 4,
        };
 
        var expectedManifest = """
            {
              "type": "parameter.v0",
              "value": "{param.inputs.value}",
              "inputs": {
                "value": {
                  "type": "string",
                  "default": {
                    "generate": {
                      "minLength": 16,
                      "lower": false,
                      "upper": false,
                      "numeric": false,
                      "special": false,
                      "minLower": 1,
                      "minUpper": 2,
                      "minNumeric": 3,
                      "minSpecial": 4
                    }
                  }
                }
              }
            }
            """{
              "type": "parameter.v0",
              "value": "{param.inputs.value}",
              "inputs": {
                "value": {
                  "type": "string",
                  "default": {
                    "generate": {
                      "minLength": 16,
                      "lower": false,
                      "upper": false,
                      "numeric": false,
                      "special": false,
                      "minLower": 1,
                      "minUpper": 2,
                      "minNumeric": 3,
                      "minSpecial": 4
                    }
                  }
                }
              }
            }
            """;
 
        var manifest = await ManifestUtils.GetManifest(param.Resource).DefaultTimeout();
        Assert.Equal(expectedManifest, manifest.ToString());
    }
 
    private static TestProgram CreateTestProgramJsonDocumentManifestPublisher(bool includeIntegrationServices = false, bool includeNodeApp = false)
    {
        var program = TestProgram.Create<ManifestGenerationTests>(GetManifestArgs(), includeIntegrationServices, includeNodeApp);
        program.AppBuilder.Services.AddKeyedSingleton<IDistributedApplicationPublisher, JsonDocumentManifestPublisher>("manifest");
        return program;
    }
 
    private static string[] GetManifestArgs()
    {
        var manifestPath = Path.GetTempFileName();
        return ["--publisher", "manifest", "--output-path", manifestPath];
    }
}