diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f1dc5aa..c6d0ee6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,6 @@ "version": "latest", "installBicep": true }, - "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/azure/azure-dev/azd:latest": {} }, @@ -33,11 +32,11 @@ "customizations": { "vscode": { "extensions": [ + "GitHub.vscode-github-actions", "ms-azuretools.azure-dev", "ms-azuretools.vscode-bicep", "vscjava.vscode-java-pack", "ms-azuretools.vscode-docker" - ] } }, @@ -50,7 +49,7 @@ // Set minimal host requirements for the container. "hostRequirements": { - "memory": "8gb" + "memory": "16gb" } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. diff --git a/README.md b/README.md index e9702d4..159e836 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,31 @@ This repository contains code demonstrating how to enable OpenTelemetry features * [Spring Boot](./code/spring-boot-telemetry/README.md) * [Quarkus](./code/quarkus-telemetry/README.md) -## Run the Code +Setup the infrastructure, and deploy the sample to Azure Container Apps with [azd](https://learn.microsoft.com/azure/developer/azure-developer-cli/). +We'll make use of bicep files that you find and update [here](./infra). To provide Infrastructure as Code for Azure, Terraform or Bicep files are recommended. Learn more about Bicep [here](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/overview?tabs=bicep). -Since native apps are built for the machine it runs on, make sure you execute this on a unix machine. -Feel free to clone the repository locally when you are using Linux or MacOS. -For Windows further adjustments might be neccessary. +```bash + # Login to Azure + azd auth login --use-device-code + + # Provision the Infrastructure with the Region and ShortCut set for namings + azd provision -### We recommend to use CodeSpaces. + # Deploy the apps + azd up +``` -To ensure the machine type you're running on, fork the repository and then select `Create a codespace on main` by clicking on the `+` icon. +Create some traffic, for instance with Postman: +![Postman Traffic](./images/postman.png) -![Create CodeSpace Screenshot](./docs/assets/create-codespace.png) -The [.devcontainer file](.devcontainer/devcontainer.json) holds all dependencies needed to run the code 'locally' on a linux machine. +The Application Map in Azure Application Insights will look like this: + +![Application Map](./images/application-map.png) + +And give you traces like: + +![End to End Transaction - Trace](./images/e2e-transaction.png) + +Delete all resources with `azd down` afterwards. -The first start might take a few minutes. Every restart is consiberably fast. diff --git a/azure.yaml b/azure.yaml new file mode 100644 index 0000000..edebf1f --- /dev/null +++ b/azure.yaml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +# This is an example starter azure.yaml file containing several example services in comments below. +# Make changes as needed to describe your application setup. +# To learn more about the azure.yaml file, visit https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/azd-schema + +# Name of the application. +name: azd-starter +services: + quarkus: + language: java + project: code/quarkus-telemetry/quarkus-telemetry-superhero + host: containerapp + docker: + path: Dockerfile + spring-boot: + language: java + project: code/spring-boot-telemetry + host: containerapp + docker: + path: Dockerfile + diff --git a/code/spring-boot-telemetry/README.md b/code/spring-boot-telemetry/README.md index 9cf400b..14aef7a 100644 --- a/code/spring-boot-telemetry/README.md +++ b/code/spring-boot-telemetry/README.md @@ -2,20 +2,24 @@ ## Start the application locally with docker compose +Run `docker compose up` to start PostgreSQL locally and build the app locally within docker. + + +## Configure the Database Connection + Store the postgresql database connection details in an .env file at 'code/spring-boot-telemetry/.env'. DATABASE_PASSWORD=db_password DATABASE_URL=db_url DATABASE_USERNAME=db_admin -Run `docker compose up` to start PostgreSQL locally and build the app locally within docker. - ## Run the application in a JVM mode ```shell script mvn package cd target java -jar spring-boot-telemetry.jar + ``` ## Run the application with GraalVM native diff --git a/code/spring-boot-telemetry/src/main/java/com/azure/examples/springboot/controller/VeggieController.java b/code/spring-boot-telemetry/src/main/java/com/azure/examples/springboot/controller/VeggieController.java index 9ac9bc4..966c8ed 100644 --- a/code/spring-boot-telemetry/src/main/java/com/azure/examples/springboot/controller/VeggieController.java +++ b/code/spring-boot-telemetry/src/main/java/com/azure/examples/springboot/controller/VeggieController.java @@ -48,7 +48,7 @@ public ResponseEntity addVeggie(@RequestBody VeggieItem veggie) { } @DeleteMapping("/{id}") - public ResponseEntity deleteVeggie(@PathVariable("id") String id) { + public ResponseEntity deleteVeggie(@PathVariable("id") Long id) { veggieService.deleteVeggie(id); return ResponseEntity.ok("Veggie deleted successfully"); } @@ -60,7 +60,7 @@ public ResponseEntity> getAllVeggies() { } @GetMapping("/{id}") - public ResponseEntity getVeggieById(@PathVariable("id") String id) { + public ResponseEntity getVeggieById(@PathVariable("id") Long id) { VeggieItem veggie = veggieService.getVeggieById(id); if (veggie != null) { return ResponseEntity.ok(veggie); diff --git a/docs/assets/ai-resource-1.png b/docs/assets/ai-resource-1.png deleted file mode 100644 index de86baf..0000000 Binary files a/docs/assets/ai-resource-1.png and /dev/null differ diff --git a/docs/assets/ai-resource-2.png b/docs/assets/ai-resource-2.png deleted file mode 100644 index 2e1f138..0000000 Binary files a/docs/assets/ai-resource-2.png and /dev/null differ diff --git a/docs/assets/ai-resource-3.png b/docs/assets/ai-resource-3.png deleted file mode 100644 index 7d4216f..0000000 Binary files a/docs/assets/ai-resource-3.png and /dev/null differ diff --git a/docs/assets/banner.jpg b/docs/assets/banner.jpg deleted file mode 100644 index 9c87e96..0000000 Binary files a/docs/assets/banner.jpg and /dev/null differ diff --git a/docs/workshop.md b/docs/workshop.md deleted file mode 100644 index 7a92925..0000000 --- a/docs/workshop.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -published: false # Optional. Set to true to publish the workshop (default: false) -type: workshop # Required. -title: Full workshop title # Required. Full title of the workshop -short_title: Short title for header # Optional. Short title displayed in the header -description: This is a workshop for... # Required. -level: beginner # Required. Can be 'beginner', 'intermediate' or 'advanced' -authors: # Required. You can add as many authors as needed - - Name -contacts: # Required. Must match the number of authors - - Author's email, Twitter... -duration_minutes: 20 # Required. Estimated duration in minutes -tags: javascript, api, node.js # Required. Tags for filtering and searching -#banner_url: assets/banner.jpg # Optional. Should be a 1280x640px image -#video_url: https://youtube.com/link # Optional. Link to a video of the workshop -#audience: students # Optional. Audience of the workshop (students, pro devs, etc.) -#wt_id: # Optional. Set advocacy tracking code for supported links -#oc_id: # Optional. Set marketing tracking code for supported links -#navigation_levels: 2 # Optional. Number of levels displayed in the side menu (default: 2) -#sections_title: # Optional. Override titles for each section to be displayed in the side bar -# - Section 1 title -# - Section 2 title ---- - -# Workshop Title - -Content for first section - ---- - -## Second section - -Content for second section diff --git a/images/application-map.png b/images/application-map.png new file mode 100644 index 0000000..a23f55f Binary files /dev/null and b/images/application-map.png differ diff --git a/images/e2e-transaction.png b/images/e2e-transaction.png new file mode 100644 index 0000000..5eb4df7 Binary files /dev/null and b/images/e2e-transaction.png differ diff --git a/images/postman.png b/images/postman.png new file mode 100644 index 0000000..c3744e5 Binary files /dev/null and b/images/postman.png differ diff --git a/infra/abbreviations.json b/infra/abbreviations.json new file mode 100644 index 0000000..292beef --- /dev/null +++ b/infra/abbreviations.json @@ -0,0 +1,136 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "loadTesting": "lt-", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} \ No newline at end of file diff --git a/infra/app/quarkus.bicep b/infra/app/quarkus.bicep new file mode 100644 index 0000000..679a646 --- /dev/null +++ b/infra/app/quarkus.bicep @@ -0,0 +1,133 @@ +targetScope = 'resourceGroup' + +/******************************************************************************/ +/* PARAMETERS */ +/******************************************************************************/ + +@description('Name of the container app.') +param name string + +@description('Location in which the resources will be deployed. Default value is the resource group location.') +param location string = resourceGroup().location + +@description('Tags that will be added to all the resources. For Azure Developer CLI, "azd-env-name" should be added to the tags.') +param tags object = {} + +@description('Name of the service. This name is used to add "azd-service-name" tag to the tags for the container app. Default value is "quarkus". If you change this value, make sure to change the name of the service in "azure.yaml" file as well.') +param serviceName string + +@description('Name of the identity that will be created and used by the container app to pull image from the container registry.') +param identityName string + +@description('Name of the existing Application Insights instance that will be used by the container app.') +param applicationInsightsName string + +@description('Name of the existing container apps environment.') +param containerAppsEnvironmentName string + +@description('Name of the existing container registry that will be used by the container app.') +param containerRegistryName string + +@description('Database connection configuration information.') +param databaseConfig databaseConfigType + +@description('Name of the Key Vault that contains the secrets.') +param keyVaultName string + +@description('Flag that indicates whether the container app already exists or not. This is used in container app upsert to set the image name to the value of the existing container apps image name.') +param exists bool + +/******************************************************************************/ +/* TYPES */ +/******************************************************************************/ + +type databaseConfigType = { + hostname: string + name: string + username: string + port: int? +} + +/******************************************************************************/ +/* RESOURCES */ +/******************************************************************************/ + +resource quarkusIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} + +resource keyVault 'Microsoft.KeyVault/vaults@2021-06-01-preview' existing = { + name: keyVaultName +} + +module quarkusKeyVaultAccess '../core/security/keyvault-access.bicep' = { + name: 'keyvault-access-${quarkusIdentity.name}' + params: { + keyVaultName: keyVault.name + principalId: quarkusIdentity.properties.principalId + } +} + +module quarkus '../core/host/container-app-upsert.bicep' = { + name: '${serviceName}-container-app' + params: { + name: name + location: location + tags: union(tags, { 'azd-service-name': serviceName }) + identityType: 'UserAssigned' + identityName: identityName + exists: exists + containerAppsEnvironmentName: containerAppsEnvironmentName + containerRegistryName: containerRegistryName + env: [ + { + name: 'QUARKUS_DATASOURCE_JDBC_URL' + value: 'jdbc:postgresql://${databaseConfig.hostname}:${databaseConfig.?port ?? 5432}/${databaseConfig.name}' + } + { + name: 'QUARKUS_DATASOURCE_USERNAME' + value: databaseConfig.username + } + { + name: 'QUARKUS_DATASOURCE_PASSWORD' + secretRef: 'postgres-admin-password' + } + { + name: 'QUARKUS_OTEL_AZURE_APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.properties.ConnectionString + } + ] + secrets: [ + { + name: 'postgres-admin-password' + keyVaultUrl: '${keyVault.properties.vaultUri}secrets/postgres-admin-password' + identity: quarkusIdentity.id + } + ] + targetPort: 8081 + } + dependsOn: [ + quarkusKeyVaultAccess + ] +} + +/******************************************************************************/ +/* OUTPUTS */ +/******************************************************************************/ + +@description('ID of the service principal that is used by the container app to pull image from the container registry.') +output SERVICE_QUARKUS_IDENTITY_PRINCIPAL_ID string = quarkusIdentity.properties.principalId + +@description('Name of the container app.') +output SERVICE_QUARKUS_NAME string = quarkus.outputs.name + +@description('URI of the container app.') +output SERVICE_QUARKUS_URI string = quarkus.outputs.uri + +@description('Name of the container apps image.') +output SERVICE_QUARKUS_IMAGE_NAME string = quarkus.outputs.imageName diff --git a/infra/app/spring-boot.bicep b/infra/app/spring-boot.bicep new file mode 100644 index 0000000..518181d --- /dev/null +++ b/infra/app/spring-boot.bicep @@ -0,0 +1,140 @@ +targetScope = 'resourceGroup' + +/******************************************************************************/ +/* PARAMETERS */ +/******************************************************************************/ + +@description('Name of the container app.') +param name string + +@description('Location in which the resources will be deployed. Default value is the resource group location.') +param location string = resourceGroup().location + +@description('Tags that will be added to all the resources. For Azure Developer CLI, "azd-env-name" should be added to the tags.') +param tags object = {} + +@description('Name of the service. This name is used to add "azd-service-name" tag to the tags for the container app. Default value is "srping-boot". If you change this value, make sure to change the name of the service in "azure.yaml" file as well.') +param serviceName string + +@description('Name of the identity that will be created and used by the container app to pull image from the container registry.') +param identityName string + +@description('Name of the existing Application Insights instance that will be used by the container app.') +param applicationInsightsName string + +@description('Name of the existing container apps environment.') +param containerAppsEnvironmentName string + +@description('Name of the existing container registry that will be used by the container app.') +param containerRegistryName string + +@description('Database connection configuration information.') +param databaseConfig databaseConfigType + +@description('Name of the Key Vault that contains the secrets.') +param keyVaultName string + +@description('URL of the super hero service to call.') +param superHeroUrl string + +@description('Flag that indicates whether the container app already exists or not. This is used in container app upsert to set the image name to the value of the existing container apps image name.') +param exists bool + +/******************************************************************************/ +/* TYPES */ +/******************************************************************************/ + +type databaseConfigType = { + hostname: string + name: string + username: string + port: int? +} + +/******************************************************************************/ +/* RESOURCES */ +/******************************************************************************/ + +resource springBootIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} + +resource keyVault 'Microsoft.KeyVault/vaults@2021-06-01-preview' existing = { + name: keyVaultName +} + +module springBootKeyVaultAccess '../core/security/keyvault-access.bicep' = { + name: 'keyvault-access-${springBootIdentity.name}' + params: { + keyVaultName: keyVault.name + principalId: springBootIdentity.properties.principalId + } +} + +module springBoot '../core/host/container-app-upsert.bicep' = { + name: '${serviceName}-container-app' + params: { + name: name + location: location + tags: union(tags, { 'azd-service-name': serviceName }) + identityType: 'UserAssigned' + identityName: identityName + exists: exists + containerAppsEnvironmentName: containerAppsEnvironmentName + containerRegistryName: containerRegistryName + env: [ + { + name: 'SPRING_DATASOURCE_URL' + value: 'jdbc:postgresql://${databaseConfig.hostname}:${databaseConfig.?port ?? 5432}/${databaseConfig.name}' + } + { + name: 'SPRING_DATASOURCE_USERNAME' + value: databaseConfig.username + } + { + name: 'SPRING_DATASOURCE_PASSWORD' + secretRef: 'postgres-admin-password' + } + { + name: 'CLIENT_SUPERHERO_URL' + value: superHeroUrl + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.properties.ConnectionString + } + ] + secrets: [ + { + name: 'postgres-admin-password' + keyVaultUrl: '${keyVault.properties.vaultUri}secrets/postgres-admin-password' + identity: springBootIdentity.id + } + ] + targetPort: 8080 + } + dependsOn: [ + springBootKeyVaultAccess + ] +} + +/******************************************************************************/ +/* OUTPUTS */ +/******************************************************************************/ + +@description('ID of the service principal that is used by the container app to pull image from the container registry.') +output SERVICE_SPRING_BOOT_IDENTITY_PRINCIPAL_ID string = springBootIdentity.properties.principalId + +@description('Name of the container app.') +output SERVICE_SPRING_BOOT_NAME string = springBoot.outputs.name + +@description('URI of the container app.') +output SERVICE_SPRING_BOOT_URI string = springBoot.outputs.uri + +@description('Name of the container apps image.') +output SERVICE_SPRING_BOOT_IMAGE_NAME string = springBoot.outputs.imageName diff --git a/infra/core/database/postgresql/flexibleserver.bicep b/infra/core/database/postgresql/flexibleserver.bicep new file mode 100644 index 0000000..dc9ba97 --- /dev/null +++ b/infra/core/database/postgresql/flexibleserver.bicep @@ -0,0 +1,84 @@ + +metadata description = 'Creates an Azure Database for PostgreSQL - Flexible Server.' +param name string +param location string = resourceGroup().location +param tags object = {} + +param sku object +param storage object +param administratorLogin string +@secure() +param administratorLoginPassword string +param databaseNames array = [] +param allowAzureIPsFirewall bool = false +param allowAllIPsFirewall bool = false +param allowedSingleIPs array = [] +param azureExtensions array = [] + +// PostgreSQL version +param version string + +resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2023-03-01-preview' = { + location: location + tags: tags + name: name + sku: sku + properties: { + version: version + administratorLogin: administratorLogin + administratorLoginPassword: administratorLoginPassword + storage: storage + highAvailability: { + mode: 'Disabled' + } + + } + + resource database 'databases' = [for name in databaseNames: { + name: name + }] +} + +resource firewall_all 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = if (allowAllIPsFirewall) { + name: 'allow-all-IPs' + parent: postgresServer + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '255.255.255.255' + } +} + +resource firewall_azure 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = if (allowAzureIPsFirewall) { + name: 'allow-all-azure-internal-IPs' + parent: postgresServer + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource firewall_single 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = [for ip in allowedSingleIPs: { + name: 'allow-single-${replace(ip, '.', '')}' + parent: postgresServer + properties: { + startIpAddress: ip + endIpAddress: ip + } +}] + +// Workaround issue https://github.com/Azure/bicep-types-az/issues/1507 + +resource configurations 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2023-03-01-preview' = if (length(azureExtensions) > 0) { + name: 'azure.extensions' + parent: postgresServer + properties: { + value: join(azureExtensions, ',') + source: 'user-override' + } + dependsOn: [ + firewall_all, firewall_all, firewall_single + ] +} + + +output POSTGRES_DOMAIN_NAME string = postgresServer.properties.fullyQualifiedDomainName diff --git a/infra/core/host/container-app-upsert.bicep b/infra/core/host/container-app-upsert.bicep new file mode 100644 index 0000000..f9c75b4 --- /dev/null +++ b/infra/core/host/container-app-upsert.bicep @@ -0,0 +1,109 @@ +metadata description = 'Creates or updates an existing Azure Container App.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('The environment name for the container apps') +param containerAppsEnvironmentName string + +@description('The number of CPU cores allocated to a single container instance, e.g., 0.5') +param containerCpuCoreCount string = '0.5' + +@description('The maximum number of replicas to run. Must be at least 1.') +@minValue(1) +param containerMaxReplicas int = 10 + +@description('The amount of memory allocated to a single container instance, e.g., 1Gi') +param containerMemory string = '1.0Gi' + +@description('The minimum number of replicas to run. Must be at least 1.') +@minValue(0) +param containerMinReplicas int = 1 + +@description('The name of the container') +param containerName string = 'main' + +@description('The name of the container registry') +param containerRegistryName string = '' + +@description('Hostname suffix for container registry. Set when deploying to sovereign clouds') +param containerRegistryHostSuffix string = 'azurecr.io' + +@allowed([ 'http', 'grpc' ]) +@description('The protocol used by Dapr to connect to the app, e.g., HTTP or gRPC') +param daprAppProtocol string = 'http' + +@description('Enable or disable Dapr for the container app') +param daprEnabled bool = false + +@description('The Dapr app ID') +param daprAppId string = containerName + +@description('Specifies if the resource already exists') +param exists bool = false + +@description('Specifies if Ingress is enabled for the container app') +param ingressEnabled bool = true + +@description('The type of identity for the resource') +@allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) +param identityType string = 'None' + +@description('The name of the user-assigned identity') +param identityName string = '' + +@description('The name of the container image') +param imageName string = '' + +@description('The secrets required for the container') +param secrets array = [] + +@description('The environment variables for the container') +param env array = [] + +@description('Specifies if the resource ingress is exposed externally') +param external bool = true + +@description('The service binds associated with the container') +param serviceBinds array = [] + +@description('The target port for the container') +param targetPort int = 80 + +resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { + name: name +} + +module app 'container-app.bicep' = { + name: '${deployment().name}-update' + params: { + name: name + location: location + tags: tags + identityType: identityType + identityName: identityName + ingressEnabled: ingressEnabled + containerName: containerName + containerAppsEnvironmentName: containerAppsEnvironmentName + containerRegistryName: containerRegistryName + containerRegistryHostSuffix: containerRegistryHostSuffix + containerCpuCoreCount: containerCpuCoreCount + containerMemory: containerMemory + containerMinReplicas: containerMinReplicas + containerMaxReplicas: containerMaxReplicas + daprEnabled: daprEnabled + daprAppId: daprAppId + daprAppProtocol: daprAppProtocol + secrets: secrets + external: external + env: env + imageName: !empty(imageName) ? imageName : exists ? existingApp.properties.template.containers[0].image : '' + targetPort: targetPort + serviceBinds: serviceBinds + } +} + +output defaultDomain string = app.outputs.defaultDomain +output imageName string = app.outputs.imageName +output name string = app.outputs.name +output uri string = app.outputs.uri diff --git a/infra/core/host/container-app.bicep b/infra/core/host/container-app.bicep new file mode 100644 index 0000000..f67ceae --- /dev/null +++ b/infra/core/host/container-app.bicep @@ -0,0 +1,165 @@ +metadata description = 'Creates a container app in an Azure Container App environment.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Allowed origins') +param allowedOrigins array = [] + +@description('Name of the environment for container apps') +param containerAppsEnvironmentName string + +@description('CPU cores allocated to a single container instance, e.g., 0.5') +param containerCpuCoreCount string = '0.5' + +@description('The maximum number of replicas to run. Must be at least 1.') +@minValue(1) +param containerMaxReplicas int = 10 + +@description('Memory allocated to a single container instance, e.g., 1Gi') +param containerMemory string = '1.0Gi' + +@description('The minimum number of replicas to run. Must be at least 1.') +param containerMinReplicas int = 1 + +@description('The name of the container') +param containerName string = 'main' + +@description('The name of the container registry') +param containerRegistryName string = '' + +@description('Hostname suffix for container registry. Set when deploying to sovereign clouds') +param containerRegistryHostSuffix string = 'azurecr.io' + +@description('The protocol used by Dapr to connect to the app, e.g., http or grpc') +@allowed([ 'http', 'grpc' ]) +param daprAppProtocol string = 'http' + +@description('The Dapr app ID') +param daprAppId string = containerName + +@description('Enable Dapr') +param daprEnabled bool = false + +@description('The environment variables for the container') +param env array = [] + +@description('Specifies if the resource ingress is exposed externally') +param external bool = true + +@description('The name of the user-assigned identity') +param identityName string = '' + +@description('The type of identity for the resource') +@allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) +param identityType string = 'None' + +@description('The name of the container image') +param imageName string = '' + +@description('Specifies if Ingress is enabled for the container app') +param ingressEnabled bool = true + +param revisionMode string = 'Single' + +@description('The secrets required for the container') +param secrets array = [] + +@description('The service binds associated with the container') +param serviceBinds array = [] + +@description('The name of the container apps add-on to use. e.g. redis') +param serviceType string = '' + +@description('The target port for the container') +param targetPort int = 80 + +resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = if (!empty(identityName)) { + name: identityName +} + +// Private registry support requires both an ACR name and a User Assigned managed identity +var usePrivateRegistry = !empty(identityName) && !empty(containerRegistryName) + +// Automatically set to `UserAssigned` when an `identityName` has been set +var normalizedIdentityType = !empty(identityName) ? 'UserAssigned' : identityType + +module containerRegistryAccess '../security/registry-access.bicep' = if (usePrivateRegistry) { + name: '${deployment().name}-registry-access' + params: { + containerRegistryName: containerRegistryName + principalId: usePrivateRegistry ? userIdentity.properties.principalId : '' + } +} + +resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { + name: name + location: location + tags: tags + // It is critical that the identity is granted ACR pull access before the app is created + // otherwise the container app will throw a provision error + // This also forces us to use an user assigned managed identity since there would no way to + // provide the system assigned identity with the ACR pull access before the app is created + dependsOn: usePrivateRegistry ? [ containerRegistryAccess ] : [] + identity: { + type: normalizedIdentityType + userAssignedIdentities: !empty(identityName) && normalizedIdentityType == 'UserAssigned' ? { '${userIdentity.id}': {} } : null + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + activeRevisionsMode: revisionMode + ingress: ingressEnabled ? { + external: external + targetPort: targetPort + transport: 'auto' + corsPolicy: { + allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) + } + } : null + dapr: daprEnabled ? { + enabled: true + appId: daprAppId + appProtocol: daprAppProtocol + appPort: ingressEnabled ? targetPort : 0 + } : { enabled: false } + secrets: secrets + service: !empty(serviceType) ? { type: serviceType } : null + registries: usePrivateRegistry ? [ + { + server: '${containerRegistryName}.${containerRegistryHostSuffix}' + identity: userIdentity.id + } + ] : [] + } + template: { + serviceBinds: !empty(serviceBinds) ? serviceBinds : null + containers: [ + { + image: !empty(imageName) ? imageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: containerName + env: env + resources: { + cpu: json(containerCpuCoreCount) + memory: containerMemory + } + } + ] + scale: { + minReplicas: containerMinReplicas + maxReplicas: containerMaxReplicas + } + } + } +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' existing = { + name: containerAppsEnvironmentName +} + +output defaultDomain string = containerAppsEnvironment.properties.defaultDomain +output identityPrincipalId string = normalizedIdentityType == 'None' ? '' : (empty(identityName) ? app.identity.principalId : userIdentity.properties.principalId) +output imageName string = imageName +output name string = app.name +output serviceBind object = !empty(serviceType) ? { serviceId: app.id, name: name } : {} +output uri string = ingressEnabled ? 'https://${app.properties.configuration.ingress.fqdn}' : '' diff --git a/infra/core/host/container-apps-environment.bicep b/infra/core/host/container-apps-environment.bicep new file mode 100644 index 0000000..20f4632 --- /dev/null +++ b/infra/core/host/container-apps-environment.bicep @@ -0,0 +1,41 @@ +metadata description = 'Creates an Azure Container Apps environment.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Name of the Application Insights resource') +param applicationInsightsName string = '' + +@description('Specifies if Dapr is enabled') +param daprEnabled bool = false + +@description('Name of the Log Analytics workspace') +param logAnalyticsWorkspaceName string + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = { + name: name + location: location + tags: tags + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey + } + } + daprAIInstrumentationKey: daprEnabled && !empty(applicationInsightsName) ? applicationInsights.properties.InstrumentationKey : '' + } +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { + name: logAnalyticsWorkspaceName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (daprEnabled && !empty(applicationInsightsName)) { + name: applicationInsightsName +} + +output defaultDomain string = containerAppsEnvironment.properties.defaultDomain +output id string = containerAppsEnvironment.id +output name string = containerAppsEnvironment.name diff --git a/infra/core/host/container-apps.bicep b/infra/core/host/container-apps.bicep new file mode 100644 index 0000000..1c656e2 --- /dev/null +++ b/infra/core/host/container-apps.bicep @@ -0,0 +1,40 @@ +metadata description = 'Creates an Azure Container Registry and an Azure Container Apps environment.' +param name string +param location string = resourceGroup().location +param tags object = {} + +param containerAppsEnvironmentName string +param containerRegistryName string +param containerRegistryResourceGroupName string = '' +param containerRegistryAdminUserEnabled bool = false +param logAnalyticsWorkspaceName string +param applicationInsightsName string = '' + +module containerAppsEnvironment 'container-apps-environment.bicep' = { + name: '${name}-container-apps-environment' + params: { + name: containerAppsEnvironmentName + location: location + tags: tags + logAnalyticsWorkspaceName: logAnalyticsWorkspaceName + applicationInsightsName: applicationInsightsName + } +} + +module containerRegistry 'container-registry.bicep' = { + name: '${name}-container-registry' + scope: !empty(containerRegistryResourceGroupName) ? resourceGroup(containerRegistryResourceGroupName) : resourceGroup() + params: { + name: containerRegistryName + location: location + adminUserEnabled: containerRegistryAdminUserEnabled + tags: tags + } +} + +output defaultDomain string = containerAppsEnvironment.outputs.defaultDomain +output environmentName string = containerAppsEnvironment.outputs.name +output environmentId string = containerAppsEnvironment.outputs.id + +output registryLoginServer string = containerRegistry.outputs.loginServer +output registryName string = containerRegistry.outputs.name diff --git a/infra/core/host/container-registry.bicep b/infra/core/host/container-registry.bicep new file mode 100644 index 0000000..d14731c --- /dev/null +++ b/infra/core/host/container-registry.bicep @@ -0,0 +1,137 @@ +metadata description = 'Creates an Azure Container Registry.' +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Indicates whether admin user is enabled') +param adminUserEnabled bool = false + +@description('Indicates whether anonymous pull is enabled') +param anonymousPullEnabled bool = false + +@description('Azure ad authentication as arm policy settings') +param azureADAuthenticationAsArmPolicy object = { + status: 'enabled' +} + +@description('Indicates whether data endpoint is enabled') +param dataEndpointEnabled bool = false + +@description('Encryption settings') +param encryption object = { + status: 'disabled' +} + +@description('Export policy settings') +param exportPolicy object = { + status: 'enabled' +} + +@description('Metadata search settings') +param metadataSearch string = 'Disabled' + +@description('Options for bypassing network rules') +param networkRuleBypassOptions string = 'AzureServices' + +@description('Public network access setting') +param publicNetworkAccess string = 'Enabled' + +@description('Quarantine policy settings') +param quarantinePolicy object = { + status: 'disabled' +} + +@description('Retention policy settings') +param retentionPolicy object = { + days: 7 + status: 'disabled' +} + +@description('Scope maps setting') +param scopeMaps array = [] + +@description('SKU settings') +param sku object = { + name: 'Basic' +} + +@description('Soft delete policy settings') +param softDeletePolicy object = { + retentionDays: 7 + status: 'disabled' +} + +@description('Trust policy settings') +param trustPolicy object = { + type: 'Notary' + status: 'disabled' +} + +@description('Zone redundancy setting') +param zoneRedundancy string = 'Disabled' + +@description('The log analytics workspace ID used for logging and monitoring') +param workspaceId string = '' + +// 2023-11-01-preview needed for metadataSearch +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { + name: name + location: location + tags: tags + sku: sku + properties: { + adminUserEnabled: adminUserEnabled + anonymousPullEnabled: anonymousPullEnabled + dataEndpointEnabled: dataEndpointEnabled + encryption: encryption + metadataSearch: metadataSearch + networkRuleBypassOptions: networkRuleBypassOptions + policies:{ + quarantinePolicy: quarantinePolicy + trustPolicy: trustPolicy + retentionPolicy: retentionPolicy + exportPolicy: exportPolicy + azureADAuthenticationAsArmPolicy: azureADAuthenticationAsArmPolicy + softDeletePolicy: softDeletePolicy + } + publicNetworkAccess: publicNetworkAccess + zoneRedundancy: zoneRedundancy + } + + resource scopeMap 'scopeMaps' = [for scopeMap in scopeMaps: { + name: scopeMap.name + properties: scopeMap.properties + }] +} + +// TODO: Update diagnostics to be its own module +// Blocking issue: https://github.com/Azure/bicep/issues/622 +// Unable to pass in a `resource` scope or unable to use string interpolation in resource types +resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { + name: 'registry-diagnostics' + scope: containerRegistry + properties: { + workspaceId: workspaceId + logs: [ + { + category: 'ContainerRegistryRepositoryEvents' + enabled: true + } + { + category: 'ContainerRegistryLoginEvents' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + timeGrain: 'PT1M' + } + ] + } +} + +output id string = containerRegistry.id +output loginServer string = containerRegistry.properties.loginServer +output name string = containerRegistry.name diff --git a/infra/core/monitor/applicationinsights-dashboard.bicep b/infra/core/monitor/applicationinsights-dashboard.bicep new file mode 100644 index 0000000..d082e66 --- /dev/null +++ b/infra/core/monitor/applicationinsights-dashboard.bicep @@ -0,0 +1,1236 @@ +metadata description = 'Creates a dashboard for an Application Insights instance.' +param name string +param applicationInsightsName string +param location string = resourceGroup().location +param tags object = {} + +// 2020-09-01-preview because that is the latest valid version +resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { + name: name + location: location + tags: tags + properties: { + lenses: [ + { + order: 0 + parts: [ + { + position: { + x: 0 + y: 0 + colSpan: 2 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'id' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' + asset: { + idInputName: 'id' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'overview' + } + } + { + position: { + x: 2 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'ProactiveDetection' + } + } + { + position: { + x: 3 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:20:33.345Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 5 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-08T18:47:35.237Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'ConfigurationId' + value: '78ce933e-e864-4b05-a27b-71fd55a6afad' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 0 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Usage' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 3 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:22:35.782Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Reliability' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 7 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:42:40.072Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'failures' + } + } + { + position: { + x: 8 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Responsiveness\r\n' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 11 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:43:37.804Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'performance' + } + } + { + position: { + x: 12 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Browser' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 15 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'MetricsExplorerJsonDefinitionId' + value: 'BrowserPerformanceTimelineMetrics' + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + createdTime: '2018-05-08T12:16:27.534Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'CurrentFilter' + value: { + eventTypes: [ + 4 + 1 + 3 + 5 + 2 + 6 + 13 + ] + typeFacets: {} + isPermissive: false + } + } + { + name: 'id' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'browser' + } + } + { + position: { + x: 0 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'sessions/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Sessions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'users/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Users' + color: '#7E58FF' + } + } + ] + title: 'Unique sessions and users' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'segmentationUsers' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Failed requests' + color: '#EC008C' + } + } + ] + title: 'Failed requests' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'failures' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/duration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server response time' + color: '#00BCF2' + } + } + ] + title: 'Server response time' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'performance' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/networkDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Page load network connect time' + color: '#7E58FF' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/processingDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Client processing time' + color: '#44F1C8' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/sendDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Send request time' + color: '#EB9371' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/receiveDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Receiving response time' + color: '#0672F1' + } + } + ] + title: 'Average page load time breakdown' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/availabilityPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability' + color: '#47BDF5' + } + } + ] + title: 'Average availability' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'availability' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/server' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server exceptions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'dependencies/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Dependency failures' + color: '#7E58FF' + } + } + ] + title: 'Server exceptions and Dependency failures' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processorCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Processor time' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process CPU' + color: '#7E58FF' + } + } + ] + title: 'Average processor and process CPU utilization' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/browser' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Browser exceptions' + color: '#47BDF5' + } + } + ] + title: 'Browser exceptions' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/count' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability test results count' + color: '#47BDF5' + } + } + ] + title: 'Availability test results count' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processIOBytesPerSecond' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process IO rate' + color: '#47BDF5' + } + } + ] + title: 'Average process I/O rate' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/memoryAvailableBytes' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Available memory' + color: '#47BDF5' + } + } + ] + title: 'Average available memory' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + ] + } + ] + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} diff --git a/infra/core/monitor/applicationinsights.bicep b/infra/core/monitor/applicationinsights.bicep new file mode 100644 index 0000000..850e9fe --- /dev/null +++ b/infra/core/monitor/applicationinsights.bicep @@ -0,0 +1,31 @@ +metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' +param name string +param dashboardName string = '' +param location string = resourceGroup().location +param tags object = {} +param logAnalyticsWorkspaceId string + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspaceId + } +} + +module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { + name: 'application-insights-dashboard' + params: { + name: dashboardName + location: location + applicationInsightsName: applicationInsights.name + } +} + +output connectionString string = applicationInsights.properties.ConnectionString +output id string = applicationInsights.id +output instrumentationKey string = applicationInsights.properties.InstrumentationKey +output name string = applicationInsights.name diff --git a/infra/core/monitor/loganalytics.bicep b/infra/core/monitor/loganalytics.bicep new file mode 100644 index 0000000..33f9dc2 --- /dev/null +++ b/infra/core/monitor/loganalytics.bicep @@ -0,0 +1,22 @@ +metadata description = 'Creates a Log Analytics workspace.' +param name string +param location string = resourceGroup().location +param tags object = {} + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { + name: name + location: location + tags: tags + properties: any({ + retentionInDays: 30 + features: { + searchVersion: 1 + } + sku: { + name: 'PerGB2018' + } + }) +} + +output id string = logAnalytics.id +output name string = logAnalytics.name diff --git a/infra/core/monitor/monitoring.bicep b/infra/core/monitor/monitoring.bicep new file mode 100644 index 0000000..7476125 --- /dev/null +++ b/infra/core/monitor/monitoring.bicep @@ -0,0 +1,33 @@ +metadata description = 'Creates an Application Insights instance and a Log Analytics workspace.' +param logAnalyticsName string +param applicationInsightsName string +param applicationInsightsDashboardName string = '' +param location string = resourceGroup().location +param tags object = {} + +module logAnalytics 'loganalytics.bicep' = { + name: 'loganalytics' + params: { + name: logAnalyticsName + location: location + tags: tags + } +} + +module applicationInsights 'applicationinsights.bicep' = { + name: 'applicationinsights' + params: { + name: applicationInsightsName + location: location + tags: tags + dashboardName: applicationInsightsDashboardName + logAnalyticsWorkspaceId: logAnalytics.outputs.id + } +} + +output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString +output applicationInsightsId string = applicationInsights.outputs.id +output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey +output applicationInsightsName string = applicationInsights.outputs.name +output logAnalyticsWorkspaceId string = logAnalytics.outputs.id +output logAnalyticsWorkspaceName string = logAnalytics.outputs.name diff --git a/infra/core/security/keyvault-access.bicep b/infra/core/security/keyvault-access.bicep new file mode 100644 index 0000000..316775f --- /dev/null +++ b/infra/core/security/keyvault-access.bicep @@ -0,0 +1,22 @@ +metadata description = 'Assigns an Azure Key Vault access policy.' +param name string = 'add' + +param keyVaultName string +param permissions object = { secrets: [ 'get', 'list' ] } +param principalId string + +resource keyVaultAccessPolicies 'Microsoft.KeyVault/vaults/accessPolicies@2022-07-01' = { + parent: keyVault + name: name + properties: { + accessPolicies: [ { + objectId: principalId + tenantId: subscription().tenantId + permissions: permissions + } ] + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} diff --git a/infra/core/security/keyvault-secret.bicep b/infra/core/security/keyvault-secret.bicep new file mode 100644 index 0000000..7441b29 --- /dev/null +++ b/infra/core/security/keyvault-secret.bicep @@ -0,0 +1,31 @@ +metadata description = 'Creates or updates a secret in an Azure Key Vault.' +param name string +param tags object = {} +param keyVaultName string +param contentType string = 'string' +@description('The value of the secret. Provide only derived values like blob storage access, but do not hard code any secrets in your templates') +@secure() +param secretValue string + +param enabled bool = true +param exp int = 0 +param nbf int = 0 + +resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + name: name + tags: tags + parent: keyVault + properties: { + attributes: { + enabled: enabled + exp: exp + nbf: nbf + } + contentType: contentType + value: secretValue + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} diff --git a/infra/core/security/keyvault.bicep b/infra/core/security/keyvault.bicep new file mode 100644 index 0000000..663ec00 --- /dev/null +++ b/infra/core/security/keyvault.bicep @@ -0,0 +1,27 @@ +metadata description = 'Creates an Azure Key Vault.' +param name string +param location string = resourceGroup().location +param tags object = {} + +param principalId string = '' + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { + name: name + location: location + tags: tags + properties: { + tenantId: subscription().tenantId + sku: { family: 'A', name: 'standard' } + accessPolicies: !empty(principalId) ? [ + { + objectId: principalId + permissions: { secrets: [ 'get', 'list' ] } + tenantId: subscription().tenantId + } + ] : [] + } +} + +output endpoint string = keyVault.properties.vaultUri +output id string = keyVault.id +output name string = keyVault.name diff --git a/infra/core/security/registry-access.bicep b/infra/core/security/registry-access.bicep new file mode 100644 index 0000000..fc66837 --- /dev/null +++ b/infra/core/security/registry-access.bicep @@ -0,0 +1,19 @@ +metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' +param containerRegistryName string +param principalId string + +var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + +resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry // Use when specifying a scope that is different than the deployment scope + name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) + properties: { + roleDefinitionId: acrPullRole + principalType: 'ServicePrincipal' + principalId: principalId + } +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { + name: containerRegistryName +} diff --git a/infra/core/security/role.bicep b/infra/core/security/role.bicep new file mode 100644 index 0000000..0b30cfd --- /dev/null +++ b/infra/core/security/role.bicep @@ -0,0 +1,21 @@ +metadata description = 'Creates a role assignment for a service principal.' +param principalId string + +@allowed([ + 'Device' + 'ForeignGroup' + 'Group' + 'ServicePrincipal' + 'User' +]) +param principalType string = 'ServicePrincipal' +param roleDefinitionId string + +resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) + } +} diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 0000000..aa0dfe5 --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,301 @@ +targetScope = 'subscription' + +// See also https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create + +/******************************************************************************/ +/* PARAMETERS */ +/******************************************************************************/ + +@minLength(1) +@maxLength(64) +@description('Name of the the environment which is used to generate a short unique hash used in all resources.') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +/******************************* Resource Names *******************************/ + +// Optional parameters to override the default azd resource naming conventions. +// Add to main.parameters.json to provide values. + +@maxLength(90) +@description('Name of the resource group to deploy. If not specified, a name will be generated.') +param resourceGroupName string = '' + +@maxLength(63) +@description('Keyvault resource name. If not specified, a name will be generated.') +param keyVaultName string = '' + +@description('Id of the user or app to assign application roles to. AZD will set it to the current user if not specified.') +param principalId string = '' + +@maxLength(60) +@description('Name of the container apps environment to deploy. If not specified, a name will be generated. The maximum length is 60 characters.') +param containerAppsEnvironmentName string = '' + +@maxLength(50) +@description('Name of the Container Registry to deploy. If not specified, a name will be generated. The name is global and must be unique within Azure. The maximum length is 50 characters.') +param containerRegistryName string = '' + +/* Observability */ + +@maxLength(63) +@description('Name of the Log Analytics Workspace to deploy. If not specified, a name will be generated. The maximum length is 63 characters.') +param logAnalyticsWorkspaceName string = '' + +@maxLength(255) +@description('Name of the Application Insights to deploy. If not specified, a name will be generated. The maximum length is 255 characters.') +param applicationInsightsName string = '' + +@maxLength(160) +@description('Name of the Application Insights dashboard to deploy. If not specified, a name will be generated. The maximum length is 160 characters.') +param applicationInsightsDashboardName string = '' + +/* PostgreSQL */ + +@maxLength(63) +@description('Name of the PostgreSQL flexible server to deploy. If not specified, a name will be generated. The name is global and must be unique within Azure. The maximum length is 63 characters. It contains only lowercase letters, numbers and hyphens, and cannot start nor end with a hyphen.') +param postgresFlexibleServerName string = '' + +@description('Name of the PostgreSQL admin user.') +param postgresAdminUsername string = 'pgadmin' + +@secure() +@description('PostGreSQL Server administrator password') +param postgresAdminPassword string + +/* Quarkus Telemetry */ + +@maxLength(32) +@description('Name of the Quarkus Telemetry Container App to deploy. If not specified, a name will be generated. The maximum length is 32 characters.') +param quarkusContainerAppName string = '' + +@description('Set if the Quarkus Telemetry Container aAp already exists.') +param quarkusAppExists bool = false + +/* Spring Boot Telemetry */ + +@maxLength(32) +@description('Name of the SpringBoot Telemetry Container App to deploy. If not specified, a name will be generated. The maximum length is 32 characters.') +param springBootContainerAppName string = '' + +@description('Set if the SpringBoot Telemetry Container aAp already exists.') +param springBootAppExists bool = false + +/******************************************************************************/ +/* VARIABLES */ +/******************************************************************************/ + +var abbreviations = loadJsonContent('./abbreviations.json') + +// tags that should be applied to all resources. +var tags = { + 'azd-env-name': environmentName +} + +/******************************* Resource Names *******************************/ + + +// Name of the service defined in azure.yaml +// A tag named azd-service-name with this value should be applied to the service host resource, such as: +// tags: union(tags, { 'azd-service-name': apiServiceName }) +var quarkusServiceName = 'quarkus' +var springBootServiceName = 'spring-boot' + +var quarkusDbName = '${quarkusServiceName}db' +var springBootDbName = '${springBootServiceName}db' + +// Generate a unique token to be used in naming resources. +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +@description('Name of the environment with only alphanumeric characters. Used for resource names that require alphanumeric characters only.') +var alphaNumericEnvironmentName = replace(replace(environmentName, '-', ''), ' ', '') + +var _containerAppsEnvironmentName = !empty(containerAppsEnvironmentName) ? containerAppsEnvironmentName : take('${abbreviations.appManagedEnvironments}${environmentName}', 60) +var _containerRegistryName = !empty(containerRegistryName) ? containerRegistryName : take('${abbreviations.containerRegistryRegistries}${take(alphaNumericEnvironmentName, 35)}${resourceToken}', 50) +var _logAnalyticsName = !empty(logAnalyticsWorkspaceName) ? logAnalyticsWorkspaceName : take('${abbreviations.operationalInsightsWorkspaces}${environmentName}', 63) +var _applicationInsightsName = !empty(applicationInsightsName) ? applicationInsightsName : take('${abbreviations.insightsComponents}${environmentName}', 255) +var _applicationInsightsDashboardName = !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : take('${abbreviations.portalDashboards}${environmentName}', 160) +var _quarkusContainerAppName = !empty(quarkusContainerAppName) ? quarkusContainerAppName : take('${abbreviations.appContainerApps}${quarkusServiceName}-${environmentName}', 32) +var _springBootContainerAppName = !empty(springBootContainerAppName) ? springBootContainerAppName : take('${abbreviations.appContainerApps}spring-boot-${environmentName}', 32) +var _postgresFlexibleServerName = !empty(postgresFlexibleServerName) ? postgresFlexibleServerName : take(toLower('${abbreviations.dBforPostgreSQLServers}${take(environmentName, 44)}-${resourceToken}'), 63) +var _keyVaultName = !empty(keyVaultName) ? keyVaultName : take('${abbreviations.keyVaultVaults}${take(alphaNumericEnvironmentName, 8)}${resourceToken}', 24) +var _keyVaultSecrets = [ + { + name: 'postgres-admin-password' + value: postgresAdminPassword + } +] + +/******************************************************************************/ +/* RESOURCES */ +/******************************************************************************/ + +// Organize resources in a resource group +resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : '${abbreviations.resourcesResourceGroups}${environmentName}' + location: location + tags: tags +} + +module quarkus './app/quarkus.bicep' = { + name: quarkusServiceName + scope: resourceGroup + params: { + name: _quarkusContainerAppName + serviceName: quarkusServiceName + location: location + tags: tags + identityName: _quarkusContainerAppName + applicationInsightsName: monitoring.outputs.applicationInsightsName + containerAppsEnvironmentName: containerAppsEnvironment.outputs.name + containerRegistryName: containerRegistry.outputs.name + databaseConfig: { + name: quarkusDbName + hostname: postgresServer.outputs.POSTGRES_DOMAIN_NAME + username: postgresAdminUsername + } + keyVaultName: keyVault.outputs.name + exists: quarkusAppExists + } +} + +module springBoot './app/spring-boot.bicep' = { + name: springBootServiceName + scope: resourceGroup + params: { + name: _springBootContainerAppName + serviceName: springBootServiceName + location: location + tags: tags + identityName: _springBootContainerAppName + applicationInsightsName: monitoring.outputs.applicationInsightsName + containerAppsEnvironmentName: containerAppsEnvironment.outputs.name + containerRegistryName: containerRegistry.outputs.name + databaseConfig: { + name: springBootDbName + hostname: postgresServer.outputs.POSTGRES_DOMAIN_NAME + username: postgresAdminUsername + } + keyVaultName: keyVault.outputs.name + superHeroUrl: quarkus.outputs.SERVICE_QUARKUS_URI + exists: springBootAppExists + } +} + +module containerAppsEnvironment 'core/host/container-apps-environment.bicep' = { + name: _containerAppsEnvironmentName + + scope: resourceGroup + params: { + name: _containerAppsEnvironmentName + location: location + tags: tags + logAnalyticsWorkspaceName: monitoring.outputs.logAnalyticsWorkspaceName + applicationInsightsName: monitoring.outputs.applicationInsightsName + } +} + +module keyVault './core/security/keyvault.bicep' = { + name: 'keyVault' + scope: resourceGroup + params: { + name: _keyVaultName + location: location + tags: tags + principalId: principalId + } +} + +/* Give access to the keyvault to the current identity */ +module principalKeyVaultAccess './core/security/keyvault-access.bicep' = { + name: 'keyvault-access-${principalId}' + scope: resourceGroup + params: { + keyVaultName: keyVault.outputs.name + principalId: principalId + } +} + +@batchSize(1) +module keyVaultSecrets './core/security/keyvault-secret.bicep' = [for secret in _keyVaultSecrets: { + name: 'keyvault-secret-${secret.name}' + scope: resourceGroup + params: { + keyVaultName: keyVault.outputs.name + name: secret.name + secretValue: secret.value + } +}] + +module containerRegistry 'core/host/container-registry.bicep' = { + name: _containerRegistryName + scope: resourceGroup + params: { + name: _containerRegistryName + location: location + tags: tags + } +} + +module monitoring 'core/monitor/monitoring.bicep' = { + name: 'monitoring' + scope: resourceGroup + params: { + location: location + tags: tags + logAnalyticsName: _logAnalyticsName + applicationInsightsName: _applicationInsightsName + applicationInsightsDashboardName: _applicationInsightsDashboardName + } +} + +module postgresServer 'core/database/postgresql/flexibleserver.bicep' = { + name: _postgresFlexibleServerName + scope: resourceGroup + params: { + name: _postgresFlexibleServerName + location: location + tags: tags + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + storage: { + storageSizeGB: 32 + } + version: '16' + administratorLogin: postgresAdminUsername + administratorLoginPassword: postgresAdminPassword + databaseNames: [ + quarkusDbName, springBootDbName + ] + allowAzureIPsFirewall: true + } +} + +/******************************************************************************/ +/* OUTPUTS */ +/******************************************************************************/ + +// Outputs are automatically saved in the local azd environment .env file. +// To see these outputs, run `azd env get-values`, or `azd env get-values --output json` for json output. +@description('Location where all resources were installed.') +output AZURE_LOCATION string = location + +@description('Azure Tenant ID.') +output AZURE_TENANT_ID string = tenant().tenantId + +@description('Azure Key Vault name. Reused to fetch PostgreSQL password in main.parameters.json') +output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name + +@description('Container registry endpoint.') +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer + +@description('Quarkus application URI.') +output QUARKUS_SERVICE_URI string = quarkus.outputs.SERVICE_QUARKUS_URI + +@description('SpringBoot application URI.') +output SPRING_BOOT_SERVICE_URI string = springBoot.outputs.SERVICE_SPRING_BOOT_URI diff --git a/infra/main.parameters.json b/infra/main.parameters.json new file mode 100644 index 0000000..154ddc5 --- /dev/null +++ b/infra/main.parameters.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "postgresAdminPassword": { + "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} postgres-admin-password)" + }, + "quarkusAppExists": { + "value": "${SERVICE_QUARKUS_RESOURCE_EXISTS=false}" + }, + "springBootAppExists": { + "value": "${SERVICE_SPRING_BOOT_RESOURCE_EXISTS=false}" + } + } +} diff --git a/scripts/docker-compose/docker-compose.yml b/scripts/docker-compose/docker-compose.yml deleted file mode 100644 index 5e81c0b..0000000 --- a/scripts/docker-compose/docker-compose.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: "2" -services: - - database: - image: postgres:latest - environment: - POSTGRES_PASSWORD: changeit - POSTGRES_USER: pguser - POSTGRES_DB: test - ports: - - '5432:5432' diff --git a/scripts/repo/create-github-template.sh b/scripts/repo/create-github-template.sh deleted file mode 100755 index 1a949df..0000000 --- a/scripts/repo/create-github-template.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -############################################################################## -# Usage: ./create-github-template.sh [--local] -# Creates the project template and push it to GitHub. -############################################################################## - -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")" -cd ../.. - -GITHUB_REPOSITORY=${GITHUB_REPOSITORY:-} -TEMPLATE_HOME=/tmp/moaw-template - -if [[ -z "${GITHUB_REPOSITORY}" ]]; then - TEMPLATE_REPO=$(git remote get-url origin) -else - TEMPLATE_REPO=https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git -fi - -echo "Preparing GitHub project template..." -echo "(temp folder: $TEMPLATE_HOME)" -rm -rf "$TEMPLATE_HOME" - -# Clone the template repo and start from the base branch to keep -# the contributors history in the main branch we'll overwrite -git clone "$TEMPLATE_REPO" "$TEMPLATE_HOME" -pushd "$TEMPLATE_HOME" -git reset --hard origin/base -git checkout -b main -popd - -find . -type d -not -path '*node_modules*' -not -path '*.git/*' -not -path '*/dist' -not -path '*dist/*' -exec mkdir -p '{}' "$TEMPLATE_HOME/{}" ';' -find . -type f -not -path '*node_modules*' -not -path '*.git/*' -not -path '*dist/*' -not -path '*/.DS_Store' -exec cp -r '{}' "$TEMPLATE_HOME/{}" ';' -cd "$TEMPLATE_HOME" - -############################################################################## -# TODO: Prepare the project template -############################################################################## - -# Remove unnecessary files -rm -rf node_modules -rm -rf .github -rm -rf TODO -rm -rf package-lock.json -rm -rf scripts/repo -rm -rf docs -rm -rf .azure -rm -rf .env -rm -rf ./*.env - -# Prepare files -echo -e "console.log('hello world!')" > index.js - -############################################################################## - -# Prepare the commit -git add . -git commit -m "chore: prepare project template" - -if [[ ${1-} == "--local" ]] || [[ -z "${GITHUB_REPOSITORY}" ]]; then - echo "Local mode: skipping GitHub push." - open "$TEMPLATE_HOME" -else - # Update git repo - git push -u origin main --force - - rm -rf "$TEMPLATE_HOME" -fi - -echo "Successfully updated project template." diff --git a/scripts/repo/create-packages.sh b/scripts/repo/create-packages.sh deleted file mode 100755 index dda939e..0000000 --- a/scripts/repo/create-packages.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -############################################################################## -# Usage: ./create-packages.sh -# Creates packages for skippable sections of the workshop -############################################################################## - -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/../.." - -target_folder=dist - -rm -rf "$target_folder" -mkdir -p "$target_folder" - -copyFolder() { - local src="$1" - local dest="$target_folder/${2:-}" - find "$src" -type d -not -path '*node_modules*' -not -path '*/.git' -not -path '*.git/*' -not -path '*/dist' -not -path '*dist/*' -exec mkdir -p '{}' "$dest/{}" ';' - find "$src" -type f -not -path '*node_modules*' -not -path '*.git/*' -not -path '*dist/*' -not -path '*/.DS_Store' -exec cp -r '{}' "$dest/{}" ';' -} - -makeArchive() { - local src="$1" - local name="${2:-$src}" - local archive="$name.tar.gz" - local cwd="${3:-}" - echo "Creating $archive..." - if [[ -n "$cwd" ]]; then - pushd "$target_folder/$cwd" >/dev/null - tar -czvf "../$archive" "$src" - popd - rm -rf "$target_folder/${cwd:?}" - else - pushd "$target_folder/$cwd" >/dev/null - tar -czvf "$archive" "$src" - popd - rm -rf "$target_folder/${src:?}" - fi -} - -############################################################################## -# TODO: Create as many packages as you need -############################################################################## - -echo "Creating solution package..." -copyFolder . solution - -rm -rf "$target_folder/solution/.azure" -rm -rf "$target_folder/solution/.env" -rm -rf "$target_folder/solution/*.env" -rm -rf "$target_folder/solution/docs" -rm -rf "$target_folder/solution/scripts/repo" -rm -rf "$target_folder/solution/.github" - -makeArchive . solution solution