diff --git a/src/CommunityToolkit.Aspire.Hosting.DbGate/DbGateBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.DbGate/DbGateBuilderExtensions.cs index e013a05eb..44e9940b3 100644 --- a/src/CommunityToolkit.Aspire.Hosting.DbGate/DbGateBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.DbGate/DbGateBuilderExtensions.cs @@ -87,4 +87,26 @@ public static IResourceBuilder AddDbGate(this IDistribu return dbGateContainerBuilder; } } + + /// + /// Sanitizes a resource name to be used as a connection ID in DbGate environment variables. + /// + /// The resource name to sanitize. + /// A sanitized connection ID safe for use in environment variable names. + /// + /// + /// This method performs basic sanitization by replacing hyphens with underscores, as hyphens are not valid + /// in Linux environment variable names. + /// + /// + /// Note: Linux environment variable names have additional constraints (must contain only letters, numbers, and underscores, + /// and cannot start with a number). This method does not validate or enforce these additional constraints. + /// Resource names should follow standard naming conventions to ensure compatibility. + /// + /// + public static string SanitizeConnectionId(string resourceName) + { + ArgumentNullException.ThrowIfNull(resourceName); + return resourceName.Replace('-', '_'); + } } diff --git a/src/CommunityToolkit.Aspire.Hosting.MongoDB.Extensions/MongoDBBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.MongoDB.Extensions/MongoDBBuilderExtensions.cs index 4b605cc6c..48a9ee733 100644 --- a/src/CommunityToolkit.Aspire.Hosting.MongoDB.Extensions/MongoDBBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.MongoDB.Extensions/MongoDBBuilderExtensions.cs @@ -56,7 +56,8 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, var mongoDBServer = builder.Resource; var name = mongoDBServer.Name; - var label = $"LABEL_{name}"; + var connectionId = DbGateBuilderExtensions.SanitizeConnectionId(name); + var label = $"LABEL_{connectionId}"; // Multiple WithDbGate calls will be ignored if (context.EnvironmentVariables.ContainsKey(label)) @@ -67,16 +68,16 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, // DbGate assumes MongoDB is being accessed over a default Aspire container network and hardcodes the resource address // This will need to be refactored once updated service discovery APIs are available context.EnvironmentVariables.Add(label, name); - context.EnvironmentVariables.Add($"URL_{name}", mongoDBServer.ConnectionStringExpression); - context.EnvironmentVariables.Add($"ENGINE_{name}", "mongo@dbgate-plugin-mongo"); + context.EnvironmentVariables.Add($"URL_{connectionId}", mongoDBServer.ConnectionStringExpression); + context.EnvironmentVariables.Add($"ENGINE_{connectionId}", "mongo@dbgate-plugin-mongo"); if (context.EnvironmentVariables.GetValueOrDefault("CONNECTIONS") is string { Length: > 0 } connections) { - context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{name}"; + context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{connectionId}"; } else { - context.EnvironmentVariables["CONNECTIONS"] = name; + context.EnvironmentVariables["CONNECTIONS"] = connectionId; } } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.MySql.Extensions/MySqlBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.MySql.Extensions/MySqlBuilderExtensions.cs index 06cb7e77e..fcc409a84 100644 --- a/src/CommunityToolkit.Aspire.Hosting.MySql.Extensions/MySqlBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.MySql.Extensions/MySqlBuilderExtensions.cs @@ -97,7 +97,8 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, var mySqlServer = builder.Resource; var name = mySqlServer.Name; - var label = $"LABEL_{name}"; + var connectionId = DbGateBuilderExtensions.SanitizeConnectionId(name); + var label = $"LABEL_{connectionId}"; // Multiple WithDbGate calls will be ignored if (context.EnvironmentVariables.ContainsKey(label)) @@ -107,19 +108,19 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, // DbGate assumes MySql is being accessed over a default Aspire container network and hardcodes the resource address context.EnvironmentVariables.Add(label, name); - context.EnvironmentVariables.Add($"SERVER_{name}", name); - context.EnvironmentVariables.Add($"USER_{name}", "root"); - context.EnvironmentVariables.Add($"PASSWORD_{name}", mySqlServer.PasswordParameter); - context.EnvironmentVariables.Add($"PORT_{name}", mySqlServer.PrimaryEndpoint.TargetPort!.ToString()!); - context.EnvironmentVariables.Add($"ENGINE_{name}", "mysql@dbgate-plugin-mysql"); + context.EnvironmentVariables.Add($"SERVER_{connectionId}", name); + context.EnvironmentVariables.Add($"USER_{connectionId}", "root"); + context.EnvironmentVariables.Add($"PASSWORD_{connectionId}", mySqlServer.PasswordParameter); + context.EnvironmentVariables.Add($"PORT_{connectionId}", mySqlServer.PrimaryEndpoint.TargetPort!.ToString()!); + context.EnvironmentVariables.Add($"ENGINE_{connectionId}", "mysql@dbgate-plugin-mysql"); if (context.EnvironmentVariables.GetValueOrDefault("CONNECTIONS") is string { Length: > 0 } connections) { - context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{name}"; + context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{connectionId}"; } else { - context.EnvironmentVariables["CONNECTIONS"] = name; + context.EnvironmentVariables["CONNECTIONS"] = connectionId; } } diff --git a/src/CommunityToolkit.Aspire.Hosting.PostgreSQL.Extensions/PostgresBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.PostgreSQL.Extensions/PostgresBuilderExtensions.cs index 8b6b585ab..ed5dd4962 100644 --- a/src/CommunityToolkit.Aspire.Hosting.PostgreSQL.Extensions/PostgresBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.PostgreSQL.Extensions/PostgresBuilderExtensions.cs @@ -97,7 +97,8 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, var postgresServer = builder.Resource; var name = postgresServer.Name; - var label = $"LABEL_{name}"; + var connectionId = DbGateBuilderExtensions.SanitizeConnectionId(name); + var label = $"LABEL_{connectionId}"; // Multiple WithDbGate calls will be ignored if (context.EnvironmentVariables.ContainsKey(label)) @@ -111,20 +112,20 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, // DbGate assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address // This will need to be refactored once updated service discovery APIs are available - context.EnvironmentVariables.Add($"LABEL_{name}", postgresServer.Name); - context.EnvironmentVariables.Add($"SERVER_{name}", postgresServer.Name); - context.EnvironmentVariables.Add($"USER_{name}", userParameter); - context.EnvironmentVariables.Add($"PASSWORD_{name}", postgresServer.PasswordParameter); - context.EnvironmentVariables.Add($"PORT_{name}", postgresServer.PrimaryEndpoint.TargetPort!.ToString()!); - context.EnvironmentVariables.Add($"ENGINE_{name}", "postgres@dbgate-plugin-postgres"); + context.EnvironmentVariables.Add($"LABEL_{connectionId}", postgresServer.Name); + context.EnvironmentVariables.Add($"SERVER_{connectionId}", postgresServer.Name); + context.EnvironmentVariables.Add($"USER_{connectionId}", userParameter); + context.EnvironmentVariables.Add($"PASSWORD_{connectionId}", postgresServer.PasswordParameter); + context.EnvironmentVariables.Add($"PORT_{connectionId}", postgresServer.PrimaryEndpoint.TargetPort!.ToString()!); + context.EnvironmentVariables.Add($"ENGINE_{connectionId}", "postgres@dbgate-plugin-postgres"); if (context.EnvironmentVariables.GetValueOrDefault("CONNECTIONS") is string { Length: > 0 } connections) { - context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{name}"; + context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{connectionId}"; } else { - context.EnvironmentVariables["CONNECTIONS"] = name; + context.EnvironmentVariables["CONNECTIONS"] = connectionId; } } diff --git a/src/CommunityToolkit.Aspire.Hosting.Redis.Extensions/RedisBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Redis.Extensions/RedisBuilderExtensions.cs index 49ea3ae85..a4dfb7b8b 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Redis.Extensions/RedisBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Redis.Extensions/RedisBuilderExtensions.cs @@ -54,24 +54,25 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, var redisResource = builder.Resource; var name = redisResource.Name; - var lalbel = $"LABEL_{name}"; + var connectionId = DbGateBuilderExtensions.SanitizeConnectionId(name); + var label = $"LABEL_{connectionId}"; // DbGate assumes Redis is being accessed over a default Aspire container network and hardcodes the resource address var redisUrl = redisResource.PasswordParameter is not null ? ReferenceExpression.Create($"redis://:{redisResource.PasswordParameter}@{name}:{redisResource.PrimaryEndpoint.TargetPort?.ToString()}") : ReferenceExpression.Create($"redis://{name}:{redisResource.PrimaryEndpoint.TargetPort?.ToString()}"); - context.EnvironmentVariables.Add(lalbel, name); - context.EnvironmentVariables.Add($"URL_{name}", redisUrl); - context.EnvironmentVariables.Add($"ENGINE_{name}", "redis@dbgate-plugin-redis"); + context.EnvironmentVariables.Add(label, name); + context.EnvironmentVariables.Add($"URL_{connectionId}", redisUrl); + context.EnvironmentVariables.Add($"ENGINE_{connectionId}", "redis@dbgate-plugin-redis"); if (context.EnvironmentVariables.GetValueOrDefault("CONNECTIONS") is string { Length: > 0 } connections) { - context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{name}"; + context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{connectionId}"; } else { - context.EnvironmentVariables["CONNECTIONS"] = name; + context.EnvironmentVariables["CONNECTIONS"] = connectionId; } } } \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions/SqlServerBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions/SqlServerBuilderExtensions.cs index c7eb3082b..c2e4e3dfd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions/SqlServerBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions/SqlServerBuilderExtensions.cs @@ -96,7 +96,8 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, var sqlServerResource = builder.Resource; var name = sqlServerResource.Name; - var label = $"LABEL_{name}"; + var connectionId = DbGateBuilderExtensions.SanitizeConnectionId(name); + var label = $"LABEL_{connectionId}"; // Multiple WithDbGate calls will be ignored if (context.EnvironmentVariables.ContainsKey(label)) @@ -107,19 +108,19 @@ private static void ConfigureDbGateContainer(EnvironmentCallbackContext context, // DbGate assumes SqlServer is being accessed over a default Aspire container network and hardcodes the resource address // This will need to be refactored once updated service discovery APIs are available context.EnvironmentVariables.Add(label, sqlServerResource.Name); - context.EnvironmentVariables.Add($"SERVER_{name}", sqlServerResource.Name); - context.EnvironmentVariables.Add($"USER_{name}", "sa"); - context.EnvironmentVariables.Add($"PASSWORD_{name}", sqlServerResource.PasswordParameter); - context.EnvironmentVariables.Add($"PORT_{name}", sqlServerResource.PrimaryEndpoint.TargetPort!.ToString()!); - context.EnvironmentVariables.Add($"ENGINE_{name}", "mssql@dbgate-plugin-mssql"); + context.EnvironmentVariables.Add($"SERVER_{connectionId}", sqlServerResource.Name); + context.EnvironmentVariables.Add($"USER_{connectionId}", "sa"); + context.EnvironmentVariables.Add($"PASSWORD_{connectionId}", sqlServerResource.PasswordParameter); + context.EnvironmentVariables.Add($"PORT_{connectionId}", sqlServerResource.PrimaryEndpoint.TargetPort!.ToString()!); + context.EnvironmentVariables.Add($"ENGINE_{connectionId}", "mssql@dbgate-plugin-mssql"); if (context.EnvironmentVariables.GetValueOrDefault("CONNECTIONS") is string { Length: > 0 } connections) { - context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{name}"; + context.EnvironmentVariables["CONNECTIONS"] = $"{connections},{connectionId}"; } else { - context.EnvironmentVariables["CONNECTIONS"] = name; + context.EnvironmentVariables["CONNECTIONS"] = connectionId; } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.DbGate.Tests/DbGatePublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.DbGate.Tests/DbGatePublicApiTests.cs index 08dbb8e67..59296c065 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.DbGate.Tests/DbGatePublicApiTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.DbGate.Tests/DbGatePublicApiTests.cs @@ -65,4 +65,31 @@ public void WithHostPortShouldThrowWhenBuilderIsNull() var exception = Assert.Throws(action); Assert.Equal(nameof(builder), exception.ParamName); } + + [Fact] + public void SanitizeConnectionIdShouldThrowWhenResourceNameIsNull() + { + string resourceName = null!; + + var action = () => DbGateBuilderExtensions.SanitizeConnectionId(resourceName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(resourceName), exception.ParamName); + } + + [Theory] + [InlineData("mysql", "mysql")] + [InlineData("mysql-db", "mysql_db")] + [InlineData("my-sql-db", "my_sql_db")] + [InlineData("mysql_db", "mysql_db")] + [InlineData("mysql-", "mysql_")] + [InlineData("-mysql", "_mysql")] + [InlineData("--mysql--", "__mysql__")] + [InlineData("", "")] + public void SanitizeConnectionIdShouldReplaceHyphensWithUnderscores(string input, string expected) + { + var result = DbGateBuilderExtensions.SanitizeConnectionId(input); + + Assert.Equal(expected, result); + } } diff --git a/tests/CommunityToolkit.Aspire.Hosting.MySql.Extensions.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.MySql.Extensions.Tests/ResourceCreationTests.cs index 262a85dfb..cec7f8db7 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.MySql.Extensions.Tests/ResourceCreationTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.MySql.Extensions.Tests/ResourceCreationTests.cs @@ -369,4 +369,68 @@ public async Task WithDbGateAddsAnnotationsForMultipleMySqlResource() Assert.Equal("mysql@dbgate-plugin-mysql", item.Value); }); } + + [Fact] + public async Task WithDbGateSanitizesResourceNameWithHyphens() + { + var builder = DistributedApplication.CreateBuilder(); + + var mysqlResourceBuilder = builder.AddMySql("mysql-db") + .WithDbGate(); + + var mysqlResource = mysqlResourceBuilder.Resource; + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var dbGateResource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(dbGateResource); + + Assert.Equal("dbgate", dbGateResource.Name); + + var envs = await dbGateResource.GetEnvironmentVariablesAsync(); + + Assert.NotEmpty(envs); + Assert.Collection(envs, + item => + { + // Connection ID should be sanitized (hyphens replaced with underscores) + Assert.Equal("LABEL_mysql_db", item.Key); + // But the label value should still be the original resource name + Assert.Equal(mysqlResource.Name, item.Value); + }, + item => + { + Assert.Equal("SERVER_mysql_db", item.Key); + Assert.Equal(mysqlResource.Name, item.Value); + }, + item => + { + Assert.Equal("USER_mysql_db", item.Key); + Assert.Equal("root", item.Value); + }, + async item => + { + Assert.Equal("PASSWORD_mysql_db", item.Key); + Assert.Equal(await mysqlResource.PasswordParameter.GetValueAsync(default), item.Value); + }, + item => + { + Assert.Equal("PORT_mysql_db", item.Key); + Assert.Equal(mysqlResource.PrimaryEndpoint.TargetPort.ToString(), item.Value); + }, + item => + { + Assert.Equal("ENGINE_mysql_db", item.Key); + Assert.Equal("mysql@dbgate-plugin-mysql", item.Value); + }, + item => + { + Assert.Equal("CONNECTIONS", item.Key); + // Connection ID in the CONNECTIONS value should also be sanitized + Assert.Equal("mysql_db", item.Value); + }); + } }