diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj index 66e0c13e3e3..103aaea13bd 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj @@ -9,6 +9,7 @@ + diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs index 9c42c6603c2..2e84f43ce65 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs @@ -3,6 +3,7 @@ using Azure.Storage.Blobs; using Azure.Storage.Queues; +using Microsoft.Data.SqlClient; var builder = WebApplication.CreateBuilder(args); @@ -12,6 +13,8 @@ builder.AddKeyedAzureQueue("myqueue"); +builder.AddSqlServerClient("sqldb"); + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -30,6 +33,38 @@ return blobNames; }); +app.MapGet("/sql", async (SqlConnection connection) => +{ + await connection.OpenAsync(); + + // Ensure the Items table exists + await using var createCmd = connection.CreateCommand(); + createCmd.CommandText = """ + IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Items') + CREATE TABLE Items (Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(256) NOT NULL, CreatedAt DATETIME2 DEFAULT GETUTCDATE()) + """; + await createCmd.ExecuteNonQueryAsync(); + + // Insert a new item + var itemName = $"Item-{Guid.NewGuid():N}"; + await using var insertCmd = connection.CreateCommand(); + insertCmd.CommandText = "INSERT INTO Items (Name) OUTPUT INSERTED.Id, INSERTED.Name, INSERTED.CreatedAt VALUES (@name)"; + insertCmd.Parameters.Add(new SqlParameter("@name", itemName)); + + await using var reader = await insertCmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + return Results.Ok(new + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + CreatedAt = reader.GetDateTime(2) + }); + } + + return Results.StatusCode(500); +}); + app.Run(); static async Task ReadBlobsAsync(BlobContainerClient containerClient, List output) diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj index 5b4082c956a..ccb80aac73e 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj @@ -13,6 +13,7 @@ + diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index 81ad596b9e8..9994579d456 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -47,8 +47,15 @@ privateEndpointsSubnet.AddPrivateEndpoint(blobs); privateEndpointsSubnet.AddPrivateEndpoint(queues); +var sqlServer = builder.AddAzureSqlServer("sql") + .RunAsContainer(c => c.WithLifetime(ContainerLifetime.Persistent)); +privateEndpointsSubnet.AddPrivateEndpoint(sqlServer); + +var db = sqlServer.AddDatabase("sqldb"); + builder.AddProject("api") .WithExternalHttpEndpoints() + .WithReference(db).WaitFor(db) .WithReference(mycontainer).WaitFor(mycontainer) .WithReference(myqueue).WaitFor(myqueue); diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep index 775868cc85d..8b98b474936 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep @@ -11,6 +11,8 @@ param api_identity_outputs_id string param api_containerport string +param sql_outputs_sqlserverfqdn string + param storage_outputs_blobendpoint string param storage_outputs_queueendpoint string @@ -63,6 +65,30 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { name: 'HTTP_PORTS' value: api_containerport } + { + name: 'ConnectionStrings__sqldb' + value: 'Server=tcp:${sql_outputs_sqlserverfqdn},1433;Encrypt=True;Authentication="Active Directory Default";Database=sqldb' + } + { + name: 'SQLDB_HOST' + value: sql_outputs_sqlserverfqdn + } + { + name: 'SQLDB_PORT' + value: '1433' + } + { + name: 'SQLDB_URI' + value: 'mssql://${sql_outputs_sqlserverfqdn}:1433/sqldb' + } + { + name: 'SQLDB_JDBCCONNECTIONSTRING' + value: 'jdbc:sqlserver://${sql_outputs_sqlserverfqdn}:1433;database=sqldb;encrypt=true;trustServerCertificate=false' + } + { + name: 'SQLDB_DATABASENAME' + value: 'sqldb' + } { name: 'ConnectionStrings__mycontainer' value: 'Endpoint=${storage_outputs_blobendpoint};ContainerName=mycontainer' diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-sql.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-sql.module.bicep new file mode 100644 index 00000000000..a2e224b59f6 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-sql.module.bicep @@ -0,0 +1,95 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_name string + +param sql_outputs_sqlserveradminname string + +param vnet_outputs_sql_aci_subnet_id string + +param sql_store_outputs_name string + +param principalId string + +param principalName string + +param private_endpoints_sql_pe_outputs_name string + +param private_endpoints_files_pe_outputs_name string + +resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { + name: sql_outputs_name +} + +resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: principalName +} + +resource private_endpoints_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: private_endpoints_sql_pe_outputs_name +} + +resource private_endpoints_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: private_endpoints_files_pe_outputs_name +} + +resource script_sql_sqldb 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: take('script-${uniqueString('sql', principalName, 'sqldb', resourceGroup().id)}', 24) + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerAdmin.id}': { } + } + } + kind: 'AzurePowerShell' + properties: { + azPowerShellVersion: '14.0' + retentionInterval: 'PT1H' + containerSettings: { + subnetIds: [ + { + id: vnet_outputs_sql_aci_subnet_id + } + ] + } + environmentVariables: [ + { + name: 'DBNAME' + value: 'sqldb' + } + { + name: 'DBSERVER' + value: sql.properties.fullyQualifiedDomainName + } + { + name: 'PRINCIPALTYPE' + value: 'ServicePrincipal' + } + { + name: 'PRINCIPALNAME' + value: principalName + } + { + name: 'ID' + value: mi.properties.clientId + } + ] + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\r\n\$sqlDatabaseName = "\$env:DBNAME"\r\n\$principalName = "\$env:PRINCIPALNAME"\r\n\$id = "\$env:ID"\r\n\r\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\r\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\r\nImport-Module SqlServer\r\n\r\n\$sqlCmd = @"\r\nDECLARE @name SYSNAME = \'\$principalName\';\r\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\r\n\r\n-- Convert the guid to the right type\r\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\r\n\r\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\r\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\r\nEXEC (@cmd);\r\n\r\n-- Assign roles to the new user\r\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\r\nEXEC (@role1);\r\n\r\n"@\r\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\r\n\r\nWrite-Host \$sqlCmd\r\n\r\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\r\n\r\n\$maxRetries = 5\r\n\$retryDelay = 60\r\n\$attempt = 0\r\n\$success = \$false\r\n\r\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\r\n \$attempt++\r\n Write-Host "Attempt \$attempt of \$maxRetries..."\r\n try {\r\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\r\n \$success = \$true\r\n Write-Host "SQL command succeeded on attempt \$attempt."\r\n } catch {\r\n Write-Host "Attempt \$attempt failed: \$_"\r\n if (\$attempt -lt \$maxRetries) {\r\n Write-Host "Retrying in \$retryDelay seconds..."\r\n Start-Sleep -Seconds \$retryDelay\r\n } else {\r\n throw\r\n }\r\n }\r\n}' + storageAccountSettings: { + storageAccountName: sql_store_outputs_name + } + } + dependsOn: [ + private_endpoints_sql_pe + private_endpoints_files_pe + ] +} \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json index 7b7fdce65d9..23fa1a507ed 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json @@ -7,7 +7,8 @@ "params": { "nat_outputs_id": "{nat.outputs.id}", "container_apps_nsg_outputs_id": "{container-apps-nsg.outputs.id}", - "private_endpoints_nsg_outputs_id": "{private-endpoints-nsg.outputs.id}" + "private_endpoints_nsg_outputs_id": "{private-endpoints-nsg.outputs.id}", + "sql_nsg_outputs_id": "{sql-nsg.outputs.id}" } }, "container-apps-nsg": { @@ -95,6 +96,46 @@ "storage_outputs_id": "{storage.outputs.id}" } }, + "sql": { + "type": "azure.bicep.v0", + "connectionString": "Server=tcp:{sql.outputs.sqlServerFqdn},1433;Encrypt=True;Authentication=\u0022Active Directory Default\u0022", + "path": "sql.module.bicep" + }, + "privatelink-database-windows-net": { + "type": "azure.bicep.v0", + "path": "privatelink-database-windows-net.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}" + } + }, + "private-endpoints-sql-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-sql-pe.module.bicep", + "params": { + "privatelink_database_windows_net_outputs_name": "{privatelink-database-windows-net.outputs.name}", + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "sql_outputs_id": "{sql.outputs.id}" + } + }, + "sql-store": { + "type": "azure.bicep.v0", + "path": "sql-store.module.bicep" + }, + "sql-admin-identity": { + "type": "azure.bicep.v0", + "path": "sql-admin-identity.module.bicep", + "params": { + "sql_outputs_sqlserveradminname": "{sql.outputs.sqlServerAdminName}" + } + }, + "sql-nsg": { + "type": "azure.bicep.v0", + "path": "sql-nsg.module.bicep" + }, + "sqldb": { + "type": "value.v0", + "connectionString": "{sql.connectionString};Database=sqldb" + }, "api": { "type": "project.v1", "path": "../AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj", @@ -109,6 +150,7 @@ "api_containerimage": "{api.containerImage}", "api_identity_outputs_id": "{api-identity.outputs.id}", "api_containerport": "{api.containerPort}", + "sql_outputs_sqlserverfqdn": "{sql.outputs.sqlServerFqdn}", "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", "storage_outputs_queueendpoint": "{storage.outputs.queueEndpoint}", "api_identity_outputs_clientid": "{api-identity.outputs.clientId}" @@ -118,6 +160,12 @@ "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", "HTTP_PORTS": "{api.bindings.http.targetPort}", + "ConnectionStrings__sqldb": "{sqldb.connectionString}", + "SQLDB_HOST": "{sql.outputs.sqlServerFqdn}", + "SQLDB_PORT": "1433", + "SQLDB_URI": "mssql://{sql.outputs.sqlServerFqdn}:1433/sqldb", + "SQLDB_JDBCCONNECTIONSTRING": "jdbc:sqlserver://{sql.outputs.sqlServerFqdn}:1433;database=sqldb;encrypt=true;trustServerCertificate=false", + "SQLDB_DATABASENAME": "sqldb", "ConnectionStrings__mycontainer": "{mycontainer.connectionString}", "MYCONTAINER_URI": "{storage.outputs.blobEndpoint}", "MYCONTAINER_BLOBCONTAINERNAME": "mycontainer", @@ -140,10 +188,40 @@ } } }, + "privatelink-file-core-windows-net": { + "type": "azure.bicep.v0", + "path": "privatelink-file-core-windows-net.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}" + } + }, + "private-endpoints-files-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-files-pe.module.bicep", + "params": { + "privatelink_file_core_windows_net_outputs_name": "{privatelink-file-core-windows-net.outputs.name}", + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "sql_store_outputs_id": "{sql-store.outputs.id}" + } + }, "api-identity": { "type": "azure.bicep.v0", "path": "api-identity.module.bicep" }, + "api-roles-sql": { + "type": "azure.bicep.v0", + "path": "api-roles-sql.module.bicep", + "params": { + "sql_outputs_name": "{sql.outputs.name}", + "sql_outputs_sqlserveradminname": "{sql.outputs.sqlServerAdminName}", + "vnet_outputs_sql_aci_subnet_id": "{vnet.outputs.sql_aci_subnet_Id}", + "sql_store_outputs_name": "{sql-store.outputs.name}", + "principalId": "{api-identity.outputs.principalId}", + "principalName": "{api-identity.outputs.principalName}", + "private_endpoints_sql_pe_outputs_name": "{private-endpoints-sql-pe.outputs.name}", + "private_endpoints_files_pe_outputs_name": "{private-endpoints-files-pe.outputs.name}" + } + }, "api-roles-storage": { "type": "azure.bicep.v0", "path": "api-roles-storage.module.bicep", @@ -151,6 +229,14 @@ "storage_outputs_name": "{storage.outputs.name}", "principalId": "{api-identity.outputs.principalId}" } + }, + "sql-admin-identity-roles-sql-store": { + "type": "azure.bicep.v0", + "path": "sql-admin-identity-roles-sql-store.module.bicep", + "params": { + "sql_store_outputs_name": "{sql-store.outputs.name}", + "principalId": "{sql-admin-identity.outputs.principalId}" + } } } } \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-files-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-files-pe.module.bicep new file mode 100644 index 00000000000..415ccde08b1 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-files-pe.module.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_file_core_windows_net_outputs_name string + +param vnet_outputs_private_endpoints_id string + +param sql_store_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_file_core_windows_net_outputs_name +} + +resource private_endpoints_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('private_endpoints_files_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_store_outputs_id + groupIds: [ + 'file' + ] + } + name: 'private-endpoints-files-pe-connection' + } + ] + subnet: { + id: vnet_outputs_private_endpoints_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-files-pe' + } +} + +resource private_endpoints_files_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_file_core_windows_net' + properties: { + privateDnsZoneId: privatelink_file_core_windows_net.id + } + } + ] + } + parent: private_endpoints_files_pe +} + +output id string = private_endpoints_files_pe.id + +output name string = private_endpoints_files_pe.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-sql-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-sql-pe.module.bicep new file mode 100644 index 00000000000..cd724408526 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-sql-pe.module.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param vnet_outputs_private_endpoints_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource private_endpoints_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('private_endpoints_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'private-endpoints-sql-pe-connection' + } + ] + subnet: { + id: vnet_outputs_private_endpoints_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-sql-pe' + } +} + +resource private_endpoints_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: private_endpoints_sql_pe +} + +output id string = private_endpoints_sql_pe.id + +output name string = private_endpoints_sql_pe.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-database-windows-net.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-database-windows-net.module.bicep new file mode 100644 index 00000000000..70f401ea0ab --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-database-windows-net.module.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource vnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-vnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-file-core-windows-net.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-file-core-windows-net.module.bicep new file mode 100644 index 00000000000..1b122fb4b8e --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-file-core-windows-net.module.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.file.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net' + } +} + +resource vnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net-vnet-link' + } + parent: privatelink_file_core_windows_net +} + +output id string = privatelink_file_core_windows_net.id + +output name string = 'privatelink.file.core.windows.net' \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-admin-identity-roles-sql-store.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-admin-identity-roles-sql-store.module.bicep new file mode 100644 index 00000000000..27154e6a367 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-admin-identity-roles-sql-store.module.bicep @@ -0,0 +1,20 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_store_outputs_name string + +param principalId string + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource sql_store_StorageFileDataPrivilegedContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(sql_store.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd') + principalType: 'ServicePrincipal' + } + scope: sql_store +} \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-admin-identity.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-admin-identity.module.bicep new file mode 100644 index 00000000000..a3aad5c9e46 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-admin-identity.module.bicep @@ -0,0 +1,18 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_sqlserveradminname string + +resource sql_admin_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +output id string = sql_admin_identity.id + +output clientId string = sql_admin_identity.properties.clientId + +output principalId string = sql_admin_identity.properties.principalId + +output principalName string = sql_admin_identity.name + +output name string = sql_admin_identity.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-nsg.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-nsg.module.bicep new file mode 100644 index 00000000000..b5e3c8c8d86 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-nsg.module.bicep @@ -0,0 +1,44 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('sql_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'sql-nsg' + } +} + +resource sql_nsg_allow_outbound_443_AzureActiveDirectory 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-AzureActiveDirectory' + properties: { + access: 'Allow' + destinationAddressPrefix: 'AzureActiveDirectory' + destinationPortRange: '443' + direction: 'Outbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +resource sql_nsg_allow_outbound_443_Sql 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-Sql' + properties: { + access: 'Allow' + destinationAddressPrefix: 'Sql' + destinationPortRange: '443' + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +output id string = sql_nsg.id + +output name string = sql_nsg.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-store.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-store.module.bicep new file mode 100644 index 00000000000..7dd04fdd159 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql-store.module.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('sqlstore${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: true + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'sql-store' + } +} + +output blobEndpoint string = sql_store.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = sql_store.properties.primaryEndpoints.dfs + +output queueEndpoint string = sql_store.properties.primaryEndpoints.queue + +output tableEndpoint string = sql_store.properties.primaryEndpoints.table + +output name string = sql_store.name + +output id string = sql_store.id \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql.module.bicep new file mode 100644 index 00000000000..c3d93a6eb7c --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/sql.module.bicep @@ -0,0 +1,48 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource sqldb 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'sqldb' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep index d5876caa656..757c009af4d 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep @@ -7,6 +7,8 @@ param container_apps_nsg_outputs_id string param private_endpoints_nsg_outputs_id string +param sql_nsg_outputs_id string + resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { name: take('vnet-${uniqueString(resourceGroup().id)}', 64) properties: { @@ -58,10 +60,34 @@ resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01 ] } +resource sql_aci_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'sql-aci-subnet' + properties: { + addressPrefix: '10.0.255.248/29' + delegations: [ + { + properties: { + serviceName: 'Microsoft.ContainerInstance/containerGroups' + } + name: 'Microsoft.ContainerInstance/containerGroups' + } + ] + networkSecurityGroup: { + id: sql_nsg_outputs_id + } + } + parent: vnet + dependsOn: [ + private_endpoints + ] +} + output container_apps_Id string = container_apps.id output private_endpoints_Id string = private_endpoints.id +output sql_aci_subnet_Id string = sql_aci_subnet.id + output id string = vnet.id output name string = vnet.name \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs index 6700bee2b26..5b690842bd9 100644 --- a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -3,6 +3,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; +using Aspire.Hosting.Azure.Network; using Azure.Provisioning; using Azure.Provisioning.Network; using Azure.Provisioning.PrivateDns; @@ -81,7 +82,14 @@ public static IResourceBuilder AddPrivateEndpoint( } rootResource.Annotations.Add(new PrivateEndpointTargetAnnotation(resource)); - return builder.AddResource(resource); + var pe = builder.AddResource(resource); + + if (target.Resource is IAzurePrivateEndpointTargetNotification notificationTarget) + { + notificationTarget.OnPrivateEndpointCreated(pe); + } + + return pe; void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) { diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs index fe10e5f6d3a..4d99ba00a2c 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs @@ -10,7 +10,7 @@ namespace Aspire.Hosting.Azure; /// /// The name of the service delegation. /// The service name for the delegation (e.g., "Microsoft.App/environments"). -internal sealed class AzureSubnetServiceDelegationAnnotation(string name, string serviceName) : IResourceAnnotation +public sealed class AzureSubnetServiceDelegationAnnotation(string name, string serviceName) : IResourceAnnotation { /// /// Gets or sets the name associated with the service delegation. diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs index e253c7c32a5..62ebc2f8461 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs @@ -20,6 +20,9 @@ public class AzureVirtualNetworkResource(string name, Action + /// Gets the list of subnets for the virtual network. + /// internal List Subnets { get; } = []; /// diff --git a/src/Aspire.Hosting.Azure.Network/IAzurePrivateEndpointTargetNotification.cs b/src/Aspire.Hosting.Azure.Network/IAzurePrivateEndpointTargetNotification.cs new file mode 100644 index 00000000000..aa87e2c1b11 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/IAzurePrivateEndpointTargetNotification.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.Network; + +/// +/// An optional interface that can be implemented by resources that are targets for +/// Azure private endpoints, to receive a notification when a private endpoint is created for them. +/// +public interface IAzurePrivateEndpointTargetNotification : IAzurePrivateEndpointTarget +{ + /// + /// Handles the event that occurs when a new Azure private endpoint resource is created. + /// + /// The Azure private endpoint resource that was created. Cannot be null. + void OnPrivateEndpointCreated(IResourceBuilder privateEndpoint); +} diff --git a/src/Aspire.Hosting.Azure.Sql/AdminDeploymentScriptSubnetAnnotation.cs b/src/Aspire.Hosting.Azure.Sql/AdminDeploymentScriptSubnetAnnotation.cs new file mode 100644 index 00000000000..7a603b0ae5a --- /dev/null +++ b/src/Aspire.Hosting.Azure.Sql/AdminDeploymentScriptSubnetAnnotation.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; + +namespace Aspire.Hosting; + +/// +/// Annotation that stores the ACI subnet reference for deployment script configuration. +/// +internal sealed class AdminDeploymentScriptSubnetAnnotation(AzureSubnetResource subnet) : IResourceAnnotation +{ + /// + /// Gets the ACI subnet resource used for deployment scripts. + /// + public AzureSubnetResource Subnet { get; } = subnet ?? throw new ArgumentNullException(nameof(subnet)); +} diff --git a/src/Aspire.Hosting.Azure.Sql/Aspire.Hosting.Azure.Sql.csproj b/src/Aspire.Hosting.Azure.Sql/Aspire.Hosting.Azure.Sql.csproj index 40d76f77970..b5a93be6e67 100644 --- a/src/Aspire.Hosting.Azure.Sql/Aspire.Hosting.Azure.Sql.csproj +++ b/src/Aspire.Hosting.Azure.Sql/Aspire.Hosting.Azure.Sql.csproj @@ -1,4 +1,4 @@ - + @@ -15,6 +15,8 @@ + + diff --git a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs index 9816444ec53..44875c5fcc0 100644 --- a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs +++ b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs @@ -3,12 +3,14 @@ #pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; using Azure.Provisioning.Expressions; using Azure.Provisioning.Roles; using Azure.Provisioning.Sql; +using Azure.Provisioning.Storage; using static Azure.Provisioning.Expressions.BicepFunction; namespace Aspire.Hosting; @@ -353,4 +355,114 @@ private static SqlServer CreateSqlServerResourceOnly(AzureResourceInfrastructure return sqlServer; } + + /// + /// Configures the Azure SQL Server to use the specified subnet for deployment script execution. + /// + /// The Azure SQL Server resource builder. + /// The subnet to delegate for Azure Container Instances used by deployment scripts. + /// A reference to the for chaining. + /// + /// + /// When an Azure SQL Server has a private endpoint, deployment scripts that add database role assignments + /// run inside Azure Container Instances (ACI). This method allows you to provide an explicit subnet for those + /// containers instead of having one auto-created. + /// + /// + /// The specified subnet will be automatically delegated to Microsoft.ContainerInstance/containerGroups. + /// Ensure the subnet has outbound network security rules allowing access to Azure Active Directory (port 443) + /// and SQL (port 443) service tags. + /// + /// + /// + /// Provide a custom ACI subnet for the deployment script: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + /// var aciSubnet = vnet.AddSubnet("aci-subnet", "10.0.3.0/29"); + /// + /// var sql = builder.AddAzureSqlServer("sql") + /// .WithAdminDeploymentScriptSubnet(aciSubnet); + /// peSubnet.AddPrivateEndpoint(sql); + /// + /// + [Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] + public static IResourceBuilder WithAdminDeploymentScriptSubnet( + this IResourceBuilder builder, + IResourceBuilder subnet) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(subnet); + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + return builder; + } + + builder.Resource.Annotations.Add(new AdminDeploymentScriptSubnetAnnotation(subnet.Resource)); + + return builder; + } + + /// + /// Configures the Azure SQL Server to use the specified storage account for deployment script execution. + /// + /// The Azure SQL Server resource builder. + /// The storage account to use for deployment scripts. + /// A reference to the for chaining. + /// + /// + /// When an Azure SQL Server has a private endpoint, deployment scripts require a storage account to upload + /// scripts and write logs. This method allows you to provide an explicit storage account instead of having + /// one auto-created. + /// + /// + /// The storage account must have AllowSharedKeyAccess enabled, as deployment scripts need to mount + /// file shares. If the storage is not an existing resource, this method will automatically configure + /// AllowSharedKeyAccess = true. + /// + /// + /// + /// Provide a custom storage account for the deployment script: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + /// + /// var storage = builder.AddAzureStorage("scriptstorage"); + /// var sql = builder.AddAzureSqlServer("sql") + /// .WithAdminDeploymentScriptStorage(storage); + /// peSubnet.AddPrivateEndpoint(sql); + /// + /// + [Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] + public static IResourceBuilder WithAdminDeploymentScriptStorage( + this IResourceBuilder builder, + IResourceBuilder storage) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(storage); + + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + // Set the user's storage. The BeforeStartEvent handler will remove the + // original default storage since it no longer matches. + builder.Resource.DeploymentScriptStorage = storage.Resource; + + // If the storage is not an existing resource, ensure AllowSharedKeyAccess is enabled + if (!storage.Resource.IsExisting()) + { + storage.ConfigureInfrastructure(infra => + { + var sa = infra.GetProvisionableResources().OfType().SingleOrDefault() + ?? throw new InvalidOperationException("Could not find a StorageAccount resource in the infrastructure. Ensure that the provided storage builder creates a StorageAccount resource."); + + sa.AllowSharedKeyAccess = true; + }); + } + + return builder; + } } diff --git a/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs b/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs index baf22e07682..de391804d4a 100644 --- a/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs +++ b/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs @@ -2,23 +2,35 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable AZPROVISION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.Network; +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Pipelines; using Azure.Provisioning; using Azure.Provisioning.Expressions; +using Azure.Provisioning.Network; using Azure.Provisioning.Primitives; using Azure.Provisioning.Resources; using Azure.Provisioning.Roles; using Azure.Provisioning.Sql; +using Azure.Provisioning.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Aspire.Hosting.Azure; /// /// Represents an Azure Sql Server resource. /// -public class AzureSqlServerResource : AzureProvisioningResource, IResourceWithConnectionString, IAzurePrivateEndpointTarget +public class AzureSqlServerResource : AzureProvisioningResource, IResourceWithConnectionString, IAzurePrivateEndpointTarget, IAzurePrivateEndpointTargetNotification { + private const string AciSubnetDelegationServiceId = "Microsoft.ContainerInstance/containerGroups"; + private readonly Dictionary _databases = new Dictionary(StringComparers.ResourceName); private readonly bool _createdWithInnerResource; @@ -60,6 +72,19 @@ public AzureSqlServerResource(SqlServerServerResource innerResource, Action new("sqlServerAdminName", this); + /// + /// Gets or sets the storage account used for deployment scripts. + /// Set during AddAzureSqlServer and potentially swapped by WithAdminDeploymentScriptStorage + /// or removed by the preparer if no private endpoint is detected. + /// + internal AzureStorageResource? DeploymentScriptStorage { get; set; } + + internal AzureUserAssignedIdentityResource? AdminIdentity { get; set; } + + internal AzureNetworkSecurityGroupResource? DeploymentScriptNetworkSecurityGroup { get; set; } + + internal List DeploymentScriptDependsOn { get; } = []; + /// /// Gets the host name for the SQL Server. /// @@ -207,6 +232,27 @@ public override void AddRoleAssignments(IAddRoleAssignmentsContext roleAssignmen sqlServerAdmin.Name = AdminName.AsProvisioningParameter(infra); infra.Add(sqlServerAdmin); + // Check for deployment script subnet and storage (for private endpoint scenarios) + this.TryGetLastAnnotation(out var subnetAnnotation); + + // Resolve the ACI subnet ID and storage account name for deployment scripts. + BicepValue? aciSubnetId = null; + BicepValue? deploymentStorageAccountName = null; + + if (subnetAnnotation is not null) + { + // Explicit subnet provided by user + aciSubnetId = subnetAnnotation.Subnet.Id.AsProvisioningParameter(infra); + } + + if (DeploymentScriptStorage is not null) + { + // Storage reference — either auto-created or user-provided + var existingStorageAccount = (StorageAccount)DeploymentScriptStorage.AddAsExistingResource(infra); + + deploymentStorageAccountName = existingStorageAccount.Name; + } + // When not in Run Mode (F5) we reference the managed identity // that will need to access the database so we can add db role for it // using its ClientId. In the other case we use the PrincipalId. @@ -222,6 +268,14 @@ public override void AddRoleAssignments(IAddRoleAssignmentsContext roleAssignmen userId = managedIdentity.ClientId; } + // when private endpoints are referencing this SQL server, we need to delay the AzurePowerShellScript + // until the private endpoints are created, so the script can connect to the SQL server using the private endpoint. + var dependsOn = new List(); + foreach (var d in DeploymentScriptDependsOn) + { + dependsOn.Add(d.AddAsExistingResource(infra)); + } + foreach (var (resource, database) in Databases) { var uniqueScriptIdentifier = Infrastructure.NormalizeBicepIdentifier($"{this.GetBicepIdentifier()}_{resource}"); @@ -235,6 +289,22 @@ public override void AddRoleAssignments(IAddRoleAssignmentsContext roleAssignmen AzPowerShellVersion = "14.0" }; + // Configure the deployment script to run in a subnet (for private endpoint scenarios) + if (aciSubnetId is not null) + { + scriptResource.ContainerSettings.SubnetIds.Add( + new ScriptContainerGroupSubnet() + { + Id = aciSubnetId + }); + } + + // Configure the deployment script to use a storage account (for private endpoint scenarios) + if (deploymentStorageAccountName is not null) + { + scriptResource.StorageAccountSettings.StorageAccountName = deploymentStorageAccountName; + } + // Run the script as the administrator var id = BicepFunction.Interpolate($"{sqlServerAdmin.Id}").Compile().ToString(); @@ -280,9 +350,35 @@ public override void AddRoleAssignments(IAddRoleAssignmentsContext roleAssignmen $connectionString = "Server=tcp:${sqlServerFqdn},1433;Initial Catalog=${sqlDatabaseName};Authentication=Active Directory Default;" - Invoke-Sqlcmd -ConnectionString $connectionString -Query $sqlCmd + $maxRetries = 5 + $retryDelay = 60 + $attempt = 0 + $success = $false + + while (-not $success -and $attempt -lt $maxRetries) { + $attempt++ + Write-Host "Attempt $attempt of $maxRetries..." + try { + Invoke-Sqlcmd -ConnectionString $connectionString -Query $sqlCmd + $success = $true + Write-Host "SQL command succeeded on attempt $attempt." + } catch { + Write-Host "Attempt $attempt failed: $_" + if ($attempt -lt $maxRetries) { + Write-Host "Retrying in $retryDelay seconds..." + Start-Sleep -Seconds $retryDelay + } else { + throw + } + } + } """; + foreach (var d in dependsOn) + { + scriptResource.DependsOn.Add(d); + } + infra.Add(scriptResource); } } @@ -337,4 +433,248 @@ IEnumerable> IResourceWithConnectionSt IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["sqlServer"]; string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.database.windows.net"; + + void IAzurePrivateEndpointTargetNotification.OnPrivateEndpointCreated(IResourceBuilder privateEndpoint) + { + var builder = privateEndpoint.ApplicationBuilder; + + if (builder.ExecutionContext.IsPublishMode) + { + DeploymentScriptDependsOn.Add(privateEndpoint.Resource); + + // Guard: only create deployment script infrastructure once per SQL server. + // Multiple private endpoints may trigger this, but the admin identity, NSG, + // storage, and BeforeStartEvent subscription should only be set up once. + if (AdminIdentity is not null) + { + return; + } + + // Create a deployment script storage account (publish mode only). + // The BeforeStartEvent handler will remove the default storage if it's no longer + // needed if the user swapped it via WithAdminDeploymentScriptStorage. + AzureStorageResource? createdStorage = null; + if (DeploymentScriptStorage is null) + { + DeploymentScriptStorage = CreateDeploymentScriptStorage(builder, builder.CreateResourceBuilder(this)).Resource; + createdStorage = DeploymentScriptStorage; + } + + var admin = builder.AddAzureUserAssignedIdentity($"{Name}-admin-identity") + .WithAnnotation(new ExistingAzureResourceAnnotation(AdminName)); + AdminIdentity = admin.Resource; + + DeploymentScriptNetworkSecurityGroup = builder.AddNetworkSecurityGroup($"{Name}-nsg") + .WithSecurityRule(new AzureSecurityRule() + { + Name = "allow-outbound-443-AzureActiveDirectory", + Priority = 100, + Direction = SecurityRuleDirection.Outbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = AzureServiceTags.AzureActiveDirectory, + DestinationPortRange = "443", + }) + .WithSecurityRule(new AzureSecurityRule() + { + Name = "allow-outbound-443-Sql", + Priority = 200, + Direction = SecurityRuleDirection.Outbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = AzureServiceTags.Sql, + DestinationPortRange = "443", + }).Resource; + + builder.Eventing.Subscribe((data, token) => + { + PrepareDeploymentScriptInfrastructure(data.Model, this, createdStorage); + + return Task.CompletedTask; + }); + } + } + + private void RemoveDeploymentScriptStorage(DistributedApplicationModel appModel, AzureStorageResource storage) + { + if (ReferenceEquals(DeploymentScriptStorage, storage)) + { + DeploymentScriptStorage = null; + } + + appModel.Resources.Remove(storage); + } + + private sealed class StorageFiles(AzureStorageResource storage) : Resource("files"), IResourceWithParent, IAzurePrivateEndpointTarget + { + public BicepOutputReference Id => storage.Id; + + public IResource Parent => storage; + + public string GetPrivateDnsZoneName() => "privatelink.file.core.windows.net"; + + public IEnumerable GetPrivateLinkGroupIds() + { + yield return "file"; + } + } + + private static IResourceBuilder CreateDeploymentScriptStorage(IDistributedApplicationBuilder builder, IResourceBuilder azureSqlServer) + { + var sqlName = azureSqlServer.Resource.Name; + var storageName = $"{sqlName.Substring(0, Math.Min(sqlName.Length, 10))}-store"; + + return builder.AddAzureStorage(storageName) + .ConfigureInfrastructure(infra => + { + var sa = infra.GetProvisionableResources().OfType().SingleOrDefault() + ?? throw new InvalidOperationException("Could not find a StorageAccount resource in the infrastructure."); + + // Deployment scripts require shared key access for file share mounting. + sa.AllowSharedKeyAccess = true; + }); + } + + private static void PrepareDeploymentScriptInfrastructure(DistributedApplicationModel appModel, AzureSqlServerResource sql, AzureStorageResource? implicitStorage) + { + var hasPe = sql.HasAnnotationOfType(); + var hasRoleAssignments = sql.HasAnnotationOfType(); + + // When there's no private endpoint or no role assignments (e.g. ClearDefaultRoleAssignments was called), + // remove all deployment script infrastructure since the deployment scripts won't run. + if (!hasPe || !hasRoleAssignments) + { + if (implicitStorage is not null) + { + sql.RemoveDeploymentScriptStorage(appModel, implicitStorage); + } + + if (sql.AdminIdentity is not null) + { + appModel.Resources.Remove(sql.AdminIdentity); + sql.AdminIdentity = null; + } + + if (sql.DeploymentScriptNetworkSecurityGroup is not null) + { + appModel.Resources.Remove(sql.DeploymentScriptNetworkSecurityGroup); + sql.DeploymentScriptNetworkSecurityGroup = null; + } + + return; + } + + // If the implicitStorage was swapped out by WithAdminDeploymentScriptStorage, + // remove the original default from the model. + if (implicitStorage is not null && sql.DeploymentScriptStorage != implicitStorage) + { + sql.RemoveDeploymentScriptStorage(appModel, implicitStorage); + } + + // Find the private endpoint targeting this SQL server to get the VirtualNetwork + var pe = appModel.Resources.OfType() + .FirstOrDefault(p => ReferenceEquals(p.Target, sql)); + + if (pe is null) + { + return; + } + + var builder = new FakeDistributedApplicationBuilder(appModel); + + // add a role assignment to the DeploymentScriptStorage account so the deploymentScript can mount a file share in it. + builder.CreateResourceBuilder(sql.AdminIdentity!) + .WithRoleAssignments(builder.CreateResourceBuilder(sql.DeploymentScriptStorage!), StorageBuiltInRole.StorageFileDataPrivilegedContributor); + + // add a private endpoint to the DeploymentScriptStorage files service so the deploymentScript can access it. + var peSubnet = builder.CreateResourceBuilder(pe.Subnet); + var storagePe = peSubnet.AddPrivateEndpoint(builder.CreateResourceBuilder(new StorageFiles(sql.DeploymentScriptStorage!))); + sql.DeploymentScriptDependsOn.Add(storagePe.Resource); + + AzureSubnetResource aciSubnetResource; + // Only auto-allocate subnet if user didn't provide one + if (sql.TryGetLastAnnotation(out var subnetAnnotation)) + { + aciSubnetResource = subnetAnnotation.Subnet; + + // User provided an explicit subnet — remove the auto-created NSG since they manage their own + if (sql.DeploymentScriptNetworkSecurityGroup is { } nsg) + { + appModel.Resources.Remove(nsg); + sql.DeploymentScriptNetworkSecurityGroup = null; + } + } + else + { + var vnet = builder.CreateResourceBuilder(peSubnet.Resource.Parent); + + var existingSubnets = appModel.Resources.OfType() + .Where(s => ReferenceEquals(s.Parent, vnet.Resource)); + + var aciSubnetCidr = SubnetAddressAllocator.AllocateDeploymentScriptSubnet(vnet.Resource, existingSubnets); + var aciSubnet = vnet.AddSubnet($"{sql.Name}-aci-subnet", aciSubnetCidr) + .WithNetworkSecurityGroup(builder.CreateResourceBuilder(sql.DeploymentScriptNetworkSecurityGroup!)); + aciSubnetResource = aciSubnet.Resource; + + sql.Annotations.Add(new AdminDeploymentScriptSubnetAnnotation(aciSubnet.Resource)); + } + + // always delegate the subnet to ACI + aciSubnetResource.Annotations.Add(new AzureSubnetServiceDelegationAnnotation( + AciSubnetDelegationServiceId, + AciSubnetDelegationServiceId)); + } + + private sealed class FakeBuilder(T resource, IDistributedApplicationBuilder applicationBuilder) : IResourceBuilder where T : IResource + { + public IDistributedApplicationBuilder ApplicationBuilder => applicationBuilder; + public T Resource => resource; + public IResourceBuilder WithAnnotation(TAnnotation annotation, ResourceAnnotationMutationBehavior behavior = ResourceAnnotationMutationBehavior.Append) where TAnnotation : IResourceAnnotation + { + Resource.Annotations.Add(annotation); + return this; + } + } + + private sealed class FakeDistributedApplicationBuilder(DistributedApplicationModel model) : IDistributedApplicationBuilder + { + public IResourceCollection Resources => model.Resources; + public DistributedApplicationExecutionContext ExecutionContext { get; } = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish); + + public IResourceBuilder CreateResourceBuilder(T resource) where T : IResource + { + return new FakeBuilder(resource, this); + } + + public IResourceBuilder AddResource(T resource) where T : IResource + { + model.Resources.Add(resource); + return CreateResourceBuilder(resource); + } + + public ConfigurationManager Configuration => throw new NotImplementedException(); + + public string AppHostDirectory => throw new NotImplementedException(); + + public Assembly? AppHostAssembly => throw new NotImplementedException(); + + public IHostEnvironment Environment => throw new NotImplementedException(); + + public IServiceCollection Services => throw new NotImplementedException(); + + public IDistributedApplicationEventing Eventing => throw new NotImplementedException(); + +#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + public IDistributedApplicationPipeline Pipeline => throw new NotImplementedException(); +#pragma warning restore ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + public DistributedApplication Build() + { + throw new NotImplementedException(); + } + } } diff --git a/src/Aspire.Hosting.Azure.Sql/SubnetAddressAllocator.cs b/src/Aspire.Hosting.Azure.Sql/SubnetAddressAllocator.cs new file mode 100644 index 00000000000..cc9875fd006 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Sql/SubnetAddressAllocator.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using Aspire.Hosting.Azure; + +namespace Aspire.Hosting; + +/// +/// Allocates subnet address space within a virtual network. +/// +internal static class SubnetAddressAllocator +{ + /// + /// Allocates a /29 subnet from the highest available address in the virtual network. + /// + /// The virtual network to allocate from. + /// All existing subnets in the VirtualNetwork (from the app model). + /// The CIDR notation for the allocated subnet (e.g., "10.0.255.248/29"). + /// + /// Thrown when the VirtualNetwork address prefix is parameterized, or when no space is available. + /// + public static string AllocateDeploymentScriptSubnet(AzureVirtualNetworkResource vnet, IEnumerable existingSubnets) + { + const int prefixLength = 29; + const uint blockSize = 8; // 2^(32-29) = 8 + + var vnetAddressPrefix = vnet.AddressPrefix + ?? throw new InvalidOperationException( + $"Cannot automatically allocate a deployment script subnet for virtual network '{vnet.Name}' because it uses a parameterized address prefix. " + + $"Use 'WithAdminDeploymentScriptSubnet' to provide an explicit subnet."); + + var (vnetStart, vnetEnd) = ParseCidr(vnetAddressPrefix); + + // Ensure the VNet is large enough to contain at least one /29 block + if (vnetEnd - vnetStart + 1 < blockSize) + { + throw new InvalidOperationException( + $"Virtual network '{vnet.Name}' address space '{vnetAddressPrefix}' is too small to allocate a /{prefixLength} subnet (requires at least {blockSize} addresses). " + + $"Use 'WithAdminDeploymentScriptSubnet' to provide an explicit subnet."); + } + + // Collect all existing subnet ranges + var existingRanges = new List<(uint Start, uint End)>(); + foreach (var subnet in existingSubnets) + { + if (subnet.AddressPrefix is { } subnetCidr) + { + var range = ParseCidr(subnetCidr); + existingRanges.Add(range); + } + // Skip subnets with parameterized addresses — can't check overlap + } + + // Start from the highest /29-aligned address and work downward + var candidate = (vnetEnd - blockSize + 1) & ~(blockSize - 1); + + while (candidate >= vnetStart) + { + var candidateEnd = candidate + blockSize - 1; + + if (candidateEnd <= vnetEnd && !OverlapsAny(candidate, candidateEnd, existingRanges)) + { + return $"{UintToIp(candidate)}/{prefixLength}"; + } + + if (candidate < blockSize) + { + break; // Prevent underflow + } + + candidate -= blockSize; + } + + throw new InvalidOperationException( + $"Cannot allocate a /29 subnet in virtual network '{vnet.Name}' (address space: {vnetAddressPrefix}). " + + $"No non-overlapping address space is available. " + + $"Use 'WithAdminDeploymentScriptSubnet' to provide an explicit subnet."); + } + + private static bool OverlapsAny(uint start, uint end, List<(uint Start, uint End)> ranges) + { + foreach (var (rStart, rEnd) in ranges) + { + if (start <= rEnd && rStart <= end) + { + return true; + } + } + + return false; + } + + internal static (uint Start, uint End) ParseCidr(string cidr) + { + var parts = cidr.Split('/'); + if (parts.Length != 2 || !int.TryParse(parts[1], out var prefix) || prefix < 0 || prefix > 32) + { + throw new FormatException($"Invalid CIDR notation: '{cidr}'."); + } + + var ip = IPAddress.Parse(parts[0]); + if (ip.AddressFamily != AddressFamily.InterNetwork) + { + throw new FormatException($"Only IPv4 CIDR notation is supported: '{cidr}'."); + } + + var bytes = ip.GetAddressBytes(); + var address = (uint)((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]); + + // Compute the network mask + var mask = prefix == 0 ? 0u : uint.MaxValue << (32 - prefix); + var networkAddress = address & mask; + var broadcastAddress = networkAddress | ~mask; + + return (networkAddress, broadcastAddress); + } + + private static string UintToIp(uint address) + { + return $"{(address >> 24) & 0xFF}.{(address >> 16) & 0xFF}.{(address >> 8) & 0xFF}.{address & 0xFF}"; + } +} diff --git a/src/Aspire.Hosting.Azure/AzureProvisioningResource.cs b/src/Aspire.Hosting.Azure/AzureProvisioningResource.cs index 83396f8a0d3..8144954a3c5 100644 --- a/src/Aspire.Hosting.Azure/AzureProvisioningResource.cs +++ b/src/Aspire.Hosting.Azure/AzureProvisioningResource.cs @@ -134,7 +134,9 @@ public static T CreateExistingOrNewProvisionableResource(AzureResourceInfrast { var existingResourceName = existingAnnotation.Name is ParameterResource nameParameter ? nameParameter.AsProvisioningParameter(infrastructure) - : new BicepValue((string)existingAnnotation.Name); + : existingAnnotation.Name is BicepOutputReference nameOutputReference + ? nameOutputReference.AsProvisioningParameter(infrastructure) + : new BicepValue((string)existingAnnotation.Name); provisionedResource = createExisting(infrastructure.AspireResource.GetBicepIdentifier(), existingResourceName); if (existingAnnotation.ResourceGroup is not null) { @@ -176,6 +178,7 @@ public static bool TryApplyExistingResourceAnnotation(IAzureResource aspireResou var existingResourceName = existingAnnotation.Name switch { ParameterResource nameParameter => nameParameter.AsProvisioningParameter(infra), + BicepOutputReference nameOutputReference => nameOutputReference.AsProvisioningParameter(infra), string s => new BicepValue(s), _ => throw new NotSupportedException($"Existing resource name type '{existingAnnotation.Name.GetType()}' is not supported.") }; diff --git a/src/Aspire.Hosting.Azure/ExistingAzureResourceAnnotation.cs b/src/Aspire.Hosting.Azure/ExistingAzureResourceAnnotation.cs index 3406090c6f6..51dffb92b4d 100644 --- a/src/Aspire.Hosting.Azure/ExistingAzureResourceAnnotation.cs +++ b/src/Aspire.Hosting.Azure/ExistingAzureResourceAnnotation.cs @@ -15,7 +15,7 @@ public sealed class ExistingAzureResourceAnnotation(object name, object? resourc /// Gets the name of the existing resource. /// /// - /// Supports a or a via runtime validation. + /// Supports a , , or a via runtime validation. /// public object Name { get; } = name; diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs index 61b9a4d9846..b1fd51fbddf 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs @@ -17,7 +17,6 @@ public sealed class VnetSqlServerConnectivityDeploymentTests(ITestOutputHelper o private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); [Fact] - [ActiveIssue("https://github.com/dotnet/aspire/issues/14421")] public async Task DeployStarterTemplateWithSqlServerPrivateEndpoint() { using var cts = new CancellationTokenSource(s_testTimeout); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureSqlDeploymentScriptTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureSqlDeploymentScriptTests.cs new file mode 100644 index 00000000000..d7248b280d9 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureSqlDeploymentScriptTests.cs @@ -0,0 +1,213 @@ +// 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 ASPIREAZURE003 + +using System.Text; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using static Aspire.Hosting.Utils.AzureManifestUtils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureSqlDeploymentScriptTests +{ + [Fact] + public async Task SqlWithPrivateEndpoint_AutoCreatesBothSubnetAndStorage() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var peSubnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + var sqlServer = builder.AddAzureSqlServer("sql"); + var db = sqlServer.AddDatabase("db"); + + peSubnet.AddPrivateEndpoint(sqlServer); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + await VerifyAllAzureBicep(builder); + } + + [Fact] + public async Task SqlWithPrivateEndpoint_ExplicitSubnet_AutoCreatesStorage() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var peSubnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var aciSubnet = vnet.AddSubnet("acisubnet", "10.0.2.0/29"); + + var sqlServer = builder.AddAzureSqlServer("sql"); + var db = sqlServer.AddDatabase("db"); + + peSubnet.AddPrivateEndpoint(sqlServer); + sqlServer.WithAdminDeploymentScriptSubnet(aciSubnet); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + await VerifyAllAzureBicep(builder); + } + + [Fact] + public async Task SqlWithPrivateEndpoint_ExplicitStorage_AutoCreatesSubnet() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var peSubnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + var sqlServer = builder.AddAzureSqlServer("sql"); + var db = sqlServer.AddDatabase("db"); + + peSubnet.AddPrivateEndpoint(sqlServer); + + var storage = builder.AddAzureStorage("depscriptstorage"); + sqlServer.WithAdminDeploymentScriptStorage(storage); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + await VerifyAllAzureBicep(builder); + } + + [Fact] + public async Task SqlWithPrivateEndpoint_BothExplicitSubnetAndStorage() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var peSubnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var aciSubnet = vnet.AddSubnet("acisubnet", "10.0.2.0/29"); + + var sqlServer = builder.AddAzureSqlServer("sql"); + var db = sqlServer.AddDatabase("db"); + + peSubnet.AddPrivateEndpoint(sqlServer); + sqlServer.WithAdminDeploymentScriptSubnet(aciSubnet); + + var storage = builder.AddAzureStorage("depscriptstorage"); + sqlServer.WithAdminDeploymentScriptStorage(storage); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + await VerifyAllAzureBicep(builder); + } + + [Fact] + public async Task SqlWithPrivateEndpoint_StorageBeforePrivateEndpoint() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var peSubnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + var sqlServer = builder.AddAzureSqlServer("sql"); + var db = sqlServer.AddDatabase("db"); + + // Call WithAdminDeploymentScriptStorage BEFORE AddPrivateEndpoint + var storage = builder.AddAzureStorage("depscriptstorage"); + sqlServer.WithAdminDeploymentScriptStorage(storage); + + peSubnet.AddPrivateEndpoint(sqlServer); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + await VerifyAllAzureBicep(builder); + } + + [Fact] + public async Task SqlWithPrivateEndpoint_SubnetBeforePrivateEndpoint() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var peSubnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var aciSubnet = vnet.AddSubnet("acisubnet", "10.0.2.0/29"); + + var sqlServer = builder.AddAzureSqlServer("sql"); + var db = sqlServer.AddDatabase("db"); + + // Call WithAdminDeploymentScriptSubnet BEFORE AddPrivateEndpoint + sqlServer.WithAdminDeploymentScriptSubnet(aciSubnet); + + peSubnet.AddPrivateEndpoint(sqlServer); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + await VerifyAllAzureBicep(builder); + } + + [Fact] + public async Task SqlWithPrivateEndpoint_ClearDefaultRoleAssignments_RemovesDeploymentScriptInfra() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var peSubnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + var sqlServer = builder.AddAzureSqlServer("sql") + .ClearDefaultRoleAssignments(); + var db = sqlServer.AddDatabase("db"); + + peSubnet.AddPrivateEndpoint(sqlServer); + + builder.AddProject("api", launchProfileName: null) + .WithReference(db); + + await VerifyAllAzureBicep(builder); + } + + private static async Task VerifyAllAzureBicep(IDistributedApplicationBuilder builder) + { + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + // Collect bicep for all Azure provisioning resources, ordered by name for deterministic output + var azureResources = model.Resources + .OfType() + .OrderBy(r => r.Name) + .ToList(); + + var sb = new StringBuilder(); + + foreach (var resource in azureResources) + { + var bicep = await GetBicep(resource); + + sb.AppendLine($"// Resource: {resource.Name}"); + sb.AppendLine(bicep); + sb.AppendLine(); + } + + await Verify(sb.ToString(), extension: "bicep") + .ScrubLinesWithReplace(s => s.Replace("\\r\\n", "\\n")); + } + + private static async Task GetBicep(IResource resource) + { + var (_, bicep) = await AzureManifestUtils.GetManifestWithBicep(resource, skipPreparer: true); + return bicep; + } + + private sealed class Project : IProjectMetadata + { + public string ProjectPath => "project"; + } +} + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt index 1ee1136c7b5..15493b3cdf2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt @@ -5,7 +5,7 @@ PIPELINE DEPENDENCY GRAPH DIAGNOSTICS This diagnostic output shows the complete pipeline dependency graph structure. Use this to understand step relationships and troubleshoot execution issues. -Total steps defined: 34 +Total steps defined: 40 Analysis for full pipeline execution (showing all steps and their relationships) @@ -24,30 +24,36 @@ Steps with no dependencies run first, followed by steps that depend on them. 8. provision-api-identity 9. provision-cosmos 10. provision-api-roles-cosmos - 11. provision-sql - 12. provision-api-roles-sql - 13. provision-env-acr - 14. provision-env - 15. provision-vnet - 16. provision-privatelink-documents-azure-com - 17. provision-pe-subnet-cosmos-pe - 18. provision-privatelink-database-windows-net - 19. provision-pe-subnet-sql-pe - 20. login-to-acr-env-acr - 21. push-prereq - 22. push-api - 23. provision-api-containerapp - 24. print-api-summary - 25. provision-azure-bicep-resources - 26. print-dashboard-url-env - 27. deploy - 28. deploy-api - 29. diagnostics - 30. publish-prereq - 31. publish-azure634f9 - 32. publish - 33. publish-manifest - 34. push + 11. provision-sql-nsg + 12. provision-vnet + 13. provision-privatelink-file-core-windows-net + 14. provision-sql-store + 15. provision-pe-subnet-files-pe + 16. provision-privatelink-database-windows-net + 17. provision-sql + 18. provision-pe-subnet-sql-pe + 19. provision-api-roles-sql + 20. provision-env-acr + 21. provision-env + 22. provision-privatelink-documents-azure-com + 23. provision-pe-subnet-cosmos-pe + 24. login-to-acr-env-acr + 25. push-prereq + 26. push-api + 27. provision-api-containerapp + 28. print-api-summary + 29. provision-sql-admin-identity + 30. provision-sql-admin-identity-roles-sql-store + 31. provision-azure-bicep-resources + 32. print-dashboard-url-env + 33. deploy + 34. deploy-api + 35. diagnostics + 36. publish-prereq + 37. publish-azure634f9 + 38. publish + 39. publish-manifest + 40. push DETAILED STEP ANALYSIS ====================== @@ -132,13 +138,13 @@ Step: provision-api-roles-cosmos Step: provision-api-roles-sql Description: Provisions the Azure Bicep resource api-roles-sql using Azure infrastructure. - Dependencies: ✓ create-provisioning-context, ✓ provision-api-identity, ✓ provision-sql + Dependencies: ✓ create-provisioning-context, ✓ provision-api-identity, ✓ provision-pe-subnet-files-pe, ✓ provision-pe-subnet-sql-pe, ✓ provision-sql, ✓ provision-sql-store, ✓ provision-vnet Resource: api-roles-sql (AzureProvisioningResource) Tags: provision-infra Step: provision-azure-bicep-resources Description: Aggregation step for all Azure infrastructure provisioning operations. - Dependencies: ✓ create-provisioning-context, ✓ deploy-prereq, ✓ provision-api-containerapp, ✓ provision-api-identity, ✓ provision-api-roles-cosmos, ✓ provision-api-roles-sql, ✓ provision-cosmos, ✓ provision-env, ✓ provision-env-acr, ✓ provision-pe-subnet-cosmos-pe, ✓ provision-pe-subnet-sql-pe, ✓ provision-privatelink-database-windows-net, ✓ provision-privatelink-documents-azure-com, ✓ provision-sql, ✓ provision-vnet + Dependencies: ✓ create-provisioning-context, ✓ deploy-prereq, ✓ provision-api-containerapp, ✓ provision-api-identity, ✓ provision-api-roles-cosmos, ✓ provision-api-roles-sql, ✓ provision-cosmos, ✓ provision-env, ✓ provision-env-acr, ✓ provision-pe-subnet-cosmos-pe, ✓ provision-pe-subnet-files-pe, ✓ provision-pe-subnet-sql-pe, ✓ provision-privatelink-database-windows-net, ✓ provision-privatelink-documents-azure-com, ✓ provision-privatelink-file-core-windows-net, ✓ provision-sql, ✓ provision-sql-admin-identity, ✓ provision-sql-admin-identity-roles-sql-store, ✓ provision-sql-nsg, ✓ provision-sql-store, ✓ provision-vnet Resource: azure634f9 (AzureEnvironmentResource) Tags: provision-infra @@ -166,6 +172,12 @@ Step: provision-pe-subnet-cosmos-pe Resource: pe-subnet-cosmos-pe (AzurePrivateEndpointResource) Tags: provision-infra +Step: provision-pe-subnet-files-pe + Description: Provisions the Azure Bicep resource pe-subnet-files-pe using Azure infrastructure. + Dependencies: ✓ create-provisioning-context, ✓ provision-privatelink-file-core-windows-net, ✓ provision-sql-store, ✓ provision-vnet + Resource: pe-subnet-files-pe (AzurePrivateEndpointResource) + Tags: provision-infra + Step: provision-pe-subnet-sql-pe Description: Provisions the Azure Bicep resource pe-subnet-sql-pe using Azure infrastructure. Dependencies: ✓ create-provisioning-context, ✓ provision-privatelink-database-windows-net, ✓ provision-sql, ✓ provision-vnet @@ -184,15 +196,45 @@ Step: provision-privatelink-documents-azure-com Resource: privatelink-documents-azure-com (AzurePrivateDnsZoneResource) Tags: provision-infra +Step: provision-privatelink-file-core-windows-net + Description: Provisions the Azure Bicep resource privatelink-file-core-windows-net using Azure infrastructure. + Dependencies: ✓ create-provisioning-context, ✓ provision-vnet + Resource: privatelink-file-core-windows-net (AzurePrivateDnsZoneResource) + Tags: provision-infra + Step: provision-sql Description: Provisions the Azure Bicep resource sql using Azure infrastructure. Dependencies: ✓ create-provisioning-context Resource: sql (AzureSqlServerResource) Tags: provision-infra +Step: provision-sql-admin-identity + Description: Provisions the Azure Bicep resource sql-admin-identity using Azure infrastructure. + Dependencies: ✓ create-provisioning-context, ✓ provision-sql + Resource: sql-admin-identity (AzureUserAssignedIdentityResource) + Tags: provision-infra + +Step: provision-sql-admin-identity-roles-sql-store + Description: Provisions the Azure Bicep resource sql-admin-identity-roles-sql-store using Azure infrastructure. + Dependencies: ✓ create-provisioning-context, ✓ provision-sql-admin-identity, ✓ provision-sql-store + Resource: sql-admin-identity-roles-sql-store (AzureProvisioningResource) + Tags: provision-infra + +Step: provision-sql-nsg + Description: Provisions the Azure Bicep resource sql-nsg using Azure infrastructure. + Dependencies: ✓ create-provisioning-context + Resource: sql-nsg (AzureNetworkSecurityGroupResource) + Tags: provision-infra + +Step: provision-sql-store + Description: Provisions the Azure Bicep resource sql-store using Azure infrastructure. + Dependencies: ✓ create-provisioning-context + Resource: sql-store (AzureStorageResource) + Tags: provision-infra + Step: provision-vnet Description: Provisions the Azure Bicep resource vnet using Azure infrastructure. - Dependencies: ✓ create-provisioning-context + Dependencies: ✓ create-provisioning-context, ✓ provision-sql-nsg Resource: vnet (AzureVirtualNetworkResource) Tags: provision-infra @@ -277,36 +319,38 @@ If targeting 'create-provisioning-context': If targeting 'deploy': Direct dependencies: build-api, create-provisioning-context, print-api-summary, print-dashboard-url-env, provision-azure-bicep-resources, validate-azure-login - Total steps: 26 + Total steps: 32 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-vnet (parallel) - [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-api-roles-sql | provision-env | provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com (parallel) - [6] provision-pe-subnet-cosmos-pe | provision-pe-subnet-sql-pe | push-prereq (parallel) - [7] push-api - [8] provision-api-containerapp - [9] print-api-summary | provision-azure-bicep-resources (parallel) - [10] print-dashboard-url-env - [11] deploy + [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-sql-nsg | provision-sql-store (parallel) + [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-env | provision-sql-admin-identity | provision-vnet (parallel) + [6] provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com | provision-privatelink-file-core-windows-net | provision-sql-admin-identity-roles-sql-store | push-prereq (parallel) + [7] provision-pe-subnet-cosmos-pe | provision-pe-subnet-files-pe | provision-pe-subnet-sql-pe | push-api (parallel) + [8] provision-api-roles-sql + [9] provision-api-containerapp + [10] print-api-summary | provision-azure-bicep-resources (parallel) + [11] print-dashboard-url-env + [12] deploy If targeting 'deploy-api': Direct dependencies: print-api-summary - Total steps: 24 + Total steps: 28 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-vnet (parallel) - [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-api-roles-sql | provision-env | provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com (parallel) - [6] provision-pe-subnet-cosmos-pe | provision-pe-subnet-sql-pe | push-prereq (parallel) - [7] push-api - [8] provision-api-containerapp - [9] print-api-summary - [10] deploy-api + [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-sql-nsg | provision-sql-store (parallel) + [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-env | provision-vnet (parallel) + [6] provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com | provision-privatelink-file-core-windows-net | push-prereq (parallel) + [7] provision-pe-subnet-cosmos-pe | provision-pe-subnet-files-pe | provision-pe-subnet-sql-pe | push-api (parallel) + [8] provision-api-roles-sql + [9] provision-api-containerapp + [10] print-api-summary + [11] deploy-api If targeting 'deploy-prereq': Direct dependencies: process-parameters @@ -334,34 +378,36 @@ If targeting 'login-to-acr-env-acr': If targeting 'print-api-summary': Direct dependencies: provision-api-containerapp - Total steps: 23 + Total steps: 27 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-vnet (parallel) - [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-api-roles-sql | provision-env | provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com (parallel) - [6] provision-pe-subnet-cosmos-pe | provision-pe-subnet-sql-pe | push-prereq (parallel) - [7] push-api - [8] provision-api-containerapp - [9] print-api-summary + [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-sql-nsg | provision-sql-store (parallel) + [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-env | provision-vnet (parallel) + [6] provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com | provision-privatelink-file-core-windows-net | push-prereq (parallel) + [7] provision-pe-subnet-cosmos-pe | provision-pe-subnet-files-pe | provision-pe-subnet-sql-pe | push-api (parallel) + [8] provision-api-roles-sql + [9] provision-api-containerapp + [10] print-api-summary If targeting 'print-dashboard-url-env': Direct dependencies: provision-azure-bicep-resources, provision-env - Total steps: 24 + Total steps: 30 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-vnet (parallel) - [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-api-roles-sql | provision-env | provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com (parallel) - [6] provision-pe-subnet-cosmos-pe | provision-pe-subnet-sql-pe | push-prereq (parallel) - [7] push-api - [8] provision-api-containerapp - [9] provision-azure-bicep-resources - [10] print-dashboard-url-env + [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-sql-nsg | provision-sql-store (parallel) + [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-env | provision-sql-admin-identity | provision-vnet (parallel) + [6] provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com | provision-privatelink-file-core-windows-net | provision-sql-admin-identity-roles-sql-store | push-prereq (parallel) + [7] provision-pe-subnet-cosmos-pe | provision-pe-subnet-files-pe | provision-pe-subnet-sql-pe | push-api (parallel) + [8] provision-api-roles-sql + [9] provision-api-containerapp + [10] provision-azure-bicep-resources + [11] print-dashboard-url-env If targeting 'process-parameters': Direct dependencies: none @@ -371,17 +417,18 @@ If targeting 'process-parameters': If targeting 'provision-api-containerapp': Direct dependencies: create-provisioning-context, provision-api-identity, provision-api-roles-cosmos, provision-api-roles-sql, provision-cosmos, provision-env, provision-pe-subnet-cosmos-pe, provision-pe-subnet-sql-pe, provision-sql, push-api - Total steps: 22 + Total steps: 26 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-vnet (parallel) - [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-api-roles-sql | provision-env | provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com (parallel) - [6] provision-pe-subnet-cosmos-pe | provision-pe-subnet-sql-pe | push-prereq (parallel) - [7] push-api - [8] provision-api-containerapp + [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-sql-nsg | provision-sql-store (parallel) + [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-env | provision-vnet (parallel) + [6] provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com | provision-privatelink-file-core-windows-net | push-prereq (parallel) + [7] provision-pe-subnet-cosmos-pe | provision-pe-subnet-files-pe | provision-pe-subnet-sql-pe | push-api (parallel) + [8] provision-api-roles-sql + [9] provision-api-containerapp If targeting 'provision-api-identity': Direct dependencies: create-provisioning-context @@ -405,30 +452,34 @@ If targeting 'provision-api-roles-cosmos': [5] provision-api-roles-cosmos If targeting 'provision-api-roles-sql': - Direct dependencies: create-provisioning-context, provision-api-identity, provision-sql - Total steps: 7 + Direct dependencies: create-provisioning-context, provision-api-identity, provision-pe-subnet-files-pe, provision-pe-subnet-sql-pe, provision-sql, provision-sql-store, provision-vnet + Total steps: 14 Execution order: [0] process-parameters [1] deploy-prereq [2] validate-azure-login [3] create-provisioning-context - [4] provision-api-identity | provision-sql (parallel) - [5] provision-api-roles-sql + [4] provision-api-identity | provision-sql | provision-sql-nsg | provision-sql-store (parallel) + [5] provision-vnet + [6] provision-privatelink-database-windows-net | provision-privatelink-file-core-windows-net (parallel) + [7] provision-pe-subnet-files-pe | provision-pe-subnet-sql-pe (parallel) + [8] provision-api-roles-sql If targeting 'provision-azure-bicep-resources': - Direct dependencies: create-provisioning-context, deploy-prereq, provision-api-containerapp, provision-api-identity, provision-api-roles-cosmos, provision-api-roles-sql, provision-cosmos, provision-env, provision-env-acr, provision-pe-subnet-cosmos-pe, provision-pe-subnet-sql-pe, provision-privatelink-database-windows-net, provision-privatelink-documents-azure-com, provision-sql, provision-vnet - Total steps: 23 + Direct dependencies: create-provisioning-context, deploy-prereq, provision-api-containerapp, provision-api-identity, provision-api-roles-cosmos, provision-api-roles-sql, provision-cosmos, provision-env, provision-env-acr, provision-pe-subnet-cosmos-pe, provision-pe-subnet-files-pe, provision-pe-subnet-sql-pe, provision-privatelink-database-windows-net, provision-privatelink-documents-azure-com, provision-privatelink-file-core-windows-net, provision-sql, provision-sql-admin-identity, provision-sql-admin-identity-roles-sql-store, provision-sql-nsg, provision-sql-store, provision-vnet + Total steps: 29 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-vnet (parallel) - [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-api-roles-sql | provision-env | provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com (parallel) - [6] provision-pe-subnet-cosmos-pe | provision-pe-subnet-sql-pe | push-prereq (parallel) - [7] push-api - [8] provision-api-containerapp - [9] provision-azure-bicep-resources + [4] provision-api-identity | provision-cosmos | provision-env-acr | provision-sql | provision-sql-nsg | provision-sql-store (parallel) + [5] login-to-acr-env-acr | provision-api-roles-cosmos | provision-env | provision-sql-admin-identity | provision-vnet (parallel) + [6] provision-privatelink-database-windows-net | provision-privatelink-documents-azure-com | provision-privatelink-file-core-windows-net | provision-sql-admin-identity-roles-sql-store | push-prereq (parallel) + [7] provision-pe-subnet-cosmos-pe | provision-pe-subnet-files-pe | provision-pe-subnet-sql-pe | push-api (parallel) + [8] provision-api-roles-sql + [9] provision-api-containerapp + [10] provision-azure-bicep-resources If targeting 'provision-cosmos': Direct dependencies: create-provisioning-context @@ -463,49 +514,78 @@ If targeting 'provision-env-acr': If targeting 'provision-pe-subnet-cosmos-pe': Direct dependencies: create-provisioning-context, provision-cosmos, provision-privatelink-documents-azure-com, provision-vnet - Total steps: 8 + Total steps: 9 Execution order: [0] process-parameters [1] deploy-prereq [2] validate-azure-login [3] create-provisioning-context - [4] provision-cosmos | provision-vnet (parallel) - [5] provision-privatelink-documents-azure-com - [6] provision-pe-subnet-cosmos-pe + [4] provision-cosmos | provision-sql-nsg (parallel) + [5] provision-vnet + [6] provision-privatelink-documents-azure-com + [7] provision-pe-subnet-cosmos-pe + +If targeting 'provision-pe-subnet-files-pe': + Direct dependencies: create-provisioning-context, provision-privatelink-file-core-windows-net, provision-sql-store, provision-vnet + Total steps: 9 + Execution order: + [0] process-parameters + [1] deploy-prereq + [2] validate-azure-login + [3] create-provisioning-context + [4] provision-sql-nsg | provision-sql-store (parallel) + [5] provision-vnet + [6] provision-privatelink-file-core-windows-net + [7] provision-pe-subnet-files-pe If targeting 'provision-pe-subnet-sql-pe': Direct dependencies: create-provisioning-context, provision-privatelink-database-windows-net, provision-sql, provision-vnet - Total steps: 8 + Total steps: 9 Execution order: [0] process-parameters [1] deploy-prereq [2] validate-azure-login [3] create-provisioning-context - [4] provision-sql | provision-vnet (parallel) - [5] provision-privatelink-database-windows-net - [6] provision-pe-subnet-sql-pe + [4] provision-sql | provision-sql-nsg (parallel) + [5] provision-vnet + [6] provision-privatelink-database-windows-net + [7] provision-pe-subnet-sql-pe If targeting 'provision-privatelink-database-windows-net': Direct dependencies: create-provisioning-context, provision-vnet - Total steps: 6 + Total steps: 7 Execution order: [0] process-parameters [1] deploy-prereq [2] validate-azure-login [3] create-provisioning-context - [4] provision-vnet - [5] provision-privatelink-database-windows-net + [4] provision-sql-nsg + [5] provision-vnet + [6] provision-privatelink-database-windows-net If targeting 'provision-privatelink-documents-azure-com': Direct dependencies: create-provisioning-context, provision-vnet - Total steps: 6 + Total steps: 7 Execution order: [0] process-parameters [1] deploy-prereq [2] validate-azure-login [3] create-provisioning-context - [4] provision-vnet - [5] provision-privatelink-documents-azure-com + [4] provision-sql-nsg + [5] provision-vnet + [6] provision-privatelink-documents-azure-com + +If targeting 'provision-privatelink-file-core-windows-net': + Direct dependencies: create-provisioning-context, provision-vnet + Total steps: 7 + Execution order: + [0] process-parameters + [1] deploy-prereq + [2] validate-azure-login + [3] create-provisioning-context + [4] provision-sql-nsg + [5] provision-vnet + [6] provision-privatelink-file-core-windows-net If targeting 'provision-sql': Direct dependencies: create-provisioning-context @@ -517,7 +597,30 @@ If targeting 'provision-sql': [3] create-provisioning-context [4] provision-sql -If targeting 'provision-vnet': +If targeting 'provision-sql-admin-identity': + Direct dependencies: create-provisioning-context, provision-sql + Total steps: 6 + Execution order: + [0] process-parameters + [1] deploy-prereq + [2] validate-azure-login + [3] create-provisioning-context + [4] provision-sql + [5] provision-sql-admin-identity + +If targeting 'provision-sql-admin-identity-roles-sql-store': + Direct dependencies: create-provisioning-context, provision-sql-admin-identity, provision-sql-store + Total steps: 8 + Execution order: + [0] process-parameters + [1] deploy-prereq + [2] validate-azure-login + [3] create-provisioning-context + [4] provision-sql | provision-sql-store (parallel) + [5] provision-sql-admin-identity + [6] provision-sql-admin-identity-roles-sql-store + +If targeting 'provision-sql-nsg': Direct dependencies: create-provisioning-context Total steps: 5 Execution order: @@ -525,7 +628,28 @@ If targeting 'provision-vnet': [1] deploy-prereq [2] validate-azure-login [3] create-provisioning-context - [4] provision-vnet + [4] provision-sql-nsg + +If targeting 'provision-sql-store': + Direct dependencies: create-provisioning-context + Total steps: 5 + Execution order: + [0] process-parameters + [1] deploy-prereq + [2] validate-azure-login + [3] create-provisioning-context + [4] provision-sql-store + +If targeting 'provision-vnet': + Direct dependencies: create-provisioning-context, provision-sql-nsg + Total steps: 6 + Execution order: + [0] process-parameters + [1] deploy-prereq + [2] validate-azure-login + [3] create-provisioning-context + [4] provision-sql-nsg + [5] provision-vnet If targeting 'publish': Direct dependencies: publish-azure634f9 diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_AutoCreatesBothSubnetAndStorage.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_AutoCreatesBothSubnetAndStorage.verified.bicep new file mode 100644 index 00000000000..e75bbbbd588 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_AutoCreatesBothSubnetAndStorage.verified.bicep @@ -0,0 +1,636 @@ +// Resource: api-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name + +// Resource: api-roles-sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_name string + +param sql_outputs_sqlserveradminname string + +param myvnet_outputs_sql_aci_subnet_id string + +param sql_store_outputs_name string + +param principalId string + +param principalName string + +param pesubnet_sql_pe_outputs_name string + +param pesubnet_files_pe_outputs_name string + +resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { + name: sql_outputs_name +} + +resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: principalName +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_sql_pe_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_files_pe_outputs_name +} + +resource script_sql_db 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: take('script-${uniqueString('sql', principalName, 'db', resourceGroup().id)}', 24) + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerAdmin.id}': { } + } + } + kind: 'AzurePowerShell' + properties: { + azPowerShellVersion: '14.0' + retentionInterval: 'PT1H' + containerSettings: { + subnetIds: [ + { + id: myvnet_outputs_sql_aci_subnet_id + } + ] + } + environmentVariables: [ + { + name: 'DBNAME' + value: 'db' + } + { + name: 'DBSERVER' + value: sql.properties.fullyQualifiedDomainName + } + { + name: 'PRINCIPALTYPE' + value: 'ServicePrincipal' + } + { + name: 'PRINCIPALNAME' + value: principalName + } + { + name: 'ID' + value: mi.properties.clientId + } + ] + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\n\$maxRetries = 5\n\$retryDelay = 60\n\$attempt = 0\n\$success = \$false\n\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\n \$attempt++\n Write-Host "Attempt \$attempt of \$maxRetries..."\n try {\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\n \$success = \$true\n Write-Host "SQL command succeeded on attempt \$attempt."\n } catch {\n Write-Host "Attempt \$attempt failed: \$_"\n if (\$attempt -lt \$maxRetries) {\n Write-Host "Retrying in \$retryDelay seconds..."\n Start-Sleep -Seconds \$retryDelay\n } else {\n throw\n }\n }\n}' + storageAccountSettings: { + storageAccountName: sql_store_outputs_name + } + } + dependsOn: [ + pesubnet_sql_pe + pesubnet_files_pe + ] +} + +// Resource: env +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain + +// Resource: env-acr +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer + +// Resource: myvnet +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource pesubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'pesubnet' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: myvnet +} + +resource sql_aci_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'sql-aci-subnet' + properties: { + addressPrefix: '10.0.255.248/29' + delegations: [ + { + properties: { + serviceName: 'Microsoft.ContainerInstance/containerGroups' + } + name: 'Microsoft.ContainerInstance/containerGroups' + } + ] + networkSecurityGroup: { + id: sql_nsg_outputs_id + } + } + parent: myvnet + dependsOn: [ + pesubnet + ] +} + +output pesubnet_Id string = pesubnet.id + +output sql_aci_subnet_Id string = sql_aci_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name + +// Resource: pesubnet-files-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_file_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_store_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_file_core_windows_net_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_files_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_store_outputs_id + groupIds: [ + 'file' + ] + } + name: 'pesubnet-files-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-files-pe' + } +} + +resource pesubnet_files_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_file_core_windows_net' + properties: { + privateDnsZoneId: privatelink_file_core_windows_net.id + } + } + ] + } + parent: pesubnet_files_pe +} + +output id string = pesubnet_files_pe.id + +output name string = pesubnet_files_pe.name + +// Resource: pesubnet-sql-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'pesubnet-sql-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-sql-pe' + } +} + +resource pesubnet_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: pesubnet_sql_pe +} + +output id string = pesubnet_sql_pe.id + +output name string = pesubnet_sql_pe.name + +// Resource: privatelink-database-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-myvnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' + +// Resource: privatelink-file-core-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.file.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net-myvnet-link' + } + parent: privatelink_file_core_windows_net +} + +output id string = privatelink_file_core_windows_net.id + +output name string = 'privatelink.file.core.windows.net' + +// Resource: sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'db' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login + +// Resource: sql-admin-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_sqlserveradminname string + +resource sql_admin_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +output id string = sql_admin_identity.id + +output clientId string = sql_admin_identity.properties.clientId + +output principalId string = sql_admin_identity.properties.principalId + +output principalName string = sql_admin_identity.name + +output name string = sql_admin_identity.name + +// Resource: sql-admin-identity-roles-sql-store +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_store_outputs_name string + +param principalId string + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource sql_store_StorageFileDataPrivilegedContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(sql_store.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd') + principalType: 'ServicePrincipal' + } + scope: sql_store +} + +// Resource: sql-nsg +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('sql_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'sql-nsg' + } +} + +resource sql_nsg_allow_outbound_443_AzureActiveDirectory 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-AzureActiveDirectory' + properties: { + access: 'Allow' + destinationAddressPrefix: 'AzureActiveDirectory' + destinationPortRange: '443' + direction: 'Outbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +resource sql_nsg_allow_outbound_443_Sql 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-Sql' + properties: { + access: 'Allow' + destinationAddressPrefix: 'Sql' + destinationPortRange: '443' + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +output id string = sql_nsg.id + +output name string = sql_nsg.name + +// Resource: sql-store +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('sqlstore${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: true + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'sql-store' + } +} + +output blobEndpoint string = sql_store.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = sql_store.properties.primaryEndpoints.dfs + +output queueEndpoint string = sql_store.properties.primaryEndpoints.queue + +output tableEndpoint string = sql_store.properties.primaryEndpoints.table + +output name string = sql_store.name + +output id string = sql_store.id + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_BothExplicitSubnetAndStorage.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_BothExplicitSubnetAndStorage.verified.bicep new file mode 100644 index 00000000000..e3d1e16e20b --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_BothExplicitSubnetAndStorage.verified.bicep @@ -0,0 +1,585 @@ +// Resource: api-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name + +// Resource: api-roles-sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_name string + +param sql_outputs_sqlserveradminname string + +param myvnet_outputs_acisubnet_id string + +param depscriptstorage_outputs_name string + +param principalId string + +param principalName string + +param pesubnet_sql_pe_outputs_name string + +param pesubnet_files_pe_outputs_name string + +resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { + name: sql_outputs_name +} + +resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: depscriptstorage_outputs_name +} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: principalName +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_sql_pe_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_files_pe_outputs_name +} + +resource script_sql_db 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: take('script-${uniqueString('sql', principalName, 'db', resourceGroup().id)}', 24) + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerAdmin.id}': { } + } + } + kind: 'AzurePowerShell' + properties: { + azPowerShellVersion: '14.0' + retentionInterval: 'PT1H' + containerSettings: { + subnetIds: [ + { + id: myvnet_outputs_acisubnet_id + } + ] + } + environmentVariables: [ + { + name: 'DBNAME' + value: 'db' + } + { + name: 'DBSERVER' + value: sql.properties.fullyQualifiedDomainName + } + { + name: 'PRINCIPALTYPE' + value: 'ServicePrincipal' + } + { + name: 'PRINCIPALNAME' + value: principalName + } + { + name: 'ID' + value: mi.properties.clientId + } + ] + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\n\$maxRetries = 5\n\$retryDelay = 60\n\$attempt = 0\n\$success = \$false\n\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\n \$attempt++\n Write-Host "Attempt \$attempt of \$maxRetries..."\n try {\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\n \$success = \$true\n Write-Host "SQL command succeeded on attempt \$attempt."\n } catch {\n Write-Host "Attempt \$attempt failed: \$_"\n if (\$attempt -lt \$maxRetries) {\n Write-Host "Retrying in \$retryDelay seconds..."\n Start-Sleep -Seconds \$retryDelay\n } else {\n throw\n }\n }\n}' + storageAccountSettings: { + storageAccountName: depscriptstorage_outputs_name + } + } + dependsOn: [ + pesubnet_sql_pe + pesubnet_files_pe + ] +} + +// Resource: depscriptstorage +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('depscriptstorage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: true + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'depscriptstorage' + } +} + +output blobEndpoint string = depscriptstorage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = depscriptstorage.properties.primaryEndpoints.dfs + +output queueEndpoint string = depscriptstorage.properties.primaryEndpoints.queue + +output tableEndpoint string = depscriptstorage.properties.primaryEndpoints.table + +output name string = depscriptstorage.name + +output id string = depscriptstorage.id + +// Resource: env +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain + +// Resource: env-acr +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer + +// Resource: myvnet +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource pesubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'pesubnet' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: myvnet +} + +resource acisubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'acisubnet' + properties: { + addressPrefix: '10.0.2.0/29' + delegations: [ + { + properties: { + serviceName: 'Microsoft.ContainerInstance/containerGroups' + } + name: 'Microsoft.ContainerInstance/containerGroups' + } + ] + } + parent: myvnet + dependsOn: [ + pesubnet + ] +} + +output pesubnet_Id string = pesubnet.id + +output acisubnet_Id string = acisubnet.id + +output id string = myvnet.id + +output name string = myvnet.name + +// Resource: pesubnet-files-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_file_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param depscriptstorage_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_file_core_windows_net_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_files_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: depscriptstorage_outputs_id + groupIds: [ + 'file' + ] + } + name: 'pesubnet-files-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-files-pe' + } +} + +resource pesubnet_files_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_file_core_windows_net' + properties: { + privateDnsZoneId: privatelink_file_core_windows_net.id + } + } + ] + } + parent: pesubnet_files_pe +} + +output id string = pesubnet_files_pe.id + +output name string = pesubnet_files_pe.name + +// Resource: pesubnet-sql-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'pesubnet-sql-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-sql-pe' + } +} + +resource pesubnet_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: pesubnet_sql_pe +} + +output id string = pesubnet_sql_pe.id + +output name string = pesubnet_sql_pe.name + +// Resource: privatelink-database-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-myvnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' + +// Resource: privatelink-file-core-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.file.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net-myvnet-link' + } + parent: privatelink_file_core_windows_net +} + +output id string = privatelink_file_core_windows_net.id + +output name string = 'privatelink.file.core.windows.net' + +// Resource: sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'db' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login + +// Resource: sql-admin-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_sqlserveradminname string + +resource sql_admin_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +output id string = sql_admin_identity.id + +output clientId string = sql_admin_identity.properties.clientId + +output principalId string = sql_admin_identity.properties.principalId + +output principalName string = sql_admin_identity.name + +output name string = sql_admin_identity.name + +// Resource: sql-admin-identity-roles-depscriptstorage +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param depscriptstorage_outputs_name string + +param principalId string + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: depscriptstorage_outputs_name +} + +resource depscriptstorage_StorageFileDataPrivilegedContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(depscriptstorage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd') + principalType: 'ServicePrincipal' + } + scope: depscriptstorage +} + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ClearDefaultRoleAssignments_RemovesDeploymentScriptInfra.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ClearDefaultRoleAssignments_RemovesDeploymentScriptInfra.verified.bicep new file mode 100644 index 00000000000..8d312d88450 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ClearDefaultRoleAssignments_RemovesDeploymentScriptInfra.verified.bicep @@ -0,0 +1,278 @@ +// Resource: env +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain + +// Resource: env-acr +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer + +// Resource: myvnet +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource pesubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'pesubnet' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: myvnet +} + +output pesubnet_Id string = pesubnet.id + +output id string = myvnet.id + +output name string = myvnet.name + +// Resource: pesubnet-sql-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'pesubnet-sql-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-sql-pe' + } +} + +resource pesubnet_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: pesubnet_sql_pe +} + +output id string = pesubnet_sql_pe.id + +output name string = pesubnet_sql_pe.name + +// Resource: privatelink-database-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-myvnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' + +// Resource: sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'db' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ExplicitStorage_AutoCreatesSubnet.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ExplicitStorage_AutoCreatesSubnet.verified.bicep new file mode 100644 index 00000000000..6cad2dbdba5 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ExplicitStorage_AutoCreatesSubnet.verified.bicep @@ -0,0 +1,636 @@ +// Resource: api-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name + +// Resource: api-roles-sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_name string + +param sql_outputs_sqlserveradminname string + +param myvnet_outputs_sql_aci_subnet_id string + +param depscriptstorage_outputs_name string + +param principalId string + +param principalName string + +param pesubnet_sql_pe_outputs_name string + +param pesubnet_files_pe_outputs_name string + +resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { + name: sql_outputs_name +} + +resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: depscriptstorage_outputs_name +} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: principalName +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_sql_pe_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_files_pe_outputs_name +} + +resource script_sql_db 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: take('script-${uniqueString('sql', principalName, 'db', resourceGroup().id)}', 24) + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerAdmin.id}': { } + } + } + kind: 'AzurePowerShell' + properties: { + azPowerShellVersion: '14.0' + retentionInterval: 'PT1H' + containerSettings: { + subnetIds: [ + { + id: myvnet_outputs_sql_aci_subnet_id + } + ] + } + environmentVariables: [ + { + name: 'DBNAME' + value: 'db' + } + { + name: 'DBSERVER' + value: sql.properties.fullyQualifiedDomainName + } + { + name: 'PRINCIPALTYPE' + value: 'ServicePrincipal' + } + { + name: 'PRINCIPALNAME' + value: principalName + } + { + name: 'ID' + value: mi.properties.clientId + } + ] + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\n\$maxRetries = 5\n\$retryDelay = 60\n\$attempt = 0\n\$success = \$false\n\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\n \$attempt++\n Write-Host "Attempt \$attempt of \$maxRetries..."\n try {\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\n \$success = \$true\n Write-Host "SQL command succeeded on attempt \$attempt."\n } catch {\n Write-Host "Attempt \$attempt failed: \$_"\n if (\$attempt -lt \$maxRetries) {\n Write-Host "Retrying in \$retryDelay seconds..."\n Start-Sleep -Seconds \$retryDelay\n } else {\n throw\n }\n }\n}' + storageAccountSettings: { + storageAccountName: depscriptstorage_outputs_name + } + } + dependsOn: [ + pesubnet_sql_pe + pesubnet_files_pe + ] +} + +// Resource: depscriptstorage +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('depscriptstorage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: true + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'depscriptstorage' + } +} + +output blobEndpoint string = depscriptstorage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = depscriptstorage.properties.primaryEndpoints.dfs + +output queueEndpoint string = depscriptstorage.properties.primaryEndpoints.queue + +output tableEndpoint string = depscriptstorage.properties.primaryEndpoints.table + +output name string = depscriptstorage.name + +output id string = depscriptstorage.id + +// Resource: env +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain + +// Resource: env-acr +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer + +// Resource: myvnet +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource pesubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'pesubnet' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: myvnet +} + +resource sql_aci_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'sql-aci-subnet' + properties: { + addressPrefix: '10.0.255.248/29' + delegations: [ + { + properties: { + serviceName: 'Microsoft.ContainerInstance/containerGroups' + } + name: 'Microsoft.ContainerInstance/containerGroups' + } + ] + networkSecurityGroup: { + id: sql_nsg_outputs_id + } + } + parent: myvnet + dependsOn: [ + pesubnet + ] +} + +output pesubnet_Id string = pesubnet.id + +output sql_aci_subnet_Id string = sql_aci_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name + +// Resource: pesubnet-files-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_file_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param depscriptstorage_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_file_core_windows_net_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_files_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: depscriptstorage_outputs_id + groupIds: [ + 'file' + ] + } + name: 'pesubnet-files-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-files-pe' + } +} + +resource pesubnet_files_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_file_core_windows_net' + properties: { + privateDnsZoneId: privatelink_file_core_windows_net.id + } + } + ] + } + parent: pesubnet_files_pe +} + +output id string = pesubnet_files_pe.id + +output name string = pesubnet_files_pe.name + +// Resource: pesubnet-sql-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'pesubnet-sql-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-sql-pe' + } +} + +resource pesubnet_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: pesubnet_sql_pe +} + +output id string = pesubnet_sql_pe.id + +output name string = pesubnet_sql_pe.name + +// Resource: privatelink-database-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-myvnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' + +// Resource: privatelink-file-core-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.file.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net-myvnet-link' + } + parent: privatelink_file_core_windows_net +} + +output id string = privatelink_file_core_windows_net.id + +output name string = 'privatelink.file.core.windows.net' + +// Resource: sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'db' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login + +// Resource: sql-admin-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_sqlserveradminname string + +resource sql_admin_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +output id string = sql_admin_identity.id + +output clientId string = sql_admin_identity.properties.clientId + +output principalId string = sql_admin_identity.properties.principalId + +output principalName string = sql_admin_identity.name + +output name string = sql_admin_identity.name + +// Resource: sql-admin-identity-roles-depscriptstorage +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param depscriptstorage_outputs_name string + +param principalId string + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: depscriptstorage_outputs_name +} + +resource depscriptstorage_StorageFileDataPrivilegedContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(depscriptstorage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd') + principalType: 'ServicePrincipal' + } + scope: depscriptstorage +} + +// Resource: sql-nsg +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('sql_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'sql-nsg' + } +} + +resource sql_nsg_allow_outbound_443_AzureActiveDirectory 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-AzureActiveDirectory' + properties: { + access: 'Allow' + destinationAddressPrefix: 'AzureActiveDirectory' + destinationPortRange: '443' + direction: 'Outbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +resource sql_nsg_allow_outbound_443_Sql 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-Sql' + properties: { + access: 'Allow' + destinationAddressPrefix: 'Sql' + destinationPortRange: '443' + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +output id string = sql_nsg.id + +output name string = sql_nsg.name + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ExplicitSubnet_AutoCreatesStorage.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ExplicitSubnet_AutoCreatesStorage.verified.bicep new file mode 100644 index 00000000000..37026f1fdf2 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_ExplicitSubnet_AutoCreatesStorage.verified.bicep @@ -0,0 +1,585 @@ +// Resource: api-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name + +// Resource: api-roles-sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_name string + +param sql_outputs_sqlserveradminname string + +param myvnet_outputs_acisubnet_id string + +param sql_store_outputs_name string + +param principalId string + +param principalName string + +param pesubnet_sql_pe_outputs_name string + +param pesubnet_files_pe_outputs_name string + +resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { + name: sql_outputs_name +} + +resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: principalName +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_sql_pe_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_files_pe_outputs_name +} + +resource script_sql_db 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: take('script-${uniqueString('sql', principalName, 'db', resourceGroup().id)}', 24) + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerAdmin.id}': { } + } + } + kind: 'AzurePowerShell' + properties: { + azPowerShellVersion: '14.0' + retentionInterval: 'PT1H' + containerSettings: { + subnetIds: [ + { + id: myvnet_outputs_acisubnet_id + } + ] + } + environmentVariables: [ + { + name: 'DBNAME' + value: 'db' + } + { + name: 'DBSERVER' + value: sql.properties.fullyQualifiedDomainName + } + { + name: 'PRINCIPALTYPE' + value: 'ServicePrincipal' + } + { + name: 'PRINCIPALNAME' + value: principalName + } + { + name: 'ID' + value: mi.properties.clientId + } + ] + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\n\$maxRetries = 5\n\$retryDelay = 60\n\$attempt = 0\n\$success = \$false\n\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\n \$attempt++\n Write-Host "Attempt \$attempt of \$maxRetries..."\n try {\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\n \$success = \$true\n Write-Host "SQL command succeeded on attempt \$attempt."\n } catch {\n Write-Host "Attempt \$attempt failed: \$_"\n if (\$attempt -lt \$maxRetries) {\n Write-Host "Retrying in \$retryDelay seconds..."\n Start-Sleep -Seconds \$retryDelay\n } else {\n throw\n }\n }\n}' + storageAccountSettings: { + storageAccountName: sql_store_outputs_name + } + } + dependsOn: [ + pesubnet_sql_pe + pesubnet_files_pe + ] +} + +// Resource: env +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain + +// Resource: env-acr +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer + +// Resource: myvnet +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource pesubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'pesubnet' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: myvnet +} + +resource acisubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'acisubnet' + properties: { + addressPrefix: '10.0.2.0/29' + delegations: [ + { + properties: { + serviceName: 'Microsoft.ContainerInstance/containerGroups' + } + name: 'Microsoft.ContainerInstance/containerGroups' + } + ] + } + parent: myvnet + dependsOn: [ + pesubnet + ] +} + +output pesubnet_Id string = pesubnet.id + +output acisubnet_Id string = acisubnet.id + +output id string = myvnet.id + +output name string = myvnet.name + +// Resource: pesubnet-files-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_file_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_store_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_file_core_windows_net_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_files_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_store_outputs_id + groupIds: [ + 'file' + ] + } + name: 'pesubnet-files-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-files-pe' + } +} + +resource pesubnet_files_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_file_core_windows_net' + properties: { + privateDnsZoneId: privatelink_file_core_windows_net.id + } + } + ] + } + parent: pesubnet_files_pe +} + +output id string = pesubnet_files_pe.id + +output name string = pesubnet_files_pe.name + +// Resource: pesubnet-sql-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'pesubnet-sql-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-sql-pe' + } +} + +resource pesubnet_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: pesubnet_sql_pe +} + +output id string = pesubnet_sql_pe.id + +output name string = pesubnet_sql_pe.name + +// Resource: privatelink-database-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-myvnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' + +// Resource: privatelink-file-core-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.file.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net-myvnet-link' + } + parent: privatelink_file_core_windows_net +} + +output id string = privatelink_file_core_windows_net.id + +output name string = 'privatelink.file.core.windows.net' + +// Resource: sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'db' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login + +// Resource: sql-admin-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_sqlserveradminname string + +resource sql_admin_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +output id string = sql_admin_identity.id + +output clientId string = sql_admin_identity.properties.clientId + +output principalId string = sql_admin_identity.properties.principalId + +output principalName string = sql_admin_identity.name + +output name string = sql_admin_identity.name + +// Resource: sql-admin-identity-roles-sql-store +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_store_outputs_name string + +param principalId string + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource sql_store_StorageFileDataPrivilegedContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(sql_store.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd') + principalType: 'ServicePrincipal' + } + scope: sql_store +} + +// Resource: sql-store +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('sqlstore${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: true + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'sql-store' + } +} + +output blobEndpoint string = sql_store.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = sql_store.properties.primaryEndpoints.dfs + +output queueEndpoint string = sql_store.properties.primaryEndpoints.queue + +output tableEndpoint string = sql_store.properties.primaryEndpoints.table + +output name string = sql_store.name + +output id string = sql_store.id + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_StorageBeforePrivateEndpoint.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_StorageBeforePrivateEndpoint.verified.bicep new file mode 100644 index 00000000000..6cad2dbdba5 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_StorageBeforePrivateEndpoint.verified.bicep @@ -0,0 +1,636 @@ +// Resource: api-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name + +// Resource: api-roles-sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_name string + +param sql_outputs_sqlserveradminname string + +param myvnet_outputs_sql_aci_subnet_id string + +param depscriptstorage_outputs_name string + +param principalId string + +param principalName string + +param pesubnet_sql_pe_outputs_name string + +param pesubnet_files_pe_outputs_name string + +resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { + name: sql_outputs_name +} + +resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: depscriptstorage_outputs_name +} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: principalName +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_sql_pe_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_files_pe_outputs_name +} + +resource script_sql_db 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: take('script-${uniqueString('sql', principalName, 'db', resourceGroup().id)}', 24) + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerAdmin.id}': { } + } + } + kind: 'AzurePowerShell' + properties: { + azPowerShellVersion: '14.0' + retentionInterval: 'PT1H' + containerSettings: { + subnetIds: [ + { + id: myvnet_outputs_sql_aci_subnet_id + } + ] + } + environmentVariables: [ + { + name: 'DBNAME' + value: 'db' + } + { + name: 'DBSERVER' + value: sql.properties.fullyQualifiedDomainName + } + { + name: 'PRINCIPALTYPE' + value: 'ServicePrincipal' + } + { + name: 'PRINCIPALNAME' + value: principalName + } + { + name: 'ID' + value: mi.properties.clientId + } + ] + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\n\$maxRetries = 5\n\$retryDelay = 60\n\$attempt = 0\n\$success = \$false\n\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\n \$attempt++\n Write-Host "Attempt \$attempt of \$maxRetries..."\n try {\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\n \$success = \$true\n Write-Host "SQL command succeeded on attempt \$attempt."\n } catch {\n Write-Host "Attempt \$attempt failed: \$_"\n if (\$attempt -lt \$maxRetries) {\n Write-Host "Retrying in \$retryDelay seconds..."\n Start-Sleep -Seconds \$retryDelay\n } else {\n throw\n }\n }\n}' + storageAccountSettings: { + storageAccountName: depscriptstorage_outputs_name + } + } + dependsOn: [ + pesubnet_sql_pe + pesubnet_files_pe + ] +} + +// Resource: depscriptstorage +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('depscriptstorage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: true + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'depscriptstorage' + } +} + +output blobEndpoint string = depscriptstorage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = depscriptstorage.properties.primaryEndpoints.dfs + +output queueEndpoint string = depscriptstorage.properties.primaryEndpoints.queue + +output tableEndpoint string = depscriptstorage.properties.primaryEndpoints.table + +output name string = depscriptstorage.name + +output id string = depscriptstorage.id + +// Resource: env +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain + +// Resource: env-acr +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer + +// Resource: myvnet +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource pesubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'pesubnet' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: myvnet +} + +resource sql_aci_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'sql-aci-subnet' + properties: { + addressPrefix: '10.0.255.248/29' + delegations: [ + { + properties: { + serviceName: 'Microsoft.ContainerInstance/containerGroups' + } + name: 'Microsoft.ContainerInstance/containerGroups' + } + ] + networkSecurityGroup: { + id: sql_nsg_outputs_id + } + } + parent: myvnet + dependsOn: [ + pesubnet + ] +} + +output pesubnet_Id string = pesubnet.id + +output sql_aci_subnet_Id string = sql_aci_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name + +// Resource: pesubnet-files-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_file_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param depscriptstorage_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_file_core_windows_net_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_files_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: depscriptstorage_outputs_id + groupIds: [ + 'file' + ] + } + name: 'pesubnet-files-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-files-pe' + } +} + +resource pesubnet_files_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_file_core_windows_net' + properties: { + privateDnsZoneId: privatelink_file_core_windows_net.id + } + } + ] + } + parent: pesubnet_files_pe +} + +output id string = pesubnet_files_pe.id + +output name string = pesubnet_files_pe.name + +// Resource: pesubnet-sql-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'pesubnet-sql-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-sql-pe' + } +} + +resource pesubnet_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: pesubnet_sql_pe +} + +output id string = pesubnet_sql_pe.id + +output name string = pesubnet_sql_pe.name + +// Resource: privatelink-database-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-myvnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' + +// Resource: privatelink-file-core-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.file.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net-myvnet-link' + } + parent: privatelink_file_core_windows_net +} + +output id string = privatelink_file_core_windows_net.id + +output name string = 'privatelink.file.core.windows.net' + +// Resource: sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'db' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login + +// Resource: sql-admin-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_sqlserveradminname string + +resource sql_admin_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +output id string = sql_admin_identity.id + +output clientId string = sql_admin_identity.properties.clientId + +output principalId string = sql_admin_identity.properties.principalId + +output principalName string = sql_admin_identity.name + +output name string = sql_admin_identity.name + +// Resource: sql-admin-identity-roles-depscriptstorage +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param depscriptstorage_outputs_name string + +param principalId string + +resource depscriptstorage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: depscriptstorage_outputs_name +} + +resource depscriptstorage_StorageFileDataPrivilegedContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(depscriptstorage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd') + principalType: 'ServicePrincipal' + } + scope: depscriptstorage +} + +// Resource: sql-nsg +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('sql_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'sql-nsg' + } +} + +resource sql_nsg_allow_outbound_443_AzureActiveDirectory 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-AzureActiveDirectory' + properties: { + access: 'Allow' + destinationAddressPrefix: 'AzureActiveDirectory' + destinationPortRange: '443' + direction: 'Outbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +resource sql_nsg_allow_outbound_443_Sql 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-outbound-443-Sql' + properties: { + access: 'Allow' + destinationAddressPrefix: 'Sql' + destinationPortRange: '443' + direction: 'Outbound' + priority: 200 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: sql_nsg +} + +output id string = sql_nsg.id + +output name string = sql_nsg.name + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_SubnetBeforePrivateEndpoint.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_SubnetBeforePrivateEndpoint.verified.bicep new file mode 100644 index 00000000000..37026f1fdf2 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlDeploymentScriptTests.SqlWithPrivateEndpoint_SubnetBeforePrivateEndpoint.verified.bicep @@ -0,0 +1,585 @@ +// Resource: api-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name + +// Resource: api-roles-sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_name string + +param sql_outputs_sqlserveradminname string + +param myvnet_outputs_acisubnet_id string + +param sql_store_outputs_name string + +param principalId string + +param principalName string + +param pesubnet_sql_pe_outputs_name string + +param pesubnet_files_pe_outputs_name string + +resource sql 'Microsoft.Sql/servers@2023-08-01' existing = { + name: sql_outputs_name +} + +resource sqlServerAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: principalName +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_sql_pe_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' existing = { + name: pesubnet_files_pe_outputs_name +} + +resource script_sql_db 'Microsoft.Resources/deploymentScripts@2023-08-01' = { + name: take('script-${uniqueString('sql', principalName, 'db', resourceGroup().id)}', 24) + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${sqlServerAdmin.id}': { } + } + } + kind: 'AzurePowerShell' + properties: { + azPowerShellVersion: '14.0' + retentionInterval: 'PT1H' + containerSettings: { + subnetIds: [ + { + id: myvnet_outputs_acisubnet_id + } + ] + } + environmentVariables: [ + { + name: 'DBNAME' + value: 'db' + } + { + name: 'DBSERVER' + value: sql.properties.fullyQualifiedDomainName + } + { + name: 'PRINCIPALTYPE' + value: 'ServicePrincipal' + } + { + name: 'PRINCIPALNAME' + value: principalName + } + { + name: 'ID' + value: mi.properties.clientId + } + ] + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\n\$maxRetries = 5\n\$retryDelay = 60\n\$attempt = 0\n\$success = \$false\n\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\n \$attempt++\n Write-Host "Attempt \$attempt of \$maxRetries..."\n try {\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\n \$success = \$true\n Write-Host "SQL command succeeded on attempt \$attempt."\n } catch {\n Write-Host "Attempt \$attempt failed: \$_"\n if (\$attempt -lt \$maxRetries) {\n Write-Host "Retrying in \$retryDelay seconds..."\n Start-Sleep -Seconds \$retryDelay\n } else {\n throw\n }\n }\n}' + storageAccountSettings: { + storageAccountName: sql_store_outputs_name + } + } + dependsOn: [ + pesubnet_sql_pe + pesubnet_files_pe + ] +} + +// Resource: env +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain + +// Resource: env-acr +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer + +// Resource: myvnet +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource pesubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'pesubnet' + properties: { + addressPrefix: '10.0.1.0/24' + } + parent: myvnet +} + +resource acisubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'acisubnet' + properties: { + addressPrefix: '10.0.2.0/29' + delegations: [ + { + properties: { + serviceName: 'Microsoft.ContainerInstance/containerGroups' + } + name: 'Microsoft.ContainerInstance/containerGroups' + } + ] + } + parent: myvnet + dependsOn: [ + pesubnet + ] +} + +output pesubnet_Id string = pesubnet.id + +output acisubnet_Id string = acisubnet.id + +output id string = myvnet.id + +output name string = myvnet.name + +// Resource: pesubnet-files-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_file_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_store_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_file_core_windows_net_outputs_name +} + +resource pesubnet_files_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_files_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_store_outputs_id + groupIds: [ + 'file' + ] + } + name: 'pesubnet-files-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-files-pe' + } +} + +resource pesubnet_files_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_file_core_windows_net' + properties: { + privateDnsZoneId: privatelink_file_core_windows_net.id + } + } + ] + } + parent: pesubnet_files_pe +} + +output id string = pesubnet_files_pe.id + +output name string = pesubnet_files_pe.name + +// Resource: pesubnet-sql-pe +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_database_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param sql_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_database_windows_net_outputs_name +} + +resource pesubnet_sql_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_sql_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: sql_outputs_id + groupIds: [ + 'sqlServer' + ] + } + name: 'pesubnet-sql-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-sql-pe' + } +} + +resource pesubnet_sql_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_database_windows_net' + properties: { + privateDnsZoneId: privatelink_database_windows_net.id + } + } + ] + } + parent: pesubnet_sql_pe +} + +output id string = pesubnet_sql_pe.id + +output name string = pesubnet_sql_pe.name + +// Resource: privatelink-database-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_database_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-database-windows-net-myvnet-link' + } + parent: privatelink_database_windows_net +} + +output id string = privatelink_database_windows_net.id + +output name string = 'privatelink.database.windows.net' + +// Resource: privatelink-file-core-windows-net +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_file_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.file.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-file-core-windows-net-myvnet-link' + } + parent: privatelink_file_core_windows_net +} + +output id string = privatelink_file_core_windows_net.id + +output name string = 'privatelink.file.core.windows.net' + +// Resource: sql +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +resource db 'Microsoft.Sql/servers/databases@2023-08-01' = { + name: 'db' + location: location + properties: { + freeLimitExhaustionBehavior: 'AutoPause' + useFreeLimit: true + } + sku: { + name: 'GP_S_Gen5_2' + } + parent: sql +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login + +// Resource: sql-admin-identity +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_outputs_sqlserveradminname string + +resource sql_admin_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: sql_outputs_sqlserveradminname +} + +output id string = sql_admin_identity.id + +output clientId string = sql_admin_identity.properties.clientId + +output principalId string = sql_admin_identity.properties.principalId + +output principalName string = sql_admin_identity.name + +output name string = sql_admin_identity.name + +// Resource: sql-admin-identity-roles-sql-store +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sql_store_outputs_name string + +param principalId string + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: sql_store_outputs_name +} + +resource sql_store_StorageFileDataPrivilegedContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(sql_store.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '69566ab7-960f-475b-8e7c-b3118f30c6bd') + principalType: 'ServicePrincipal' + } + scope: sql_store +} + +// Resource: sql-store +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sql_store 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('sqlstore${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: true + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'sql-store' + } +} + +output blobEndpoint string = sql_store.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = sql_store.properties.primaryEndpoints.dfs + +output queueEndpoint string = sql_store.properties.primaryEndpoints.queue + +output tableEndpoint string = sql_store.properties.primaryEndpoints.table + +output name string = sql_store.name + +output id string = sql_store.id + diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/RoleAssignmentTests.SqlSupport.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/RoleAssignmentTests.SqlSupport.verified.bicep index 52db007f830..1555f03fe38 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/RoleAssignmentTests.SqlSupport.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/RoleAssignmentTests.SqlSupport.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param sql_outputs_name string @@ -56,6 +56,6 @@ resource script_sql_db 'Microsoft.Resources/deploymentScripts@2023-08-01' = { value: mi.properties.clientId } ] - scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\nInvoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd' + scriptContent: '\$sqlServerFqdn = "\$env:DBSERVER"\n\$sqlDatabaseName = "\$env:DBNAME"\n\$principalName = "\$env:PRINCIPALNAME"\n\$id = "\$env:ID"\n\n# Install SqlServer module - using specific version to avoid breaking changes in 22.4.5.1 (see https://github.com/dotnet/aspire/issues/9926)\nInstall-Module -Name SqlServer -RequiredVersion 22.3.0 -Force -AllowClobber -Scope CurrentUser\nImport-Module SqlServer\n\n\$sqlCmd = @"\nDECLARE @name SYSNAME = \'\$principalName\';\nDECLARE @id UNIQUEIDENTIFIER = \'\$id\';\n\n-- Convert the guid to the right type\nDECLARE @castId NVARCHAR(MAX) = CONVERT(VARCHAR(MAX), CONVERT (VARBINARY(16), @id), 1);\n\n-- Construct command: CREATE USER [@name] WITH SID = @castId, TYPE = E;\nDECLARE @cmd NVARCHAR(MAX) = N\'CREATE USER [\' + @name + \'] WITH SID = \' + @castId + \', TYPE = E;\'\nEXEC (@cmd);\n\n-- Assign roles to the new user\nDECLARE @role1 NVARCHAR(MAX) = N\'ALTER ROLE db_owner ADD MEMBER [\' + @name + \']\';\nEXEC (@role1);\n\n"@\n# Note: the string terminator must not have whitespace before it, therefore it is not indented.\n\nWrite-Host \$sqlCmd\n\n\$connectionString = "Server=tcp:\${sqlServerFqdn},1433;Initial Catalog=\${sqlDatabaseName};Authentication=Active Directory Default;"\n\n\$maxRetries = 5\n\$retryDelay = 60\n\$attempt = 0\n\$success = \$false\n\nwhile (-not \$success -and \$attempt -lt \$maxRetries) {\n \$attempt++\n Write-Host "Attempt \$attempt of \$maxRetries..."\n try {\n Invoke-Sqlcmd -ConnectionString \$connectionString -Query \$sqlCmd\n \$success = \$true\n Write-Host "SQL command succeeded on attempt \$attempt."\n } catch {\n Write-Host "Attempt \$attempt failed: \$_"\n if (\$attempt -lt \$maxRetries) {\n Write-Host "Retrying in \$retryDelay seconds..."\n Start-Sleep -Seconds \$retryDelay\n } else {\n throw\n }\n }\n}' } -} +} \ No newline at end of file