diff --git a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json index 254abacd9f3..89326b3c649 100644 --- a/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json +++ b/playground/AzureContainerApps/AzureContainerApps.AppHost/aspire-manifest.json @@ -152,7 +152,7 @@ "CACHE_HOST": "{cache.bindings.tcp.host}", "CACHE_PORT": "{cache.bindings.tcp.port}", "CACHE_PASSWORD": "{cache-password.value}", - "CACHE_URI": "redis://:{cache-password.value}@{cache.bindings.tcp.host}:{cache.bindings.tcp.port}", + "CACHE_URI": "redis://:{cache-password-uri-encoded.value}@{cache.bindings.tcp.host}:{cache.bindings.tcp.port}", "ConnectionStrings__account": "{account.connectionString}", "VALUE": "{secretparam.value}", "AZURE_PRINCIPAL_NAME": "{api-identity.outputs.principalName}" @@ -207,6 +207,11 @@ } } } + }, + "cache-password-uri-encoded": { + "type": "annotated.string", + "value": "{cache-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/DatabaseMigration/DatabaseMigration.AppHost/aspire-manifest.json b/playground/DatabaseMigration/DatabaseMigration.AppHost/aspire-manifest.json index f6920683dcc..ec10689a718 100644 --- a/playground/DatabaseMigration/DatabaseMigration.AppHost/aspire-manifest.json +++ b/playground/DatabaseMigration/DatabaseMigration.AppHost/aspire-manifest.json @@ -36,7 +36,7 @@ "DB1_PORT": "{sql1.bindings.tcp.port}", "DB1_USERNAME": "sa", "DB1_PASSWORD": "{sql1-password.value}", - "DB1_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql1.bindings.tcp.host}:{sql1.bindings.tcp.port};user=sa;password={sql1-password.value};databaseName=db1;trustServerCertificate=true", + "DB1_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql1.bindings.tcp.host}:{sql1.bindings.tcp.port};user=sa;password={sql1-password-uri-encoded.value};databaseName=db1;trustServerCertificate=true", "DB1_DATABASE": "db1" }, "bindings": { @@ -66,7 +66,7 @@ "DB1_PORT": "{sql1.bindings.tcp.port}", "DB1_USERNAME": "sa", "DB1_PASSWORD": "{sql1-password.value}", - "DB1_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql1.bindings.tcp.host}:{sql1.bindings.tcp.port};user=sa;password={sql1-password.value};databaseName=db1;trustServerCertificate=true", + "DB1_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql1.bindings.tcp.host}:{sql1.bindings.tcp.port};user=sa;password={sql1-password-uri-encoded.value};databaseName=db1;trustServerCertificate=true", "DB1_DATABASE": "db1" } }, @@ -87,6 +87,11 @@ } } } + }, + "sql1-password-uri-encoded": { + "type": "annotated.string", + "value": "{sql1-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/OracleEndToEnd/OracleEndToEnd.AppHost/aspire-manifest.json b/playground/OracleEndToEnd/OracleEndToEnd.AppHost/aspire-manifest.json index 2e30b6572a6..1aaade74c4c 100644 --- a/playground/OracleEndToEnd/OracleEndToEnd.AppHost/aspire-manifest.json +++ b/playground/OracleEndToEnd/OracleEndToEnd.AppHost/aspire-manifest.json @@ -35,7 +35,7 @@ "FREEPDB1_PORT": "{oracle.bindings.tcp.port}", "FREEPDB1_USERNAME": "system", "FREEPDB1_PASSWORD": "{oracle-password.value}", - "FREEPDB1_JDBCCONNECTIONSTRING": "jdbc:oracle:thin:system/{oracle-password.value}@//{oracle.bindings.tcp.host}:{oracle.bindings.tcp.port}/FREEPDB1", + "FREEPDB1_JDBCCONNECTIONSTRING": "jdbc:oracle:thin:system/{oracle-password-uri-encoded.value}@//{oracle.bindings.tcp.host}:{oracle.bindings.tcp.port}/FREEPDB1", "FREEPDB1_DATABASE": "FREEPDB1" }, "bindings": { @@ -65,6 +65,11 @@ } } } + }, + "oracle-password-uri-encoded": { + "type": "annotated.string", + "value": "{oracle-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/ParameterEndToEnd/ParameterEndToEnd.AppHost/aspire-manifest.json b/playground/ParameterEndToEnd/ParameterEndToEnd.AppHost/aspire-manifest.json index d4af58f35a4..1c9e9e374b7 100644 --- a/playground/ParameterEndToEnd/ParameterEndToEnd.AppHost/aspire-manifest.json +++ b/playground/ParameterEndToEnd/ParameterEndToEnd.AppHost/aspire-manifest.json @@ -94,7 +94,7 @@ "DB_PORT": "{sql.bindings.tcp.port}", "DB_USERNAME": "sa", "DB_PASSWORD": "{sql-password.value}", - "DB_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql.bindings.tcp.host}:{sql.bindings.tcp.port};user=sa;password={sql-password.value};databaseName=db;trustServerCertificate=true", + "DB_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql.bindings.tcp.host}:{sql.bindings.tcp.port};user=sa;password={sql-password-uri-encoded.value};databaseName=db;trustServerCertificate=true", "DB_DATABASE": "db" }, "bindings": { @@ -129,6 +129,11 @@ } } } + }, + "sql-password-uri-encoded": { + "type": "annotated.string", + "value": "{sql-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json b/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json index 02bbb980ea3..3ce15884072 100644 --- a/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json +++ b/playground/ProxylessEndToEnd/ProxylessEndToEnd.AppHost/aspire-manifest.json @@ -36,7 +36,7 @@ "REDIS_HOST": "{redis.bindings.tcp.host}", "REDIS_PORT": "{redis.bindings.tcp.port}", "REDIS_PASSWORD": "{redis-password.value}", - "REDIS_URI": "redis://:{redis-password.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}" + "REDIS_URI": "redis://:{redis-password-uri-encoded.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}" }, "bindings": { "http": { @@ -65,7 +65,7 @@ "REDIS_HOST": "{redis.bindings.tcp.host}", "REDIS_PORT": "{redis.bindings.tcp.port}", "REDIS_PASSWORD": "{redis-password.value}", - "REDIS_URI": "redis://:{redis-password.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}" + "REDIS_URI": "redis://:{redis-password-uri-encoded.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}" }, "bindings": { "http": { @@ -91,6 +91,11 @@ } } } + }, + "redis-password-uri-encoded": { + "type": "annotated.string", + "value": "{redis-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/Redis/Redis.AppHost/aspire-manifest.json b/playground/Redis/Redis.AppHost/aspire-manifest.json index cdde456818c..4399cccdd0b 100644 --- a/playground/Redis/Redis.AppHost/aspire-manifest.json +++ b/playground/Redis/Redis.AppHost/aspire-manifest.json @@ -98,17 +98,17 @@ "REDIS_HOST": "{redis.bindings.tcp.host}", "REDIS_PORT": "{redis.bindings.tcp.port}", "REDIS_PASSWORD": "{redis-password.value}", - "REDIS_URI": "redis://:{redis-password.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "REDIS_URI": "redis://:{redis-password-uri-encoded.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", "ConnectionStrings__garnet": "{garnet.connectionString}", "GARNET_HOST": "{garnet.bindings.tcp.host}", "GARNET_PORT": "{garnet.bindings.tcp.port}", "GARNET_PASSWORD": "{garnet-password.value}", - "GARNET_URI": "redis://:{garnet-password.value}@{garnet.bindings.tcp.host}:{garnet.bindings.tcp.port}", + "GARNET_URI": "redis://:{garnet-password-uri-encoded.value}@{garnet.bindings.tcp.host}:{garnet.bindings.tcp.port}", "ConnectionStrings__valkey": "{valkey.connectionString}", "VALKEY_HOST": "{valkey.bindings.tcp.host}", "VALKEY_PORT": "{valkey.bindings.tcp.port}", "VALKEY_PASSWORD": "{valkey-password.value}", - "VALKEY_URI": "valkey://:{valkey-password.value}@{valkey.bindings.tcp.host}:{valkey.bindings.tcp.port}" + "VALKEY_URI": "valkey://:{valkey-password-uri-encoded.value}@{valkey.bindings.tcp.host}:{valkey.bindings.tcp.port}" }, "bindings": { "http": { @@ -141,6 +141,11 @@ } } }, + "redis-password-uri-encoded": { + "type": "annotated.string", + "value": "{redis-password.value}", + "filter": "uri" + }, "garnet-password": { "type": "parameter.v0", "value": "{garnet-password.inputs.value}", @@ -157,6 +162,11 @@ } } }, + "garnet-password-uri-encoded": { + "type": "annotated.string", + "value": "{garnet-password.value}", + "filter": "uri" + }, "valkey-password": { "type": "parameter.v0", "value": "{valkey-password.inputs.value}", @@ -172,6 +182,11 @@ } } } + }, + "valkey-password-uri-encoded": { + "type": "annotated.string", + "value": "{valkey-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/TestShop/TestShop.AppHost/aspire-manifest.json b/playground/TestShop/TestShop.AppHost/aspire-manifest.json index 181fd2d449e..1da88c2b39f 100644 --- a/playground/TestShop/TestShop.AppHost/aspire-manifest.json +++ b/playground/TestShop/TestShop.AppHost/aspire-manifest.json @@ -123,7 +123,7 @@ }, "messaging": { "type": "container.v0", - "connectionString": "amqp://guest:{messaging-password.value}@{messaging.bindings.tcp.host}:{messaging.bindings.tcp.port}", + "connectionString": "amqp://guest:{messaging-password-uri-encoded.value}@{messaging.bindings.tcp.host}:{messaging.bindings.tcp.port}", "image": "docker.io/library/rabbitmq:4.1-management", "volumes": [ { @@ -164,13 +164,13 @@ "BASKETCACHE_HOST": "{basketcache.bindings.tcp.host}", "BASKETCACHE_PORT": "{basketcache.bindings.tcp.port}", "BASKETCACHE_PASSWORD": "{basketcache-password.value}", - "BASKETCACHE_URI": "redis://:{basketcache-password.value}@{basketcache.bindings.tcp.host}:{basketcache.bindings.tcp.port}", + "BASKETCACHE_URI": "redis://:{basketcache-password-uri-encoded.value}@{basketcache.bindings.tcp.host}:{basketcache.bindings.tcp.port}", "ConnectionStrings__messaging": "{messaging.connectionString}", "MESSAGING_HOST": "{messaging.bindings.tcp.host}", "MESSAGING_PORT": "{messaging.bindings.tcp.port}", "MESSAGING_USERNAME": "guest", "MESSAGING_PASSWORD": "{messaging-password.value}", - "MESSAGING_URI": "amqp://guest:{messaging-password.value}@{messaging.bindings.tcp.host}:{messaging.bindings.tcp.port}" + "MESSAGING_URI": "amqp://guest:{messaging-password-uri-encoded.value}@{messaging.bindings.tcp.host}:{messaging.bindings.tcp.port}" }, "bindings": { "http": { @@ -226,7 +226,7 @@ "MESSAGING_PORT": "{messaging.bindings.tcp.port}", "MESSAGING_USERNAME": "guest", "MESSAGING_PASSWORD": "{messaging-password.value}", - "MESSAGING_URI": "amqp://guest:{messaging-password.value}@{messaging.bindings.tcp.host}:{messaging.bindings.tcp.port}" + "MESSAGING_URI": "amqp://guest:{messaging-password-uri-encoded.value}@{messaging.bindings.tcp.host}:{messaging.bindings.tcp.port}" } }, "apigateway": { @@ -273,6 +273,11 @@ } } }, + "postgres-password-uri-encoded": { + "type": "annotated.string", + "value": "{postgres-password.value}", + "filter": "uri" + }, "basketcache-password": { "type": "parameter.v0", "value": "{basketcache-password.inputs.value}", @@ -289,6 +294,11 @@ } } }, + "basketcache-password-uri-encoded": { + "type": "annotated.string", + "value": "{basketcache-password.value}", + "filter": "uri" + }, "messaging-password": { "type": "parameter.v0", "value": "{messaging-password.inputs.value}", @@ -304,6 +314,11 @@ } } } + }, + "messaging-password-uri-encoded": { + "type": "annotated.string", + "value": "{messaging-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/mongo/Mongo.AppHost/aspire-manifest.json b/playground/mongo/Mongo.AppHost/aspire-manifest.json index a1abfefc663..a85bf970869 100644 --- a/playground/mongo/Mongo.AppHost/aspire-manifest.json +++ b/playground/mongo/Mongo.AppHost/aspire-manifest.json @@ -3,7 +3,7 @@ "resources": { "mongo": { "type": "container.v0", - "connectionString": "mongodb://admin:{mongo-password.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}?authSource=admin\u0026authMechanism=SCRAM-SHA-256", + "connectionString": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}?authSource=admin\u0026authMechanism=SCRAM-SHA-256", "image": "docker.io/library/mongo:8.0", "env": { "MONGO_INITDB_ROOT_USERNAME": "admin", @@ -20,7 +20,7 @@ }, "db": { "type": "value.v0", - "connectionString": "mongodb://admin:{mongo-password.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/db?authSource=admin\u0026authMechanism=SCRAM-SHA-256" + "connectionString": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/db?authSource=admin\u0026authMechanism=SCRAM-SHA-256" }, "api": { "type": "project.v0", @@ -38,7 +38,7 @@ "DB_PASSWORD": "{mongo-password.value}", "DB_AUTHENTICATIONDATABASE": "admin", "DB_AUTHENTICATIONMECHANISM": "SCRAM-SHA-256", - "DB_URI": "mongodb://admin:{mongo-password.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/db?authSource=admin\u0026authMechanism=SCRAM-SHA-256", + "DB_URI": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/db?authSource=admin\u0026authMechanism=SCRAM-SHA-256", "DB_DATABASE": "db" }, "bindings": { @@ -71,6 +71,11 @@ } } } + }, + "mongo-password-uri-encoded": { + "type": "annotated.string", + "value": "{mongo-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/mysql/MySqlDb.AppHost/aspire-manifest.json b/playground/mysql/MySqlDb.AppHost/aspire-manifest.json index 3b8725cbb74..ca43b72f091 100644 --- a/playground/mysql/MySqlDb.AppHost/aspire-manifest.json +++ b/playground/mysql/MySqlDb.AppHost/aspire-manifest.json @@ -52,7 +52,7 @@ "CATALOG_USERNAME": "root", "CATALOG_PASSWORD": "{mysql-password.value}", "CATALOG_URI": "mysql://root:{mysql-password.value}@{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/catalog", - "CATALOG_JDBCCONNECTIONSTRING": "jdbc:mysql://{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/catalog?user=root\u0026password={mysql-password.value}", + "CATALOG_JDBCCONNECTIONSTRING": "jdbc:mysql://{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/catalog?user=root\u0026password={mysql-password-uri-encoded.value}", "CATALOG_DATABASE": "catalog", "ConnectionStrings__myTestDb": "{myTestDb.connectionString}", "MYTESTDB_HOST": "{mysql.bindings.tcp.host}", @@ -60,7 +60,7 @@ "MYTESTDB_USERNAME": "root", "MYTESTDB_PASSWORD": "{mysql-password.value}", "MYTESTDB_URI": "mysql://root:{mysql-password.value}@{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/myTestDb", - "MYTESTDB_JDBCCONNECTIONSTRING": "jdbc:mysql://{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/myTestDb?user=root\u0026password={mysql-password.value}", + "MYTESTDB_JDBCCONNECTIONSTRING": "jdbc:mysql://{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/myTestDb?user=root\u0026password={mysql-password-uri-encoded.value}", "MYTESTDB_DATABASE": "myTestDb", "ConnectionStrings__myTestDb2": "{myTestDb2.connectionString}", "MYTESTDB2_HOST": "{mysql.bindings.tcp.host}", @@ -68,7 +68,7 @@ "MYTESTDB2_USERNAME": "root", "MYTESTDB2_PASSWORD": "{mysql-password.value}", "MYTESTDB2_URI": "mysql://root:{mysql-password.value}@{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/myTestDb2", - "MYTESTDB2_JDBCCONNECTIONSTRING": "jdbc:mysql://{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/myTestDb2?user=root\u0026password={mysql-password.value}", + "MYTESTDB2_JDBCCONNECTIONSTRING": "jdbc:mysql://{mysql.bindings.tcp.host}:{mysql.bindings.tcp.port}/myTestDb2?user=root\u0026password={mysql-password-uri-encoded.value}", "MYTESTDB2_DATABASE": "myTestDb2" }, "bindings": { @@ -100,6 +100,11 @@ } } } + }, + "mysql-password-uri-encoded": { + "type": "annotated.string", + "value": "{mysql-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/nats/Nats.AppHost/aspire-manifest.json b/playground/nats/Nats.AppHost/aspire-manifest.json index 72338afed52..8d0c7243bf5 100644 --- a/playground/nats/Nats.AppHost/aspire-manifest.json +++ b/playground/nats/Nats.AppHost/aspire-manifest.json @@ -3,7 +3,7 @@ "resources": { "nats": { "type": "container.v0", - "connectionString": "nats://nats:{nats-password.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", + "connectionString": "nats://nats:{nats-password-uri-encoded.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", "image": "docker.io/library/nats:2.11", "args": [ "--user", @@ -35,7 +35,7 @@ "NATS_PORT": "{nats.bindings.tcp.port}", "NATS_USERNAME": "nats", "NATS_PASSWORD": "{nats-password.value}", - "NATS_URI": "nats://nats:{nats-password.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}" + "NATS_URI": "nats://nats:{nats-password-uri-encoded.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}" }, "bindings": { "http": { @@ -66,7 +66,7 @@ "NATS_PORT": "{nats.bindings.tcp.port}", "NATS_USERNAME": "nats", "NATS_PASSWORD": "{nats-password.value}", - "NATS_URI": "nats://nats:{nats-password.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}" + "NATS_URI": "nats://nats:{nats-password-uri-encoded.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}" }, "bindings": { "http": { @@ -96,6 +96,11 @@ } } } + }, + "nats-password-uri-encoded": { + "type": "annotated.string", + "value": "{nats-password.value}", + "filter": "uri" } } } \ No newline at end of file diff --git a/playground/webpubsub/WebPubSub.AppHost/aspire-manifest.json b/playground/webpubsub/WebPubSub.AppHost/aspire-manifest.json index 341a2927c39..f3a1e4acb1a 100644 --- a/playground/webpubsub/WebPubSub.AppHost/aspire-manifest.json +++ b/playground/webpubsub/WebPubSub.AppHost/aspire-manifest.json @@ -48,10 +48,4 @@ "ChatForAspire": { "type": "value.v0", "connectionString": "Endpoint={wps1.outputs.endpoint};Hub=ChatForAspire" - }, - "NotificationForAspire": { - "type": "value.v0", - "connectionString": "Endpoint={wps1.outputs.endpoint};Hub=NotificationForAspire" - } - } -} \ No newline at end of file + } \ No newline at end of file diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 31b9ecdec09..1b53f4c7183 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Runtime.ExceptionServices; +using System.Text; using System.Text.Json; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; @@ -45,6 +47,10 @@ public sealed class ManifestPublishingContext(DistributedApplicationExecutionCon private readonly HashSet _currentDependencySet = []; + private readonly Dictionary> _formattedParameters = []; + + private readonly HashSet _manifestResourceNames = new(StringComparers.ResourceName); + /// /// Generates a relative path based on the location of the manifest path. /// @@ -70,6 +76,14 @@ public sealed class ManifestPublishingContext(DistributedApplicationExecutionCon internal async Task WriteModel(DistributedApplicationModel model, CancellationToken cancellationToken) { + _formattedParameters.Clear(); + _manifestResourceNames.Clear(); + + foreach (var resource in model.Resources) + { + _manifestResourceNames.Add(resource.Name); + } + Writer.WriteStartObject(); Writer.WriteString("$schema", SchemaUtils.SchemaVersion); Writer.WriteStartObject("resources"); @@ -81,10 +95,15 @@ internal async Task WriteModel(DistributedApplicationModel model, CancellationTo await WriteReferencedResources(model).ConfigureAwait(false); + WriteRemainingFormattedParameters(); + Writer.WriteEndObject(); Writer.WriteEndObject(); await Writer.FlushAsync(cancellationToken).ConfigureAwait(false); + + _formattedParameters.Clear(); + _manifestResourceNames.Clear(); } internal async Task WriteResourceAsync(IResource resource) @@ -128,6 +147,11 @@ async Task WriteResourceObjectAsync(T resource, Func action) where T : Writer.WriteStartObject(resource.Name); await action().ConfigureAwait(false); Writer.WriteEndObject(); + + if (resource is ParameterResource parameterResource) + { + WriteFormattedParameterResources(parameterResource); + } } } @@ -338,12 +362,14 @@ private async Task WriteBuildContextAsync(ContainerResource container) var valueString = value switch { string stringValue => stringValue, - IManifestExpressionProvider manifestExpression => manifestExpression.ValueExpression, + IManifestExpressionProvider manifestExpression => GetManifestExpression(manifestExpression, manifestExpression.ValueExpression), bool boolValue => boolValue ? "true" : "false", null => null, // null means let docker build pull from env var. _ => value.ToString() }; + TryAddDependentResources(value); + Writer.WriteString(key, valueString); } @@ -360,7 +386,7 @@ private async Task WriteBuildContextAsync(ContainerResource container) { FileInfo fileValue => GetManifestRelativePath(fileValue.FullName), string stringValue => stringValue, - IManifestExpressionProvider manifestExpression => manifestExpression.ValueExpression, + IManifestExpressionProvider manifestExpression => GetManifestExpression(manifestExpression, manifestExpression.ValueExpression), bool boolValue => boolValue ? "true" : "false", null => null, // null means let docker build pull from env var. _ => value.ToString() @@ -380,6 +406,8 @@ private async Task WriteBuildContextAsync(ContainerResource container) } Writer.WriteEndObject(); + + TryAddDependentResources(value); } Writer.WriteEndObject(); @@ -398,7 +426,8 @@ public void WriteConnectionString(IResource resource) if (resource is IResourceWithConnectionString resourceWithConnectionString && resourceWithConnectionString.ConnectionStringExpression is { } connectionString) { - Writer.WriteString("connectionString", connectionString.ValueExpression); + TryAddDependentResources(connectionString); + Writer.WriteString("connectionString", GetManifestExpression(connectionString)); } } @@ -529,7 +558,9 @@ await resource.ProcessEnvironmentVariableValuesAsync( { var (unprocessed, processed) = value; - Writer.WriteString(key, processed); + var manifestExpression = GetManifestExpression(unprocessed, processed); + + Writer.WriteString(key, manifestExpression); TryAddDependentResources(unprocessed); } @@ -570,7 +601,9 @@ await resource.ProcessArgumentValuesAsync( foreach (var (unprocessed, expression) in args) { - Writer.WriteStringValue(expression); + var manifestExpression = GetManifestExpression(unprocessed, expression); + + Writer.WriteStringValue(manifestExpression); TryAddDependentResources(unprocessed); } @@ -646,11 +679,19 @@ private void WriteContainerMounts(ContainerResource container) /// The object to check for references that may be resources that need to be written. public void TryAddDependentResources(object? value) { + if (value is ReferenceExpression referenceExpression) + { + RegisterFormattedParameters(referenceExpression); + } + if (value is IResource resource) { // add the resource to the ReferencedResources for now. After the whole model is written, // these will be written to the manifest - _referencedResources.TryAdd(resource.Name, resource); + if (_referencedResources.TryAdd(resource.Name, resource)) + { + _manifestResourceNames.Add(resource.Name); + } } else if (value is IValueWithReferences objectWithReferences) { @@ -667,6 +708,176 @@ public void TryAddDependentResources(object? value) } } + private string GetManifestExpression(object? source, string expression) + { + return source switch + { + ReferenceExpression referenceExpression => GetManifestExpression(referenceExpression), + _ => expression + }; + } + + private string GetManifestExpression(ReferenceExpression referenceExpression) + { + var arguments = new string[referenceExpression.ManifestExpressions.Count]; + + for (var i = 0; i < arguments.Length; i++) + { + var expression = referenceExpression.ManifestExpressions[i]; + var format = referenceExpression.StringFormats[i]; + + if (!string.IsNullOrEmpty(format)) + { + if (GetFormattedResourceNameForProvider(referenceExpression.ValueProviders[i], format) is { } formattedResourceName) + { + expression = $"{{{formattedResourceName}.value}}"; + } + } + + arguments[i] = expression; + } + + return string.Format(CultureInfo.InvariantCulture, referenceExpression.Format, arguments); + } + + private void RegisterFormattedParameters(ReferenceExpression referenceExpression) + { + var providers = referenceExpression.ValueProviders; + var formats = referenceExpression.StringFormats; + + for (var i = 0; i < providers.Count; i++) + { + var format = formats[i]; + + if (string.IsNullOrEmpty(format)) + { + continue; + } + + _ = GetFormattedResourceNameForProvider(providers[i], format); + } + } + + private string RegisterFormattedParameter(ParameterResource parameter, string format) + { + if (!_formattedParameters.TryGetValue(parameter, out var formats)) + { + formats = new Dictionary(StringComparer.Ordinal); + _formattedParameters[parameter] = formats; + } + + if (!formats.TryGetValue(format, out var resourceName)) + { + resourceName = CreateFormattedParameterResourceName(parameter.Name, format); + formats[format] = resourceName; + } + + return resourceName; + } + + private string CreateFormattedParameterResourceName(string parameterName, string format) + { + var sanitizedFormat = SanitizeFormat(format); + var baseName = $"{parameterName}-{sanitizedFormat}-encoded"; + var candidate = baseName; + var suffix = 1; + + while (_manifestResourceNames.Contains(candidate)) + { + candidate = $"{baseName}-{suffix++}"; + } + + _manifestResourceNames.Add(candidate); + return candidate; + } + + private static string SanitizeFormat(string format) + { + if (string.IsNullOrEmpty(format)) + { + return "formatted"; + } + + var builder = new StringBuilder(format.Length); + var lastWasSeparator = false; + + foreach (var ch in format) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(char.ToLowerInvariant(ch)); + lastWasSeparator = false; + } + else if (!lastWasSeparator) + { + builder.Append('-'); + lastWasSeparator = true; + } + } + + var sanitized = builder.ToString().Trim('-'); + return sanitized.Length > 0 ? sanitized : "formatted"; + } + + private void WriteFormattedParameterResources(ParameterResource parameter) + { + if (!_formattedParameters.TryGetValue(parameter, out var formats)) + { + return; + } + + foreach (var (format, resourceName) in formats) + { + Writer.WriteStartObject(resourceName); + Writer.WriteString("type", "annotated.string"); + Writer.WriteString("value", parameter.ValueExpression); + Writer.WriteString("filter", format); + Writer.WriteEndObject(); + } + + _formattedParameters.Remove(parameter); + } + + private void WriteRemainingFormattedParameters() + { + if (_formattedParameters.Count == 0) + { + return; + } + + var pending = new List(_formattedParameters.Keys); + + foreach (var parameter in pending) + { + WriteFormattedParameterResources(parameter); + } + } + + private string? GetFormattedResourceNameForProvider(object provider, string format) + { + return provider switch + { + ParameterResource parameter => RegisterFormattedParameter(parameter, format), + ReferenceExpression referenceExpression when TryGetSingleParameterProvider(referenceExpression, out var parameter) => RegisterFormattedParameter(parameter, format), + _ => null + }; + } + + private static bool TryGetSingleParameterProvider(ReferenceExpression referenceExpression, out ParameterResource parameter) + { + if (referenceExpression.ValueProviders.Count == 1 && + referenceExpression.ValueProviders[0] is ParameterResource parameterResource && + referenceExpression.ManifestExpressions.Count == 1 && + referenceExpression.Format == "{0}") + { + parameter = parameterResource; + return true; + } + + parameter = null!; + return false; + } + private async Task WriteReferencedResources(DistributedApplicationModel model) { // remove references that were already in the model and were already written diff --git a/src/Schema/aspire-13.0.json b/src/Schema/aspire-13.0.json new file mode 100644 index 00000000000..8346146b368 --- /dev/null +++ b/src/Schema/aspire-13.0.json @@ -0,0 +1,741 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://json.schemastore.org/aspire-8.0.json", + "type": "object", + "description": "Defines the .NET Aspire 13.0 deployment manifest JSON schema.", + "required": ["resources"], + "properties": { + "resources": { + "type": "object", + "description": "Contains the set of resources deployable as part of this manifest. Each property is a distinct resource.", + "additionalProperties": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string" + } + }, + "oneOf": [ + { + "type": "object", + "description": "A resource that represents a Dockerfile that will be built into a container during deployment.", + "required": [ "type", "path", "context" ], + "properties": { + "type": { + "const": "dockerfile.v0" + }, + "path": { + "type": "string", + "description": "The file path to the Dockerfile to be built into a container image." + }, + "context": { + "type": "string", + "description": "A directory path used as the context for building the Dockerfile into a container image." + }, + "env": { + "$ref": "#/definitions/env" + }, + "bindings": { + "$ref": "#/definitions/bindings" + }, + "buildArgs": { + "$ref": "#/definitions/buildArgs" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "A generic container resource.", + "required": [ "type", "image" ], + "properties": { + "type": { + "const": "container.v0" + }, + "image": { + "type": "string", + "description": "A string representing the container image to be used." + }, + "entrypoint": { + "type": "string", + "description": "The entrypoint to use for the container image when executed." + }, + "args": { + "$ref": "#/definitions/args" + }, + "connectionString": { + "$ref": "#/definitions/connectionString" + }, + "env": { + "$ref": "#/definitions/env" + }, + "bindings": { + "$ref": "#/definitions/bindings" + }, + "bindMounts": { + "$ref": "#/definitions/bindMounts" + }, + "volumes": { + "$ref": "#/definitions/volumes" + }, + "build": false + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "A generic container resource.", + "oneOf": [ + { + "required": [ "type", "build" ] + }, + { + "required": [ "type", "image" ] + } + ], + "properties": { + "type": { + "const": "container.v1" + }, + "image": { + "type": "string", + "description": "A string representing the container image to be used." + }, + "entrypoint": { + "type": "string", + "description": "The entrypoint to use for the container image when executed." + }, + "deployment": { + "oneOf": [ + { + "$ref": "#/definitions/resource.azure.bicep.v0" + }, + { + "$ref": "#/definitions/resource.azure.bicep.v1" + } + ] + }, + "args": { + "$ref": "#/definitions/args" + }, + "build": { + "$ref": "#/definitions/build" + }, + "connectionString": { + "$ref": "#/definitions/connectionString" + }, + "env": { + "$ref": "#/definitions/env" + }, + "bindings": { + "$ref": "#/definitions/bindings" + }, + "bindMounts": { + "$ref": "#/definitions/bindMounts" + }, + "volumes": { + "$ref": "#/definitions/volumes" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "Represents a .NET project resource.", + "required": [ "type", "path" ], + "properties": { + "type": { + "const": "project.v0" + }, + "path": { + "type": "string", + "description": "The path to the project file. Relative paths are interpreted as being relative to the location of the manifest file." + }, + "args": { + "$ref": "#/definitions/args" + }, + "env": { + "$ref": "#/definitions/env" + }, + "bindings": { + "$ref": "#/definitions/bindings" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "Represents a .NET project resource.", + "required": [ "type", "path" ], + "properties": { + "type": { + "const": "project.v1" + }, + "path": { + "type": "string", + "description": "The path to the project file. Relative paths are interpreted as being relative to the location of the manifest file." + }, + "deployment": { + "oneOf": [ + { + "$ref": "#/definitions/resource.azure.bicep.v0" + }, + { + "$ref": "#/definitions/resource.azure.bicep.v1" + } + ] + }, + "args": { + "$ref": "#/definitions/args" + }, + "env": { + "$ref": "#/definitions/env" + }, + "bindings": { + "$ref": "#/definitions/bindings" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "Represents an executable resource.", + "required": [ "type", "command", "workingDirectory" ], + "properties": { + "type": { + "const": "executable.v0" + }, + "workingDirectory": { + "type": "string", + "description": "The path to the working directory. Should be interpreted as being relative to the AppHost directory." + }, + "command": { + "type": "string", + "description": "The path to the command. Should be interpreted as being relative to the AppHost directory." + }, + "args": { + "$ref": "#/definitions/args" + }, + "env": { + "$ref": "#/definitions/env" + }, + "bindings": { + "$ref": "#/definitions/bindings" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "connectionString" ], + "description": "Represents a value resource. Typically used to perform string concatenation (e.g. for connection strings).", + "properties": { + "type": { + "const": "value.v0" + }, + "connectionString": { + "$ref": "#/definitions/connectionString" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "Represents a parameter resource. Parameter resources are used to represent external configuration values that should be provided at deployment time.", + "required": [ "value", "inputs" ], + "properties": { + "type": { + "const": "parameter.v0" + }, + "value": { + "$ref": "#/definitions/value" + }, + "connectionString": { + "$ref": "#/definitions/connectionString" + }, + "inputs": { + "type": "object", + "description": "Defines a set of input values which need to be either generated or prompted by the deployment tool. This is typically used for environment specific configuration values or secrets.", + "additionalProperties": { + "type": "object", + "required": [ "type" ], + "properties": { + "type": { + "type": "string", + "description": "The type of the value to be prompted or generated. Currently only 'string'' is supported.", + "enum": [ "string" ] + }, + "secret": { + "type": "boolean", + "description": "Flag indicating whether the value should be treated as a secret. Deployment tools should note this value to take appropriate precautions when prompting, storing, and transmitting this value." + }, + "default": { + "type": "object", + "oneOf": [ + { + "required": [ "generate" ], + "properties": { + "generate": { + "type": "object", + "required": [ "minLength" ], + "properties": { + "minLength": { + "type": "number", + "description": "The minimum length of the generated value." + }, + "lower": { + "type": "boolean", + "description": "Indicates whether lower case characters are allowed in the generated value." + }, + "upper": { + "type": "boolean", + "description": "Indicates whether upper case characters are allowed in the generated value." + }, + "numeric": { + "type": "boolean", + "description": "Indicates whether numeric characters are allowed in the generated value." + }, + "special": { + "type": "boolean", + "description": "Indicates whether special characters are allowed in the generated value." + }, + "minLower": { + "type": "number", + "description": "Specifies the minimum number of lower case characters that must appear in the generated value." + }, + "minUpper": { + "type": "number", + "description": "Specifies the minimum number of upper case characters that must appear in the generated value." + }, + "minNumeric": { + "type": "number", + "description": "Specifies the minimum number of numeric characters that must appear in the generated value." + }, + "minSpecial": { + "type": "number", + "description": "Specifies the minimum number of special characters that must appear in the generated value." + } + } + }, + "additionalProperties": false + } + }, + { + "required": [ "value" ], + "properties": { + "value": { + "type": "string", + "description": "The default value to use if the parameter is not provided at deployment time." + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "Represents a formatted projection of a parameter value, produced by applying the specified filter to the parameter.", + "required": [ "type", "value", "filter" ], + "properties": { + "type": { + "const": "annotated.string" + }, + "value": { + "$ref": "#/definitions/value" + }, + "filter": { + "$ref": "#/definitions/annotatedStringFilter" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "Represents a Dapr resource in the manifest.", + "required": [ "dapr" ], + "properties": { + "type": { + "const": "dapr.v0" + }, + "dapr": { + "type": "object", + "description": "Dapr specific configuration.", + "required": [ "application", "appId", "components" ], + "properties": { + "application": { + "type": "string" + }, + "appId": { + "type": "string" + }, + "components": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "daprComponent" ], + "properties": { + "type": { + "const": "dapr.component.v0" + }, + "daprComponent": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "$ref": "#/definitions/resource.azure.bicep.v0" + }, + { + "$ref": "#/definitions/resource.azure.bicep.v1" + }, + { + "type": "object", + "required": [ "type", "stack-name" ], + "properties": { + "type": { + "const": "aws.cloudformation.stack.v0" + }, + "stack-name": { + "type": "string" + }, + "references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "target-resource": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ "type", "stack-name", "template-path" ], + "properties": { + "type": { + "const": "aws.cloudformation.template.v0" + }, + "stack-name": { + "type": "string" + }, + "template-path": { + "type": "string" + }, + "references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "target-resource": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "description": "Represents extensions. Any object with a 'type' field that is not captured above will pass.", + "required": [ "type" ], + "not": { + "properties": { + "type": { + "type": "string", + "enum": [ + "parameter.v0", + "annotated.string", + "container.v0", + "container.v1", + "dockerfile.v0", + "project.v0", + "project.v1", + "value.v0", + "executable.v0", + "azure.bicep.v0", + "azure.bicep.v1", + "aws.cloudformation.template.v0", + "aws.cloudformation.stack.v0", + "dapr.component.v0", + "dapr.v0" + ] + } + } + }, + "properties": { + "type": { + "type": "string" + } + } + } + ] + } + } + }, + "definitions": { + "resource.azure.bicep.v0": { + "type": "object", + "description": "Represents a resource that is deployed using Azure Bicep.", + "required": [ "path" ], + "properties": { + "type": { + "const": "azure.bicep.v0" + }, + "path": { + "type": "string", + "description": "Path to the Bicep file to be used for deployment." + }, + "connectionString": { + "$ref": "#/definitions/connectionString" + }, + "params": { + "type": "object", + "description": "A list of parameters which are passed to Azure deployment.", + "additionalProperties": { + "oneOf": [ + { "type": "array" }, + { "type": "boolean" }, + { "type": "number" }, + { "type": "object" }, + { "type": "string" } + ] + } + } + }, + "additionalProperties": false + }, + "resource.azure.bicep.v1": { + "allOf": [ + { + "$ref": "#/definitions/resource.azure.bicep.v0" + }, + { + "type": "object", + "properties": { + "type": { + "const": "azure.bicep.v1" + }, + "scope": { + "type": "object", + "properties": { + "resourceGroup": { + "type": "string", + "description": "The name of the resource group to deploy the resource to." + } + } + } + } + } + ] + }, + "connectionString": { + "type": "string", + "description": "A connection string that can be used to connect to this resource." + }, + "value": { + "type": "string", + "description": "A value that can be referenced via an expression in the manifest" + }, + "annotatedStringFilter": { + "type": "string", + "description": "Identifies the filter to apply to the referenced parameter value (for example, 'uri').", + "enum": [ "uri" ] + }, + "args": { + "type": "array", + "description": "List of arguments to used when launched.", + "items": { + "type": "string" + } + }, + "build": { + "type": "object", + "description": "An object that captures properties that control the building of a container image.", + "required": [ "context", "dockerfile" ], + "properties": { + "context": { + "type": "string", + "description": "The path to the context directory for the container build. Can be relative of absolute. If relative it is relative to the location of the manifest file." + }, + "dockerfile": { + "type": "string", + "description": "The path to the Dockerfile. Can be relative or absolute. If relative it is relative to the manifest file." + }, + "args": { + "type": "object", + "description": "A list of build arguments which are used during container build.", + "additionalProperties": { + "type": "string" + } + }, + "secrets": { + "type": "object", + "description": "A list of build arguments which are used during container build.", + "additionalProperties": { + "type": "object", + "required": [ "type" ], + "oneOf": [ + { + "required": [ "type", "value" ], + "properties": { + "type": { + "type": "string", + "const": "env" + }, + "value": { + "type": "string", + "description": "If provided use as the value for the environment variable when docker build is run." + } + } + }, + { + "required": [ "type", "source" ], + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "source": { + "type": "string", + "description": "Path to secret file. If relative, the path is relative to the manifest file." + } + } + } + ] + } + } + }, + "additionalProperties": false + }, + "buildArgs": { + "type": "object", + "description": "A list of build arguments which are used during container build (for dockerfile.v0 resource type).", + "additionalProperties": { + "type": "string" + } + }, + "bindings": { + "type": "object", + "description": "A list of port bindings for the resource when it is deployed.", + "additionalProperties": { + "type": "object", + "required": [ "scheme", "protocol", "transport" ], + "properties": { + "scheme": { + "type": "string", + "description": "The scheme used in URIs for this binding.", + "enum": [ "http", "https", "tcp", "udp" ] + }, + "protocol": { + "type": "string", + "description": "The protocol used for this binding (only 'tcp' or 'udp' are valid).", + "enum": [ "tcp", "udp" ] + }, + "transport": { + "type": "string", + "description": "Additional information describing the transport (e.g. HTTP/2).", + "enum": [ "http", "http2", "tcp" ] + }, + "external": { + "type": "boolean", + "description": "A flag indicating whether this binding is exposed externally when deployed." + }, + "targetPort": { + "type": "number", + "description": "The port that the workload (e.g. container) is listening on." + }, + "port": { + "type": "number", + "description": "The port that the workload (e.g. container) is exposed as to other resources and externally." + } + }, + "additionalProperties": false + } + }, + "env": { + "type": "object", + "description": "A list of environment variables which are inserted into the resource at runtime.", + "additionalProperties": { + "type": "string" + } + }, + "volumes": { + "type": "array", + "description": "A list of volumes associated with this resource when deployed.", + "items": { + "type": "object", + "required": [ "name", "target", "readOnly" ], + "properties": { + "name": { + "type": "string", + "description": "The name of the volume." + }, + "target": { + "type": "string", + "description": "The target within the container where the volume is mounted." + }, + "readOnly": { + "type": "boolean", + "description": "Flag indicating whether the mount is read-only." + } + }, + "additionalProperties": false + } + }, + "bindMounts": { + "type": "array", + "description": "A list of bind mounts associated with this resource when deployed.", + "items": { + "type": "object", + "required": [ "source", "target", "readOnly" ], + "properties": { + "source": { + "type": "string", + "description": "The source path on the host which is mounted into the container." + }, + "target": { + "type": "string", + "description": "The target within the container where the volume is mounted." + }, + "readOnly": { + "type": "boolean", + "description": "Flag indicating whether the mount is read-only." + } + }, + "additionalProperties": false + } + } + } +} diff --git a/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs b/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs index 5483c7a512e..9e022563a03 100644 --- a/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs +++ b/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs @@ -202,7 +202,7 @@ public async Task VerifyManifest() var expectedManifest = $$""" { "type": "container.v0", - "connectionString": "mongodb://admin:{mongo-password.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}?authSource=admin\u0026authMechanism=SCRAM-SHA-256", + "connectionString": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}?authSource=admin\u0026authMechanism=SCRAM-SHA-256", "image": "{{MongoDBContainerImageTags.Registry}}/{{MongoDBContainerImageTags.Image}}:{{MongoDBContainerImageTags.Tag}}", "env": { "MONGO_INITDB_ROOT_USERNAME": "admin", @@ -223,7 +223,7 @@ public async Task VerifyManifest() expectedManifest = """ { "type": "value.v0", - "connectionString": "mongodb://admin:{mongo-password.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/mydb?authSource=admin\u0026authMechanism=SCRAM-SHA-256" + "connectionString": "mongodb://admin:{mongo-password-uri-encoded.value}@{mongo.bindings.tcp.host}:{mongo.bindings.tcp.port}/mydb?authSource=admin\u0026authMechanism=SCRAM-SHA-256" } """; Assert.Equal(expectedManifest, dbManifest.ToString()); diff --git a/tests/Aspire.Hosting.Nats.Tests/AddNatsTests.cs b/tests/Aspire.Hosting.Nats.Tests/AddNatsTests.cs index 0a96ba18d01..aea32acf737 100644 --- a/tests/Aspire.Hosting.Nats.Tests/AddNatsTests.cs +++ b/tests/Aspire.Hosting.Nats.Tests/AddNatsTests.cs @@ -195,7 +195,7 @@ public async Task VerifyManifest() var expectedManifest = $$""" { "type": "container.v0", - "connectionString": "nats://nats:{nats-password.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", + "connectionString": "nats://nats:{nats-password-uri-encoded.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", "image": "{{NatsContainerImageTags.Registry}}/{{NatsContainerImageTags.Image}}:{{NatsContainerImageTags.Tag}}", "args": [ "--user", @@ -232,7 +232,7 @@ public async Task VerifyManifestWihtParameters() var expectedManifest = $$""" { "type": "container.v0", - "connectionString": "nats://{user.value}:{pass.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", + "connectionString": "nats://{user-uri-encoded.value}:{pass-uri-encoded.value}@{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", "image": "{{NatsContainerImageTags.Registry}}/{{NatsContainerImageTags.Image}}:{{NatsContainerImageTags.Tag}}", "args": [ "--user", @@ -260,7 +260,7 @@ public async Task VerifyManifestWihtParameters() expectedManifest = $$""" { "type": "container.v0", - "connectionString": "nats://{user.value}:{nats2-password.value}@{nats2.bindings.tcp.host}:{nats2.bindings.tcp.port}", + "connectionString": "nats://{user-uri-encoded.value}:{nats2-password-uri-encoded.value}@{nats2.bindings.tcp.host}:{nats2.bindings.tcp.port}", "image": "{{NatsContainerImageTags.Registry}}/{{NatsContainerImageTags.Image}}:{{NatsContainerImageTags.Tag}}", "args": [ "--user", @@ -287,7 +287,7 @@ public async Task VerifyManifestWihtParameters() expectedManifest = $$""" { "type": "container.v0", - "connectionString": "nats://nats:{pass.value}@{nats3.bindings.tcp.host}:{nats3.bindings.tcp.port}", + "connectionString": "nats://nats:{pass-uri-encoded.value}@{nats3.bindings.tcp.host}:{nats3.bindings.tcp.port}", "image": "{{NatsContainerImageTags.Registry}}/{{NatsContainerImageTags.Image}}:{{NatsContainerImageTags.Tag}}", "args": [ "--user", diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs index 222417606cc..e477985c85e 100644 --- a/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs @@ -221,7 +221,7 @@ public async Task VerifyManifest(bool withManagementPlugin) var expectedManifest = $$""" { "type": "container.v0", - "connectionString": "amqp://guest:{rabbit-password.value}@{rabbit.bindings.tcp.host}:{rabbit.bindings.tcp.port}", + "connectionString": "amqp://guest:{rabbit-password-uri-encoded.value}@{rabbit.bindings.tcp.host}:{rabbit.bindings.tcp.port}", "image": "{{RabbitMQContainerImageTags.Registry}}/{{RabbitMQContainerImageTags.Image}}:{{expectedTag}}", "env": { "RABBITMQ_DEFAULT_USER": "guest", @@ -237,7 +237,6 @@ public async Task VerifyManifest(bool withManagementPlugin) } } """; - Assert.Equal(expectedManifest, manifest.ToString()); } @@ -255,7 +254,7 @@ public async Task VerifyManifestWithParameters() var expectedManifest = $$""" { "type": "container.v0", - "connectionString": "amqp://{user.value}:{pass.value}@{rabbit.bindings.tcp.host}:{rabbit.bindings.tcp.port}", + "connectionString": "amqp://{user-uri-encoded.value}:{pass-uri-encoded.value}@{rabbit.bindings.tcp.host}:{rabbit.bindings.tcp.port}", "image": "{{RabbitMQContainerImageTags.Registry}}/{{RabbitMQContainerImageTags.Image}}:{{RabbitMQContainerImageTags.Tag}}", "env": { "RABBITMQ_DEFAULT_USER": "{user.value}", @@ -279,7 +278,7 @@ public async Task VerifyManifestWithParameters() expectedManifest = $$""" { "type": "container.v0", - "connectionString": "amqp://{user.value}:{rabbit2-password.value}@{rabbit2.bindings.tcp.host}:{rabbit2.bindings.tcp.port}", + "connectionString": "amqp://{user-uri-encoded.value}:{rabbit2-password-uri-encoded.value}@{rabbit2.bindings.tcp.host}:{rabbit2.bindings.tcp.port}", "image": "{{RabbitMQContainerImageTags.Registry}}/{{RabbitMQContainerImageTags.Image}}:{{RabbitMQContainerImageTags.Tag}}", "env": { "RABBITMQ_DEFAULT_USER": "{user.value}", @@ -303,7 +302,7 @@ public async Task VerifyManifestWithParameters() expectedManifest = $$""" { "type": "container.v0", - "connectionString": "amqp://guest:{pass.value}@{rabbit3.bindings.tcp.host}:{rabbit3.bindings.tcp.port}", + "connectionString": "amqp://guest:{pass-uri-encoded.value}@{rabbit3.bindings.tcp.host}:{rabbit3.bindings.tcp.port}", "image": "{{RabbitMQContainerImageTags.Registry}}/{{RabbitMQContainerImageTags.Image}}:{{RabbitMQContainerImageTags.Tag}}", "env": { "RABBITMQ_DEFAULT_USER": "guest", diff --git a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs index 560add63780..e8e947559f8 100644 --- a/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs +++ b/tests/Aspire.Hosting.Tests/ManifestGenerationTests.cs @@ -383,7 +383,7 @@ public void VerifyTestProgramFullManifest() "REDIS_HOST": "{redis.bindings.tcp.host}", "REDIS_PORT": "{redis.bindings.tcp.port}", "REDIS_PASSWORD": "{redis-password.value}", - "REDIS_URI": "redis://:{redis-password.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", + "REDIS_URI": "redis://:{redis-password-uri-encoded.value}@{redis.bindings.tcp.host}:{redis.bindings.tcp.port}", "ConnectionStrings__postgresdb": "{postgresdb.connectionString}", "POSTGRESDB_HOST": "{postgres.bindings.tcp.host}", "POSTGRESDB_PORT": "{postgres.bindings.tcp.port}", @@ -467,6 +467,11 @@ public void VerifyTestProgramFullManifest() } } }, + "redis-password-uri-encoded": { + "type": "annotated.string", + "value": "{redis-password.value}", + "filter": "uri" + }, "postgres-password": { "type": "parameter.v0", "value": "{postgres-password.inputs.value}", @@ -481,6 +486,11 @@ public void VerifyTestProgramFullManifest() } } } + }, + "postgres-password-uri-encoded": { + "type": "annotated.string", + "value": "{postgres-password.value}", + "filter": "uri" } } }