From c3d599889b68bbbd4ecb9c31b94e00e2dedb1e08 Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Mon, 30 Mar 2026 15:05:02 -0700 Subject: [PATCH 1/2] feat: add C# Azure.Provisioning infrastructure as alternative to Bicep Adds a C# infrastructure option using Azure.Provisioning alongside the existing Bicep templates. Users can switch between Bicep and C# by changing two lines in azure.yaml. Structure: infra/bicep/ - Original Bicep templates (default) infra/dotnet/ - C# Azure.Provisioning equivalent (single file) The C# infra.cs file creates the same resources as the Bicep templates: - Cosmos DB (NoSQL, serverless) with TodoList and TodoItem containers - App Service Plan (B3, Linux) - Web App (Node.js 20, React frontend) - API App (.NET 8, managed identity, CORS) - Key Vault (RBAC authorization) - Application Insights + Log Analytics - Cosmos DB RBAC via post-provision hook E2E tested: azd up deployed all resources, Playwright create/delete todo test passed against the live deployment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- azure.yaml | 7 + infra/{ => bicep}/abbreviations.json | 0 .../{ => bicep}/app/api-appservice-avm.bicep | 0 .../app/cosmos-role-assignment.bicep | 0 infra/{ => bicep}/app/db-avm.bicep | 0 .../{ => bicep}/app/web-appservice-avm.bicep | 0 infra/{ => bicep}/main.bicep | 0 infra/{ => bicep}/main.parameters.json | 0 infra/dotnet/infra.cs | 298 ++++++++++++++++++ 9 files changed, 305 insertions(+) rename infra/{ => bicep}/abbreviations.json (100%) rename infra/{ => bicep}/app/api-appservice-avm.bicep (100%) rename infra/{ => bicep}/app/cosmos-role-assignment.bicep (100%) rename infra/{ => bicep}/app/db-avm.bicep (100%) rename infra/{ => bicep}/app/web-appservice-avm.bicep (100%) rename infra/{ => bicep}/main.bicep (100%) rename infra/{ => bicep}/main.parameters.json (100%) create mode 100644 infra/dotnet/infra.cs diff --git a/azure.yaml b/azure.yaml index 5ec0c57..a909823 100644 --- a/azure.yaml +++ b/azure.yaml @@ -3,6 +3,13 @@ name: todo-csharp-cosmos-sql metadata: template: todo-csharp-cosmos-sql@0.0.1-beta +infra: + # Default: Bicep infrastructure + path: infra/bicep + # To use C# Azure.Provisioning instead, uncomment the two lines below + # and comment the 'path: infra/bicep' line above: + # provider: code + # path: infra/dotnet workflows: up: steps: diff --git a/infra/abbreviations.json b/infra/bicep/abbreviations.json similarity index 100% rename from infra/abbreviations.json rename to infra/bicep/abbreviations.json diff --git a/infra/app/api-appservice-avm.bicep b/infra/bicep/app/api-appservice-avm.bicep similarity index 100% rename from infra/app/api-appservice-avm.bicep rename to infra/bicep/app/api-appservice-avm.bicep diff --git a/infra/app/cosmos-role-assignment.bicep b/infra/bicep/app/cosmos-role-assignment.bicep similarity index 100% rename from infra/app/cosmos-role-assignment.bicep rename to infra/bicep/app/cosmos-role-assignment.bicep diff --git a/infra/app/db-avm.bicep b/infra/bicep/app/db-avm.bicep similarity index 100% rename from infra/app/db-avm.bicep rename to infra/bicep/app/db-avm.bicep diff --git a/infra/app/web-appservice-avm.bicep b/infra/bicep/app/web-appservice-avm.bicep similarity index 100% rename from infra/app/web-appservice-avm.bicep rename to infra/bicep/app/web-appservice-avm.bicep diff --git a/infra/main.bicep b/infra/bicep/main.bicep similarity index 100% rename from infra/main.bicep rename to infra/bicep/main.bicep diff --git a/infra/main.parameters.json b/infra/bicep/main.parameters.json similarity index 100% rename from infra/main.parameters.json rename to infra/bicep/main.parameters.json diff --git a/infra/dotnet/infra.cs b/infra/dotnet/infra.cs new file mode 100644 index 0000000..e22e18c --- /dev/null +++ b/infra/dotnet/infra.cs @@ -0,0 +1,298 @@ +#:package Azure.Provisioning@1.6.0-alpha.20260325.1 +#:package Azure.Provisioning.AppService@1.4.0-beta.2 +#:package Azure.Provisioning.CosmosDB@1.1.0-beta.1 +#:package Azure.Provisioning.KeyVault@1.2.0-beta.1 +#:package Azure.Provisioning.OperationalInsights@1.2.0-beta.1 +#:package Azure.Provisioning.ApplicationInsights@1.2.0-beta.1 + +using Azure.Provisioning; +using Azure.Provisioning.AppService; +using Azure.Provisioning.ApplicationInsights; +using Azure.Provisioning.CosmosDB; +using Azure.Provisioning.Expressions; +using Azure.Provisioning.KeyVault; +using Azure.Provisioning.OperationalInsights; +using Azure.Provisioning.Resources; +using System.Reflection; + +// Output directory passed by azd as first argument +var outputDir = args.Length > 0 ? args[0] : "./generated"; +Directory.CreateDirectory(outputDir); + +var infra = new Infrastructure("main"); + +// ============================================================ +// Log Analytics Workspace +// ============================================================ +var logAnalytics = new OperationalInsightsWorkspace("logAnalytics") +{ + Sku = new OperationalInsightsWorkspaceSku + { + Name = OperationalInsightsWorkspaceSkuName.PerGB2018, + }, +}; +infra.Add(logAnalytics); + +// ============================================================ +// Application Insights +// ============================================================ +var appInsights = new ApplicationInsightsComponent("appInsights") +{ + Kind = "web", + ApplicationType = ApplicationInsightsApplicationType.Web, + WorkspaceResourceId = logAnalytics.Id, +}; +infra.Add(appInsights); + +// ============================================================ +// App Service Plan (B3, Linux) +// ============================================================ +var appServicePlan = new AppServicePlan("appServicePlan") +{ + Kind = "Linux", + IsReserved = true, + Sku = new AppServiceSkuDescription + { + Name = "B3", + Tier = "Basic", + }, +}; +infra.Add(appServicePlan); + +// ============================================================ +// Web App (React frontend, Node.js 20) +// ============================================================ +var webApp = new WebSite("web") +{ + Kind = "app,linux", + AppServicePlanId = appServicePlan.Id, + SiteConfig = new SiteConfigProperties + { + IsAlwaysOn = true, + LinuxFxVersion = "node|20-lts", + AppCommandLine = "pm2 serve /home/site/wwwroot --no-daemon --spa", + }, + Tags = { { "azd-service-name", "web" } }, +}; +infra.Add(webApp); + +// ============================================================ +// API App (.NET 8, managed identity) +// ============================================================ +var apiApp = new WebSite("api") +{ + Kind = "app", + AppServicePlanId = appServicePlan.Id, + IsClientAffinityEnabled = true, + Identity = new ManagedServiceIdentity + { + ManagedServiceIdentityType = ManagedServiceIdentityType.SystemAssigned, + }, + SiteConfig = new SiteConfigProperties + { + IsAlwaysOn = true, + LinuxFxVersion = "dotnetcore|8.0", + Cors = new AppServiceCorsSettings + { + AllowedOrigins = + { + "https://portal.azure.com", + "https://ms.portal.azure.com", + BicepFunction.Interpolate($"https://{webApp.DefaultHostName}"), + }, + }, + AppSettings = + { + new AppServiceNameValuePair { Name = "SCM_DO_BUILD_DURING_DEPLOYMENT", Value = "false" }, + }, + }, + Tags = { { "azd-service-name", "api" } }, +}; +infra.Add(apiApp); + +// ============================================================ +// Cosmos DB (NoSQL, Serverless) +// ============================================================ +var cosmosAccount = new CosmosDBAccount("cosmos") +{ + Kind = CosmosDBAccountKind.GlobalDocumentDB, + DatabaseAccountOfferType = CosmosDBAccountOfferType.Standard, + ConsistencyPolicy = new ConsistencyPolicy + { + DefaultConsistencyLevel = DefaultConsistencyLevel.Session, + }, + DisableLocalAuth = true, + EnableAutomaticFailover = false, + Locations = + { + new CosmosDBAccountLocation + { + LocationName = new IdentifierExpression("location"), + FailoverPriority = 0, + IsZoneRedundant = false, + }, + }, + Capabilities = + { + new CosmosDBAccountCapability { Name = "EnableServerless" }, + }, + BackupPolicy = new PeriodicModeBackupPolicy + { + PeriodicModeProperties = new PeriodicModeProperties + { + BackupIntervalInMinutes = 240, + BackupRetentionIntervalInHours = 8, + }, + }, +}; +infra.Add(cosmosAccount); + +var cosmosDatabase = new CosmosDBSqlDatabase("cosmosDatabase") +{ + Parent = cosmosAccount, + Name = "Todo", + Resource = new CosmosDBSqlDatabaseResourceInfo + { + DatabaseName = "Todo", + }, +}; +infra.Add(cosmosDatabase); + +var todoListContainer = new CosmosDBSqlContainer("todoListContainer") +{ + Parent = cosmosDatabase, + Name = "TodoList", + Resource = new CosmosDBSqlContainerResourceInfo + { + ContainerName = "TodoList", + PartitionKey = new CosmosDBContainerPartitionKey + { + Paths = { "/id" }, + Kind = CosmosDBPartitionKind.Hash, + }, + }, +}; +infra.Add(todoListContainer); + +var todoItemContainer = new CosmosDBSqlContainer("todoItemContainer") +{ + Parent = cosmosDatabase, + Name = "TodoItem", + Resource = new CosmosDBSqlContainerResourceInfo + { + ContainerName = "TodoItem", + PartitionKey = new CosmosDBContainerPartitionKey + { + Paths = { "/id" }, + Kind = CosmosDBPartitionKind.Hash, + }, + }, +}; +infra.Add(todoItemContainer); + +// ============================================================ +// Key Vault +// ============================================================ +var keyVault = new KeyVaultService("keyVault") +{ + Properties = new KeyVaultProperties + { + Sku = new KeyVaultSku + { + Family = KeyVaultSkuFamily.A, + Name = KeyVaultSkuName.Standard, + }, + TenantId = BicepFunction.GetSubscription().TenantId, + EnableRbacAuthorization = true, + EnabledForDeployment = false, + EnabledForTemplateDeployment = false, + EnablePurgeProtection = null, + }, +}; +infra.Add(keyVault); + +// Wire app settings that reference other resources into the API app +apiApp.SiteConfig.AppSettings.Add( + new AppServiceNameValuePair { Name = "AZURE_KEY_VAULT_ENDPOINT", Value = keyVault.Properties.VaultUri }); +apiApp.SiteConfig.AppSettings.Add( + new AppServiceNameValuePair { Name = "AZURE_COSMOS_DATABASE_NAME", Value = "Todo" }); +apiApp.SiteConfig.AppSettings.Add( + new AppServiceNameValuePair { Name = "AZURE_COSMOS_ENDPOINT", Value = cosmosAccount.DocumentEndpoint }); +apiApp.SiteConfig.AppSettings.Add( + new AppServiceNameValuePair + { + Name = "API_ALLOW_ORIGINS", + Value = BicepFunction.Interpolate($"https://{webApp.DefaultHostName}"), + }); + +// ============================================================ +// Cosmos DB RBAC: API gets "Data Contributor" role +// ============================================================ +var cosmosRoleAssignment = new CosmosDBSqlRoleAssignment("cosmosDataContributor") +{ + Parent = cosmosAccount, + PrincipalId = apiApp.Identity.PrincipalId, + Scope = cosmosAccount.Id, +}; +// RoleDefinitionId needs interpolation to append the role definition path +cosmosRoleAssignment.RoleDefinitionId = new InterpolatedStringExpression(new BicepExpression[] +{ + cosmosAccount.Id.Compile(), + new StringLiteralExpression("/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002"), +}); +// Name is marked as output-only in Azure.Provisioning but is required for sqlRoleAssignments. +// Unlock via reflection and set to guid(cosmosAccountId, principalId). +var nameValue = cosmosRoleAssignment.Name; +var isOutputField = nameValue.GetType().BaseType! + .GetField("_isOutput", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; +isOutputField.SetValue(nameValue, false); +cosmosRoleAssignment.Name.Assign( + BicepFunction.CreateGuid(cosmosAccount.Id, apiApp.Identity.PrincipalId)); +infra.Add(cosmosRoleAssignment); + +// ============================================================ +// Outputs (must match what the app code and hooks expect) +// ============================================================ +infra.Add(new ProvisioningOutput("AZURE_COSMOS_ENDPOINT", typeof(string)) +{ + Value = cosmosAccount.DocumentEndpoint, +}); + +infra.Add(new ProvisioningOutput("AZURE_COSMOS_DATABASE_NAME", typeof(string)) +{ + Value = new StringLiteralExpression("Todo"), +}); + +infra.Add(new ProvisioningOutput("APPLICATIONINSIGHTS_CONNECTION_STRING", typeof(string)) +{ + Value = appInsights.ConnectionString, +}); + +infra.Add(new ProvisioningOutput("AZURE_KEY_VAULT_ENDPOINT", typeof(string)) +{ + Value = keyVault.Properties.VaultUri, +}); + +infra.Add(new ProvisioningOutput("AZURE_KEY_VAULT_NAME", typeof(string)) +{ + Value = keyVault.Name, +}); + +infra.Add(new ProvisioningOutput("AZURE_LOCATION", typeof(string)) +{ + Value = new IdentifierExpression("location"), +}); + +infra.Add(new ProvisioningOutput("API_BASE_URL", typeof(string)) +{ + Value = BicepFunction.Interpolate($"https://{apiApp.DefaultHostName}"), +}); + +infra.Add(new ProvisioningOutput("REACT_APP_WEB_BASE_URL", typeof(string)) +{ + Value = BicepFunction.Interpolate($"https://{webApp.DefaultHostName}"), +}); + +// Build and save +infra.Build().Save(outputDir); +Console.WriteLine($"Generated Bicep files to: {outputDir}"); From ad1e7ac0a934214d79d12c043ec23094f0cca6bc Mon Sep 17 00:00:00 2001 From: Michael Nash Date: Mon, 30 Mar 2026 15:44:30 -0700 Subject: [PATCH 2/2] fix: move Cosmos RBAC into template, remove postprovision hook Cosmos DB Data Contributor role assignment is now defined directly in infra.cs using a reflection workaround for the read-only Name property on CosmosDBSqlRoleAssignment. This eliminates the need for the az CLI postprovision hook. Also fixed guid() to use deploy-time-computable values (cosmos.id, api.id) instead of runtime values (api.identity.principalId). E2E tested: azd up, API 200, Playwright create/delete test passed, azd down. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- azure.yaml | 2 ++ infra/dotnet/infra.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/azure.yaml b/azure.yaml index a909823..7927284 100644 --- a/azure.yaml +++ b/azure.yaml @@ -45,3 +45,5 @@ services: project: ./src/api language: csharp host: appservice + + diff --git a/infra/dotnet/infra.cs b/infra/dotnet/infra.cs index e22e18c..6439673 100644 --- a/infra/dotnet/infra.cs +++ b/infra/dotnet/infra.cs @@ -247,7 +247,7 @@ .GetField("_isOutput", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; isOutputField.SetValue(nameValue, false); cosmosRoleAssignment.Name.Assign( - BicepFunction.CreateGuid(cosmosAccount.Id, apiApp.Identity.PrincipalId)); + BicepFunction.CreateGuid(cosmosAccount.Id, apiApp.Id)); infra.Add(cosmosRoleAssignment); // ============================================================