diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d188b1d..54d859f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- -FROM mcr.microsoft.com/dotnet/sdk:5.0 +FROM mcr.microsoft.com/dotnet/sdk:6.0 # This Dockerfile adds a non-root user with sudo access. Use the "remoteUser" # property in devcontainer.json to use it. On Linux, the container user's GID/UIDs @@ -22,7 +22,8 @@ ENV NVM_DIR=/usr/local/share/nvm ARG INSTALL_AZURE_CLI="false" # Configure apt and install packages -RUN apt-get update \ +RUN curl -fsSL https://aka.ms/install-azd.sh | bash \ + && apt-get update \ && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ # diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6264a06..046068a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.112.0/containers/dotnetcore-3.1 { - "name": "eShopOnWeb", + "name": "eShopOnWeb", "build": { "dockerfile": "Dockerfile", "args": { @@ -11,46 +11,57 @@ "INSTALL_AZURE_CLI": "false" } }, + "features": { + "ghcr.io/devcontainers/features/azure-cli:1": { + "version": "2.38" + }, + "ghcr.io/devcontainers/features/docker-from-docker:1": { + "version": "20.10" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "2" + } + }, + // Comment out to connect as root user. See https://aka.ms/vscode-remote/containers/non-root. + // make sure this is the same as USERNAME above + "remoteUser": "vscode", - // Comment out to connect as root user. See https://aka.ms/vscode-remote/containers/non-root. - // make sure this is the same as USERNAME above - "remoteUser": "vscode", + // Set *default* container specific settings.json values on container create. + "settings": { + "terminal.integrated.shell.linux": "/bin/bash" + }, - // Set *default* container specific settings.json values on container create. - "settings": { - "terminal.integrated.shell.linux": "/bin/bash" - }, - - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-azuretools.azure-dev", "ms-dotnettools.csharp", "formulahendry.dotnet-test-explorer", "ms-vscode.vscode-node-azure-pack", "ms-kubernetes-tools.vscode-kubernetes-tools", "redhat.vscode-yaml" - ], + ], - // Use 'forwardPorts' to make a list of ports inside the container available locally. - "forwardPorts": [5000, 5001], + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [5000, 5001], - // [Optional] To reuse of your local HTTPS dev cert, first export it locally using this command: - // * Windows PowerShell: - // dotnet dev-certs https --trust; dotnet dev-certs https -ep "$env:USERPROFILE/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere" - // * macOS/Linux terminal: - // dotnet dev-certs https --trust; dotnet dev-certs https -ep "${HOME}/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere" - // - // Next, after running the command above, uncomment lines in the 'mounts' and 'remoteEnv' lines below, - // and open / rebuild the container so the settings take effect. - // - "mounts": [ - // "source=${env:HOME}${env:USERPROFILE}/.aspnet/https,target=/home/vscode/.aspnet/https,type=bind" - ], - "remoteEnv": { - // "ASPNETCORE_Kestrel__Certificates__Default__Password": "SecurePwdGoesHere", - // "ASPNETCORE_Kestrel__Certificates__Default__Path": "/home/vscode/.aspnet/https/aspnetapp.pfx", - } + // [Optional] To reuse of your local HTTPS dev cert, first export it locally using this command: + // * Windows PowerShell: + // dotnet dev-certs https --trust; dotnet dev-certs https -ep "$env:USERPROFILE/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere" + // * macOS/Linux terminal: + // dotnet dev-certs https --trust; dotnet dev-certs https -ep "${HOME}/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere" + // + // Next, after running the command above, uncomment lines in the 'mounts' and 'remoteEnv' lines below, + // and open / rebuild the container so the settings take effect. + // + "mounts": [ + // "source=${env:HOME}${env:USERPROFILE}/.aspnet/https,target=/home/vscode/.aspnet/https,type=bind" + ], + "remoteEnv": { + // "ASPNETCORE_Kestrel__Certificates__Default__Password": "SecurePwdGoesHere", + // "ASPNETCORE_Kestrel__Certificates__Default__Path": "/home/vscode/.aspnet/https/aspnetapp.pfx", + } - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "dotnet restore" + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "dotnet restore" } diff --git a/.gitignore b/.gitignore index 1148ecd..91a682b 100644 --- a/.gitignore +++ b/.gitignore @@ -257,3 +257,6 @@ pub/ #Ignore marker-file used to know which docker files we have. .eshopdocker_* +.devcontainer + +.azure diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d0663c2..680470c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "formulahendry.dotnet-test-explorer", "ms-vscode.vscode-node-azure-pack", "ms-kubernetes-tools.vscode-kubernetes-tools", - "redhat.vscode-yaml" + "redhat.vscode-yaml", + "ms-azuretools.azure-dev" ] } \ No newline at end of file diff --git a/azure.yaml b/azure.yaml new file mode 100644 index 0000000..c63402c --- /dev/null +++ b/azure.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/wbreza/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: eShopOnWeb +services: + web: + project: ./src/Web + language: csharp + host: appservice \ No newline at end of file diff --git a/infra/abbreviations.json b/infra/abbreviations.json new file mode 100644 index 0000000..a4fc9df --- /dev/null +++ b/infra/abbreviations.json @@ -0,0 +1,135 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationConfigurationStores": "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", + "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/core/database/sqlserver/sqlserver.bicep b/infra/core/database/sqlserver/sqlserver.bicep new file mode 100644 index 0000000..1c4c212 --- /dev/null +++ b/infra/core/database/sqlserver/sqlserver.bicep @@ -0,0 +1,130 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param appUser string = 'appUser' +param databaseName string +param keyVaultName string +param sqlAdmin string = 'sqlAdmin' +param connectionStringKey string = 'AZURE-SQL-CONNECTION-STRING' + +@secure() +param sqlAdminPassword string +@secure() +param appUserPassword string + +resource sqlServer 'Microsoft.Sql/servers@2022-05-01-preview' = { + name: name + location: location + tags: tags + properties: { + version: '12.0' + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Enabled' + administratorLogin: sqlAdmin + administratorLoginPassword: sqlAdminPassword + } + + resource database 'databases' = { + name: databaseName + location: location + } + + resource firewall 'firewallRules' = { + name: 'Azure Services' + properties: { + // Allow all clients + // Note: range [0.0.0.0-0.0.0.0] means "allow all Azure-hosted clients only". + // This is not sufficient, because we also want to allow direct access from developer machine, for debugging purposes. + startIpAddress: '0.0.0.1' + endIpAddress: '255.255.255.254' + } + } +} + +resource sqlDeploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { + name: '${name}-deployment-script' + location: location + kind: 'AzureCLI' + properties: { + azCliVersion: '2.37.0' + retentionInterval: 'PT1H' // Retain the script resource for 1 hour after it ends running + timeout: 'PT5M' // Five minutes + cleanupPreference: 'OnSuccess' + environmentVariables: [ + { + name: 'APPUSERNAME' + value: appUser + } + { + name: 'APPUSERPASSWORD' + secureValue: appUserPassword + } + { + name: 'DBNAME' + value: databaseName + } + { + name: 'DBSERVER' + value: sqlServer.properties.fullyQualifiedDomainName + } + { + name: 'SQLCMDPASSWORD' + secureValue: sqlAdminPassword + } + { + name: 'SQLADMIN' + value: sqlAdmin + } + ] + + scriptContent: ''' +wget https://github.com/microsoft/go-sqlcmd/releases/download/v0.8.1/sqlcmd-v0.8.1-linux-x64.tar.bz2 +tar x -f sqlcmd-v0.8.1-linux-x64.tar.bz2 -C . + +cat < ./initDb.sql +drop user ${APPUSERNAME} +go +create user ${APPUSERNAME} with password = '${APPUSERPASSWORD}' +go +alter role db_owner add member ${APPUSERNAME} +go +SCRIPT_END + +./sqlcmd -S ${DBSERVER} -d ${DBNAME} -U ${SQLADMIN} -i ./initDb.sql + ''' + } +} + +resource sqlAdminPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'sqlAdminPassword' + properties: { + value: sqlAdminPassword + } +} + +resource appUserPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'appUserPassword' + properties: { + value: appUserPassword + } +} + +resource sqlAzureConnectionStringSercret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: connectionStringKey + properties: { + value: '${connectionString}; Password=${appUserPassword}' + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +var connectionString = 'Server=${sqlServer.properties.fullyQualifiedDomainName}; Database=${sqlServer::database.name}; User=${appUser}' +output connectionStringKey string = connectionStringKey +output databaseName string = sqlServer::database.name +output connectionString string = connectionString diff --git a/infra/core/host/appservice.bicep b/infra/core/host/appservice.bicep new file mode 100644 index 0000000..62e34a6 --- /dev/null +++ b/infra/core/host/appservice.bicep @@ -0,0 +1,97 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +// Reference Properties +param applicationInsightsName string = '' +param appServicePlanId string +param keyVaultName string = '' +param managedIdentity bool = !empty(keyVaultName) + +// Runtime Properties +@allowed([ + 'dotnet', 'dotnetcore', 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' +]) +param runtimeName string +param runtimeNameAndVersion string = '${runtimeName}|${runtimeVersion}' +param runtimeVersion string + +// Microsoft.Web/sites Properties +param kind string = 'app,linux' + +// Microsoft.Web/sites/config +param allowedOrigins array = [] +param alwaysOn bool = true +param appCommandLine string = '' +param appSettings object = {} +param clientAffinityEnabled bool = false +param enableOryxBuild bool = contains(kind, 'linux') +param functionAppScaleLimit int = -1 +param linuxFxVersion string = runtimeNameAndVersion +param minimumElasticInstanceCount int = -1 +param numberOfWorkers int = -1 +param scmDoBuildDuringDeployment bool = false +param use32BitWorkerProcess bool = false + +resource appService 'Microsoft.Web/sites@2022-03-01' = { + name: name + location: location + tags: tags + kind: kind + properties: { + serverFarmId: appServicePlanId + siteConfig: { + linuxFxVersion: linuxFxVersion + alwaysOn: alwaysOn + ftpsState: 'FtpsOnly' + appCommandLine: appCommandLine + numberOfWorkers: numberOfWorkers != -1 ? numberOfWorkers : null + minimumElasticInstanceCount: minimumElasticInstanceCount != -1 ? minimumElasticInstanceCount : null + use32BitWorkerProcess: use32BitWorkerProcess + functionAppScaleLimit: functionAppScaleLimit != -1 ? functionAppScaleLimit : null + cors: { + allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) + } + } + clientAffinityEnabled: clientAffinityEnabled + httpsOnly: true + } + + identity: { type: managedIdentity ? 'SystemAssigned' : 'None' } + + resource configAppSettings 'config' = { + name: 'appsettings' + properties: union(appSettings, + { + SCM_DO_BUILD_DURING_DEPLOYMENT: string(scmDoBuildDuringDeployment) + ENABLE_ORYX_BUILD: string(enableOryxBuild) + }, + !empty(applicationInsightsName) ? { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString } : {}, + !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}) + } + + resource configLogs 'config' = { + name: 'logs' + properties: { + applicationLogs: { fileSystem: { level: 'Verbose' } } + detailedErrorMessages: { enabled: true } + failedRequestsTracing: { enabled: true } + httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } + } + dependsOn: [ + configAppSettings + ] + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = if (!(empty(keyVaultName))) { + name: keyVaultName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { + name: applicationInsightsName +} + +output identityPrincipalId string = managedIdentity ? appService.identity.principalId : '' +output name string = appService.name +output uri string = 'https://${appService.properties.defaultHostName}' diff --git a/infra/core/host/appserviceplan.bicep b/infra/core/host/appserviceplan.bicep new file mode 100644 index 0000000..69c35d7 --- /dev/null +++ b/infra/core/host/appserviceplan.bicep @@ -0,0 +1,20 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param kind string = '' +param reserved bool = true +param sku object + +resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { + name: name + location: location + tags: tags + sku: sku + kind: kind + properties: { + reserved: reserved + } +} + +output id string = appServicePlan.id diff --git a/infra/core/security/keyvault-access.bicep b/infra/core/security/keyvault-access.bicep new file mode 100644 index 0000000..96c9cf7 --- /dev/null +++ b/infra/core/security/keyvault-access.bicep @@ -0,0 +1,21 @@ +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.bicep b/infra/core/security/keyvault.bicep new file mode 100644 index 0000000..0eb4a86 --- /dev/null +++ b/infra/core/security/keyvault.bicep @@ -0,0 +1,25 @@ +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 name string = keyVault.name diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 0000000..b8c5ebb --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,144 @@ +targetScope = 'subscription' + +@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 + +// Optional parameters to override the default azd resource naming conventions. Update the main.parameters.json file to provide values. e.g.,: +// "resourceGroupName": { +// "value": "myGroupName" +// } +param resourceGroupName string = '' +param webServiceName string = '' +param catalogDatabaseName string = 'catalogDatabase' +param catalogDatabaseServerName string = '' +param identityDatabaseName string = 'identityDatabase' +param identityDatabaseServerName string = '' +param appServicePlanName string = '' +param keyVaultName string = '' + +@description('Id of the user or app to assign application roles') +param principalId string = '' + +@secure() +@description('SQL Server administrator password') +param sqlAdminPassword string + +@secure() +@description('Application user password') +param appUserPassword string + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) +var tags = { 'azd-env-name': environmentName } + +// Organize resources in a resource group +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' + location: location + tags: tags +} + +// The application frontend +module web './core/host/appservice.bicep' = { + name: 'web' + scope: rg + params: { + name: !empty(webServiceName) ? webServiceName : '${abbrs.webSitesAppService}web-${resourceToken}' + location: location + appServicePlanId: appServicePlan.outputs.id + keyVaultName: keyVault.outputs.name + runtimeName: 'dotnetcore' + runtimeVersion: '6.0' + tags: union(tags, { 'azd-service-name': 'web' }) + appSettings: { + AZURE_SQL_CATALOG_CONNECTION_STRING_KEY: 'AZURE-SQL-CATALOG-CONNECTION-STRING' + AZURE_SQL_IDENTITY_CONNECTION_STRING_KEY: 'AZURE-SQL-IDENTITY-CONNECTION-STRING' + AZURE_KEY_VAULT_ENDPOINT: keyVault.outputs.endpoint + } + } +} + +module apiKeyVaultAccess './core/security/keyvault-access.bicep' = { + name: 'api-keyvault-access' + scope: rg + params: { + keyVaultName: keyVault.outputs.name + principalId: web.outputs.identityPrincipalId + } +} + +// The application database: Catalog +module catalogDb './core/database/sqlserver/sqlserver.bicep' = { + name: 'sql-catalog' + scope: rg + params: { + name: !empty(catalogDatabaseServerName) ? catalogDatabaseServerName : '${abbrs.sqlServers}catalog-${resourceToken}' + databaseName: catalogDatabaseName + location: location + tags: tags + sqlAdminPassword: sqlAdminPassword + appUserPassword: appUserPassword + keyVaultName: keyVault.outputs.name + connectionStringKey: 'AZURE-SQL-CATALOG-CONNECTION-STRING' + } +} + +// The application database: Identity +module identityDb './core/database/sqlserver/sqlserver.bicep' = { + name: 'sql-identity' + scope: rg + params: { + name: !empty(identityDatabaseServerName) ? identityDatabaseServerName : '${abbrs.sqlServers}identity-${resourceToken}' + databaseName: identityDatabaseName + location: location + tags: tags + sqlAdminPassword: sqlAdminPassword + appUserPassword: appUserPassword + keyVaultName: keyVault.outputs.name + connectionStringKey: 'AZURE-SQL-IDENTITY-CONNECTION-STRING' + } +} + +// Store secrets in a keyvault +module keyVault './core/security/keyvault.bicep' = { + name: 'keyvault' + scope: rg + params: { + name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' + location: location + tags: tags + principalId: principalId + } +} + +// Create an App Service Plan to group applications under the same payment plan and SKU +module appServicePlan './core/host/appserviceplan.bicep' = { + name: 'appserviceplan' + scope: rg + params: { + name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' + location: location + tags: tags + sku: { + name: 'B1' + } + } +} + +// Data outputs +output AZURE_SQL_CATALOG_CONNECTION_STRING_KEY string = catalogDb.outputs.connectionStringKey +output AZURE_SQL_IDENTITY_CONNECTION_STRING_KEY string = identityDb.outputs.connectionStringKey +output AZURE_SQL_CATALOG_DATABASE_NAME string = catalogDb.outputs.databaseName +output AZURE_SQL_IDENTITY_DATABASE_NAME string = identityDb.outputs.databaseName + +// App outputs +output AZURE_LOCATION string = location +output AZURE_TENANT_ID string = tenant().tenantId +output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.endpoint +output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name diff --git a/infra/main.parameters.json b/infra/main.parameters.json new file mode 100644 index 0000000..0ef1d97 --- /dev/null +++ b/infra/main.parameters.json @@ -0,0 +1,21 @@ +{ + "$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}" + }, + "sqlAdminPassword": { + "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} sqlAdminPassword)" + }, + "appUserPassword": { + "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} appUserPassword)" + } + } +} \ No newline at end of file diff --git a/src/Web/AzureDeveloperCliCredential.cs b/src/Web/AzureDeveloperCliCredential.cs new file mode 100644 index 0000000..406c7bf --- /dev/null +++ b/src/Web/AzureDeveloperCliCredential.cs @@ -0,0 +1,148 @@ +using Azure.Core; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Azure.Identity +{ + public class AzureDeveloperCliCredential : TokenCredential + { + internal const string AzdCliNotInstalled = $"Azure Developer CLI could not be found. {Troubleshoot}"; + internal const string AzdNotLogIn = "Please run 'azd login' from a command prompt to authenticate before using this credential."; + internal const string WinAzdCliError = "'azd is not recognized"; + internal const string AzdCliTimeoutError = "Azure Developer CLI authentication timed out."; + internal const string AzdCliFailedError = "Azure Developer CLI authentication failed due to an unknown error."; + internal const string Troubleshoot = "Please visit https://aka.ms/azure-dev for installation instructions and then, once installed, authenticate to your Azure account using 'azd login'."; + internal const string InteractiveLoginRequired = "Azure Developer CLI could not login. Interactive login is required."; + private const string RefreshTokeExpired = "The provided authorization code or refresh token has expired due to inactivity. Send a new interactive authorization request for this user and resource."; + + private static readonly string DefaultWorkingDirWindows = Environment.GetFolderPath(Environment.SpecialFolder.System); + private const string DefaultWorkingDirNonWindows = "/bin/"; + private static readonly string DefaultWorkingDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? DefaultWorkingDirWindows : DefaultWorkingDirNonWindows; + + private static readonly Regex AzdNotFoundPattern = new Regex("azd:(.*)not found"); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken = default) + { + return RequestCliAccessTokenAsync(requestContext, cancellationToken) + .GetAwaiter() + .GetResult(); + } + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken = default) + { + return await RequestCliAccessTokenAsync(requestContext, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask RequestCliAccessTokenAsync(TokenRequestContext context, CancellationToken cancellationToken) + { + try + { + ProcessStartInfo processStartInfo = GetAzdCliProcessStartInfo(context.Scopes); + string output = await RunProcessAsync(processStartInfo); + + return DeserializeOutput(output); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new AuthenticationFailedException(AzdCliTimeoutError); + } + catch (InvalidOperationException exception) + { + bool isWinError = exception.Message.StartsWith(WinAzdCliError, StringComparison.CurrentCultureIgnoreCase); + bool isOtherOsError = AzdNotFoundPattern.IsMatch(exception.Message); + + if (isWinError || isOtherOsError) + { + throw new CredentialUnavailableException(AzdCliNotInstalled); + } + + bool isAADSTSError = exception.Message.Contains("AADSTS"); + bool isLoginError = exception.Message.IndexOf("azd login", StringComparison.OrdinalIgnoreCase) != -1; + + if (isLoginError && !isAADSTSError) + { + throw new CredentialUnavailableException(AzdNotLogIn); + } + + bool isRefreshTokenFailedError = exception.Message.IndexOf(AzdCliFailedError, StringComparison.OrdinalIgnoreCase) != -1 && + exception.Message.IndexOf(RefreshTokeExpired, StringComparison.OrdinalIgnoreCase) != -1 || + exception.Message.IndexOf("CLIInternalError", StringComparison.OrdinalIgnoreCase) != -1; + + if (isRefreshTokenFailedError) + { + throw new CredentialUnavailableException(InteractiveLoginRequired); + } + + throw new AuthenticationFailedException($"{AzdCliFailedError} {Troubleshoot} {exception.Message}"); + } + catch (Exception ex) + { + throw new CredentialUnavailableException($"{AzdCliFailedError} {Troubleshoot} {ex.Message}"); + } + } + + private async ValueTask RunProcessAsync(ProcessStartInfo processStartInfo, CancellationToken cancellationToken = default) + { + var process = Process.Start(processStartInfo); + if (process == null) + { + throw new CredentialUnavailableException(AzdCliFailedError); + } + + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + var errorMessage = process.StandardError.ReadToEnd(); + throw new InvalidOperationException(errorMessage); + } + + return process.StandardOutput.ReadToEnd(); + } + + private ProcessStartInfo GetAzdCliProcessStartInfo(string[] scopes) + { + string scopeArgs = string.Join(" ", scopes.Select(scope => string.Format($"--scope {scope}"))); + string command = $"azd auth token --output json {scopeArgs}"; + + string fileName; + string argument; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); + argument = $"/c \"{command}\""; + } + else + { + fileName = "/bin/sh"; + argument = $"-c \"{command}\""; + } + + return new ProcessStartInfo + { + FileName = fileName, + Arguments = argument, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + ErrorDialog = false, + CreateNoWindow = true, + WorkingDirectory = DefaultWorkingDir, + }; + } + + private static AccessToken DeserializeOutput(string output) + { + using JsonDocument document = JsonDocument.Parse(output); + + JsonElement root = document.RootElement; + string accessToken = root.GetProperty("token").GetString(); + DateTimeOffset expiresOn = root.GetProperty("expiresOn").GetDateTimeOffset(); + + return new AccessToken(accessToken, expiresOn); + } + } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index e1181e2..a998b28 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,5 +1,6 @@ using System.Net.Mime; using Ardalis.ListStartupServices; +using Azure.Identity; using BlazorAdmin; using BlazorAdmin.Services; using Blazored.LocalStorage; @@ -8,6 +9,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.EntityFrameworkCore; using Microsoft.eShopWeb; using Microsoft.eShopWeb.ApplicationCore.Interfaces; using Microsoft.eShopWeb.Infrastructure.Data; @@ -21,7 +23,19 @@ var builder = WebApplication.CreateBuilder(args); builder.Logging.AddConsole(); -Microsoft.eShopWeb.Infrastructure.Dependencies.ConfigureServices(builder.Configuration, builder.Services); +// Configure SQL Server +var credential = new ChainedTokenCredential(new AzureDeveloperCliCredential(), new DefaultAzureCredential()); +builder.Configuration.AddAzureKeyVault(new Uri(builder.Configuration["AZURE_KEY_VAULT_ENDPOINT"]), credential); +builder.Services.AddDbContext(c => +{ + var connectionString = builder.Configuration[builder.Configuration["AZURE_SQL_CATALOG_CONNECTION_STRING_KEY"]]; + c.UseSqlServer(connectionString, sqlOptions => sqlOptions.EnableRetryOnFailure()); +}); +builder.Services.AddDbContext(options => +{ + var connectionString = builder.Configuration[builder.Configuration["AZURE_SQL_IDENTITY_CONNECTION_STRING_KEY"]]; + options.UseSqlServer(connectionString, sqlOptions => sqlOptions.EnableRetryOnFailure()); +}); builder.Services.AddCookieSettings(); diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index 1d06eb0..6ec695d 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -17,6 +17,8 @@ + +