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