This commit is contained in:
Sebastian 2021-03-19 16:09:58 +01:00
Родитель 83a286c1b3
Коммит d0722d75b3
11 изменённых файлов: 2001 добавлений и 8 удалений

350
.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,350 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/

103
README.md
Просмотреть файл

@ -1,14 +1,101 @@
# Project
# Teams Live Event Traffic Distributor
> This repo has been populated by an initial template to help get you started. Please
> make sure to update the content to build a great experience for community-building.
## What it is
As the maintainer of this project, please make a few updates:
This solution was built to load balance users between a number of Microsoft Teams Live Events, since one Live Event can only host a couple of thousand clients (current limit of 20K attendees per Live Event stream) - For Live Events that need to go beyond this limit, the recommendation as of now is to publish the produced content across multiple streams, each of them having a different URL. This situation brings some complexity in terms of communication and management of the event as it requires to split the audience in pools of 20K users.
- Improving this README.MD file to provide a great experience
- Updating SUPPORT.MD with content about this project's support experience
- Understanding the security reporting process in SECURITY.MD
- Remove this section from the README
More info on setting-up a Teams Live Event can be found [here](https://docs.microsoft.com/en-us/microsoftteams/teams-live-events/plan-for-teams-live-events).
The following solution covers this challenge by automatically distributing the Live Event URLs to the users from a unique URL. By default, this is the URL of Azure Front Door, e.g. `myliveevent.azurefd.net`, instead of a URL to the Teams Event directly. To achieve this load balancing, API Management is being used to randomly and evenly redirect client requests, using HTTP 302 status code, to one of the URL of the actual Live Event. Also, to manage the capacity and resiliency of the solution, we recommend to have a spare stream - e.g. for 100K participants, you'll need 6 streams (6 x 20K = 120K users max)
Note: The unique URL can be changed by either using a URL shortener like Bitly or also by adding your own custom domain to Front Door (e.g. `myliveevent.contoso.com`) - Check the documentation to [Configure HTTPS on a Front Door custom domain](https://docs.microsoft.com/en-us/azure/frontdoor/front-door-custom-domain-https)
## Azure Components
The solution deploys the following components:
- Azure Front Door for global load balancing and failover
- 2x Azure API Management in Consumption tier, in two different regions for resiliency
- Azure Application Insights including on Azure Portal Dashboard for monitoring
- 2x Azure Storage Accounts with one Table storage each. These are only being used (and only will incur costs) when using the table-storage mode for a high number of backend URLs
<p align="center">
<br>
<img src="./media/Azure-ARM-Template.png" alt="Azure components deployed to redirect users" width="600"/>
<br><br>
<img src="./media/Live-Event-Multi-streams.png" alt="Teams Live Event & multi-streams setup" width="600"/>
<br><br>
<img src="./media/workflow.png" alt="Accessing a multi-stream Teams Live Event" width="300"/>
</p>
## Alternative use cases
While the solution was originally built for Teams Live Events, it can easily be repurposed for any kind of similar load balancing where the backends are hosted on the same domain.
## How to use
### Optional: Make changes and build Bicep
This solution uses [bicep](https://github.com/Azure/bicep) templates which are then compiled into ARM templates. If you wish to make any changes, do so in the bicep templates and compile it. It is not recommended to modify the ARM template (main.json). If you didn't make any changes, you can just use the main.json ARM template file from the repo which was already generated.
### Deploy to Azure via CLI
Create a resource group (change the location based on your needs)
```
az group create -n myresource-group -l northeurope
```
There are three different deployment types available, based on how many backend URLs you need to distribute traffic to. (the reason for this is a length limitation in API Management Policy definitions).
#### Default Policy-based mode - **this mode should be applicable to most users.**
Use this when the list of all your backend URLs is not longer than approx. 14,000 characters. In this case the list of URLs is injected directly into the policy of API Management.
Use this command to deploy the ARM template - replace the **backends** parameter with your individual, comma-separated list of Event URLs and **locationSecondary** based on your preferences.
```
az deployment group create -g myresource-group --template-file .\main.json -p prefix=myprefix -p locationSecondary=westeurope -p loadBalancingMode=default -p backends="https://teams.microsoft.com/l/meetup-join/1,https://teams.microsoft.com/l/meetup-join/2,https://teams.microsoft.com/l/meetup-join/3"
```
#### Table-storage mode - for high number of backend URLs
In this case the URLs must be imported after the ARM template deployment into Table storage accounts and APIM fetches them from there. See below for details on this. \
Use this command to deploy the ARM template - replace the **locationSecondary** parameter based on your preferences. Note that we are not specifying the URLs here yet and instead setting the parameter `loadBalancingMode=largeEvent`.
```
az deployment group create -g myresource-group --template-file .\main.json -p prefix=myprefix -p locationSecondary=westeurope -p loadBalancingMode=largeEvent
```
After the deployment finished, you need to import the list of URLs into both Table storage accounts. Use the `import-urls-to-table.ps1` PowerShell script in the `testing` folder for this purpose.
#### Language-based routing mode - distribute users to different backends based on their browser language (or a query parameter)
To use this more, your list of backends must be formatted different. See the following example.
**Note**: This mode currently only supports a list of URLs that is not longer than approx. 14,000 characters.
Use this command to deploy the ARM template - replace the **backends** parameter with your individual, semicolon-separated list of Event URLs, starting with the language code. Within each language code you can have multiple URLs. In this case, those will be load-balanced as well. There always needs to be at least one URL for English (`en`) as this is used as the fallback option. Also, update **locationSecondary** based on your preferences.
```
az deployment group create -g myresource-group --template-file .\main.json -p prefix=myprefix -p locationSecondary=westeurope -p loadBalancingMode=userLanguage -p backends="de=https://teams.microsoft.com/l/meetup-join/19%3ameeting_GERMAN1;fr=https://teams.microsoft.com/l/meetup-join/19%3ameeting_FRENCH1;en=https://teams.microsoft.com/l/meetup-join/19%3ameeting_ENGLISH1,https://teams.microsoft.com/l/meetup-join/19%3ameeting_ENGLISH2"
```
In this mode, the browser request header `Accept-Language` is being used to determine which URL to use. Alternatively the query parameter `?lang=` can be set, which will then take precedence. For example: [https://{MYPREFIX}globalfrontdoor.azurefd.net?lang=fr]() to force French as the language.
#### Deploy through the Azure Portal
As an alternative to using command line, you can also deploy through the Azure Portal directly and set the parameters accordingly.
[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fsebader%2Fteams-distributor%2Fmain%2Fdeployment%2Fmain.json)
### Test
Your initial URL - if you did not use a custom domain - will be like [https://{MYPREFIX}globalfrontdoor.azurefd.net]()- To test your setup, simply call this URL from a web browser and you should get redirected to one of your backend URLs. Try this a couple of times and you will see different forwarding targets. Note: Based on your browser or proxy settings, it might be that the forward target gets cached. In this case, just open a second browser (or use a private browsing tab).
_Note: After the first deployment it can take a couple of minutes until the Front Door URL goes lives and starts to your traffic_
## Costs
(only provided as an example, as of Feb-2021)
Overall cost for this solution is pretty minimal. The only reoccurring billing (without any incoming traffic), is for the Front Door routing configuration. All other costs are purely based on incoming traffic / usage.
- API Management - Consumption tier: $3.50 per 1 million calls. And the first 1 million calls per Azure subscription are free. [Pricing](https://azure.microsoft.com/en-us/pricing/details/api-management/)
- Front Door: $0.01 per GB incoming traffic, $0.17 per GB response traffic (Zone 1), $22 per month for the two routing rules. [Pricing](https://azure.microsoft.com/en-us/pricing/details/frontdoor/)
- Application Insights: $2.88 per GB ingested data - and the first 5 GB per billing account are included per month. [Pricing](https://azure.microsoft.com/en-us/pricing/details/monitor/)
The ARM template allows you to deploy the Azure services only for the preparation and duration of the Live Event. With a typical timeframe of 5 days and 100K attendees, the estimated cost of the solution is less than $5.
## Contributing

335
deployment/main.bicep Normal file
Просмотреть файл

@ -0,0 +1,335 @@
@description('Prefix for all resources to create uniqueness')
param prefix string
@description('Region of the second API Management instance. Needs to be different than the location of the resource group which is being used as the primary location. Must support APIM Consumption tier.')
param locationSecondary string
@allowed([
'default'
'userLanguage'
'largeEvent'
])
@description('Which Load Balancing (LB) mode to use. Default: Random LB with a list of URL that does not exceed 15,000 characters. userLanguage: LB based on user browser language. largeEvent: Random LB with a list of URL that exceedss 15,000 characters (many, long URLs).')
param loadBalancingMode string = 'default'
@description('Leave blank if you set the parameter loadBalancingMode to largeEvent. Otherwise: If mode=default: Comma-separated list of backend URLs to which incoming requests will be forwarded to in a random fashion. For example like: https://teams.microsoft.com/l/meetup-join/1,https://teams.microsoft.com/l/meetup-join/2 If mode=userLanguage: List of backend URLs, split by language to which incoming requests will be forwarded based on their browser language and, if there are multiple links per language in a random fashion. Uses English as the fallback. For example like: de=https://teams.microsoft.com/l/meetup-join/19%3ameeting_GERMAN1;fr=https://teams.microsoft.com/l/meetup-join/19%3ameeting_FRENCH1;en=https://teams.microsoft.com/l/meetup-join/19%3ameeting_ENGLISH1,https://teams.microsoft.com/l/meetup-join/19%3ameeting_ENGLISH2')
param backends string = ''
@description('API Management Publisher Name')
param apimPublisherName string = 'Contoso Admin'
@description('API Management Publisher Email Address')
param apimPublisherEmail string = 'noreply@contoso.com'
@description('No need to change. ID to be added to the deployment names, such as the run ID of a pipeline. Default to UTC-now timestamp')
param deploymentId string = utcNow()
var location = resourceGroup().location
var frontDoorName = '${prefix}globalfrontdoor'
var frontdoor_default_dns_name = '${frontDoorName}.azurefd.net'
resource appinsights 'Microsoft.Insights/components@2018-05-01-preview' = {
name: '${prefix}appinsights'
location: location
kind: 'web'
properties: {
Application_Type: 'web'
}
}
var regions = [
location
locationSecondary
]
module apim 'module_apim.bicep' = [for region in regions: {
name: 'apim-${region}-${deploymentId}'
params: {
applicationInsightsName: appinsights.name
location: region
backends: backends
prefix: prefix
publisherEmail: apimPublisherEmail
publisherName: apimPublisherName
loadBalancingMode: loadBalancingMode
}
}]
resource frontdoor 'Microsoft.Network/frontDoors@2020-05-01' = {
name: frontDoorName
location: 'Global'
properties: {
backendPools: [
{
name: 'BackendAPIMs'
properties: {
backends: [for index in range(0, length(regions)): {
address: apim[index].outputs.apimHostname
backendHostHeader: apim[index].outputs.apimHostname
httpPort: 80
httpsPort: 443
priority: 1
weight: 50
}]
healthProbeSettings: {
id: '${resourceId('Microsoft.Network/frontDoors', frontDoorName)}/healthProbeSettings/HealthProbeSetting'
}
loadBalancingSettings: {
id: '${resourceId('Microsoft.Network/frontDoors', frontDoorName)}/loadBalancingSettings/LoadBalancingSettings'
}
}
}
]
frontendEndpoints: [
{
name: 'DefaultFrontendEndpoint'
properties: {
hostName: frontdoor_default_dns_name
sessionAffinityEnabledState: 'Disabled'
}
}
/*
// Enable this if you have a custom domain name available
{
name: 'CustomDomainFrontendEndpoint'
properties: {
hostName: customDomainName_frontdoor
sessionAffinityEnabledState: 'Disabled'
}
}
*/
]
routingRules: [
{
name: 'HTTPSRedirect'
properties: {
acceptedProtocols: [
'Http'
]
patternsToMatch: [
'/*'
]
routeConfiguration: {
'@odata.type': '#Microsoft.Azure.FrontDoor.Models.FrontdoorRedirectConfiguration'
redirectProtocol: 'HttpsOnly'
redirectType: 'Moved'
}
frontendEndpoints: [
{
id: '${resourceId('Microsoft.Network/frontDoors', frontDoorName)}/frontendEndpoints/DefaultFrontendEndpoint'
}
]
}
}
{
name: 'DefaultBackendForwardRule'
properties: {
acceptedProtocols: [
'Https'
]
patternsToMatch: [
'/*'
]
routeConfiguration: {
'@odata.type': '#Microsoft.Azure.FrontDoor.Models.FrontdoorForwardingConfiguration'
backendPool: {
id: '${resourceId('Microsoft.Network/frontDoors', frontDoorName)}/backendPools/BackendAPIMs'
}
forwardingProtocol: 'HttpsOnly'
}
frontendEndpoints: [
{
id: '${resourceId('Microsoft.Network/frontDoors', frontDoorName)}/frontendEndpoints/DefaultFrontendEndpoint'
}
]
}
}
]
healthProbeSettings: [
{
name: 'HealthProbeSetting'
properties: {
healthProbeMethod: 'HEAD'
path: '/healthz'
protocol: 'Https'
intervalInSeconds: 30
}
}
]
loadBalancingSettings: [
{
name: 'LoadBalancingSettings'
properties: {
additionalLatencyMilliseconds: 500
sampleSize: 4
successfulSamplesRequired: 2
}
}
]
}
}
resource dashboard 'Microsoft.Portal/dashboards@2015-08-01-preview' = {
name: guid(resourceGroup().name, prefix, 'dashboard')
location: location
tags: {
'hidden-title': 'Teams Distributor Statistics'
}
properties: {
lenses: {
'0': {
order: 0
parts: {
'0': {
position: {
colSpan: 10
rowSpan: 5
x: 0
y: 0
}
metadata: {
type: 'Extension/Microsoft_OperationsManagementSuite_Workspace/PartType/LogsDashboardPart'
inputs: [
{
name: 'Scope'
value: {
resourceIds: [
appinsights.id
]
}
}
{
name: 'Dimensions'
value: {
xAxis: {
name: 'timestamp'
type: 'datetime'
}
yAxis: [
{
name: 'Number of Requests'
type: 'long'
}
]
splitBy: [
{
name: 'Backend'
type: 'string'
}
]
aggregation: 'Sum'
}
}
{
name: 'PartId'
value: guid(resourceGroup().name, 'part0')
}
{
name: 'Version'
value: '2.0'
}
{
name: 'TimeRange'
value: 'PT30M'
}
{
name: 'Query'
value: 'set query_bin_auto_size=5m;\r\nrequests\r\n| extend Backend=tostring(customDimensions[\'Response-location\'])\r\n| where Backend != ""\r\n| summarize [\'Number of Requests\']=count() by Backend, bin_auto(timestamp)\r\n| render areachart'
}
{
name: 'PartTitle'
value: 'Forwarded Requests per Backend'
}
{
name: 'PartSubTitle'
value: 'On 5-Minute aggregation'
}
{
name: 'ControlType'
value: 'FrameControlChart'
}
{
name: 'SpecificChart'
value: 'StackedArea'
}
]
}
}
'1': {
position: {
colSpan: 6
rowSpan: 5
x: 10
y: 0
}
metadata: {
type: 'Extension/Microsoft_OperationsManagementSuite_Workspace/PartType/LogsDashboardPart'
inputs: [
{
name: 'Scope'
value: {
resourceIds: [
appinsights.id
]
}
}
{
name: 'Dimensions'
value: {
xAxis: {
name: 'Region'
type: 'string'
}
yAxis: [
{
name: 'Count'
type: 'long'
}
]
splitBy: []
aggregation: 'Sum'
}
}
{
name: 'PartId'
value: guid(resourceGroup().name, 'part1')
}
{
name: 'Version'
value: '2.0'
}
{
name: 'TimeRange'
value: 'PT30M'
}
{
name: 'Query'
value: 'requests\r\n| summarize Count=count() by Region=tostring(customDimensions.Region)\r\n| render piechart'
}
{
name: 'PartTitle'
value: 'Handled requests per APIM Region'
}
{
name: 'PartSubTitle'
value: 'As load-balanced by Front Door'
}
{
name: 'ControlType'
value: 'FrameControlChart'
}
{
name: 'SpecificChart'
value: 'Pie'
}
]
}
}
}
}
}
}
}
output frontDoorUrl string = frontdoor_default_dns_name

755
deployment/main.json Normal file
Просмотреть файл

@ -0,0 +1,755 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"prefix": {
"type": "string",
"metadata": {
"description": "Prefix for all resources to create uniqueness"
}
},
"locationSecondary": {
"type": "string",
"metadata": {
"description": "Region of the second API Management instance. Needs to be different than the location of the resource group which is being used as the primary location. Must support APIM Consumption tier."
}
},
"loadBalancingMode": {
"type": "string",
"defaultValue": "default",
"metadata": {
"description": "Which Load Balancing (LB) mode to use. Default: Random LB with a list of URL that does not exceed 15,000 characters. userLanguage: LB based on user browser language. largeEvent: Random LB with a list of URL that exceedss 15,000 characters (many, long URLs)."
},
"allowedValues": [
"default",
"userLanguage",
"largeEvent"
]
},
"backends": {
"type": "string",
"defaultValue": "",
"metadata": {
"description": "Leave blank if you set the parameter loadBalancingMode to largeEvent. Otherwise: If mode=default: Comma-separated list of backend URLs to which incoming requests will be forwarded to in a random fashion. For example like: https://teams.microsoft.com/l/meetup-join/1,https://teams.microsoft.com/l/meetup-join/2 If mode=userLanguage: List of backend URLs, split by language to which incoming requests will be forwarded based on their browser language and, if there are multiple links per language in a random fashion. Uses English as the fallback. For example like: de=https://teams.microsoft.com/l/meetup-join/19%3ameeting_GERMAN1;fr=https://teams.microsoft.com/l/meetup-join/19%3ameeting_FRENCH1;en=https://teams.microsoft.com/l/meetup-join/19%3ameeting_ENGLISH1,https://teams.microsoft.com/l/meetup-join/19%3ameeting_ENGLISH2"
}
},
"apimPublisherName": {
"type": "string",
"defaultValue": "Contoso Admin",
"metadata": {
"description": "API Management Publisher Name"
}
},
"apimPublisherEmail": {
"type": "string",
"defaultValue": "noreply@contoso.com",
"metadata": {
"description": "API Management Publisher Email Address"
}
},
"deploymentId": {
"type": "string",
"defaultValue": "[utcNow()]",
"metadata": {
"description": "No need to change. ID to be added to the deployment names, such as the run ID of a pipeline. Default to UTC-now timestamp"
}
}
},
"functions": [],
"variables": {
"location": "[resourceGroup().location]",
"frontDoorName": "[format('{0}globalfrontdoor', parameters('prefix'))]",
"frontdoor_default_dns_name": "[format('{0}.azurefd.net', variables('frontDoorName'))]",
"regions": [
"[variables('location')]",
"[parameters('locationSecondary')]"
]
},
"resources": [
{
"type": "Microsoft.Insights/components",
"apiVersion": "2018-05-01-preview",
"name": "[format('{0}appinsights', parameters('prefix'))]",
"location": "[variables('location')]",
"kind": "web",
"properties": {
"Application_Type": "web"
}
},
{
"type": "Microsoft.Network/frontDoors",
"apiVersion": "2020-05-01",
"name": "[variables('frontDoorName')]",
"location": "Global",
"properties": {
"backendPools": [
{
"name": "BackendAPIMs",
"properties": {
"copy": [
{
"name": "backends",
"count": "[length(range(0, length(variables('regions'))))]",
"input": {
"address": "[reference(resourceId('Microsoft.Resources/deployments', format('apim-{0}-{1}', variables('regions')[range(0, length(variables('regions')))[copyIndex('backends')]], parameters('deploymentId'))), '2019-10-01').outputs.apimHostname.value]",
"backendHostHeader": "[reference(resourceId('Microsoft.Resources/deployments', format('apim-{0}-{1}', variables('regions')[range(0, length(variables('regions')))[copyIndex('backends')]], parameters('deploymentId'))), '2019-10-01').outputs.apimHostname.value]",
"httpPort": 80,
"httpsPort": 443,
"priority": 1,
"weight": 50
}
}
],
"healthProbeSettings": {
"id": "[format('{0}/healthProbeSettings/HealthProbeSetting', resourceId('Microsoft.Network/frontDoors', variables('frontDoorName')))]"
},
"loadBalancingSettings": {
"id": "[format('{0}/loadBalancingSettings/LoadBalancingSettings', resourceId('Microsoft.Network/frontDoors', variables('frontDoorName')))]"
}
}
}
],
"frontendEndpoints": [
{
"name": "DefaultFrontendEndpoint",
"properties": {
"hostName": "[variables('frontdoor_default_dns_name')]",
"sessionAffinityEnabledState": "Disabled"
}
}
],
"routingRules": [
{
"name": "HTTPSRedirect",
"properties": {
"acceptedProtocols": [
"Http"
],
"patternsToMatch": [
"/*"
],
"routeConfiguration": {
"@odata.type": "#Microsoft.Azure.FrontDoor.Models.FrontdoorRedirectConfiguration",
"redirectProtocol": "HttpsOnly",
"redirectType": "Moved"
},
"frontendEndpoints": [
{
"id": "[format('{0}/frontendEndpoints/DefaultFrontendEndpoint', resourceId('Microsoft.Network/frontDoors', variables('frontDoorName')))]"
}
]
}
},
{
"name": "DefaultBackendForwardRule",
"properties": {
"acceptedProtocols": [
"Https"
],
"patternsToMatch": [
"/*"
],
"routeConfiguration": {
"@odata.type": "#Microsoft.Azure.FrontDoor.Models.FrontdoorForwardingConfiguration",
"backendPool": {
"id": "[format('{0}/backendPools/BackendAPIMs', resourceId('Microsoft.Network/frontDoors', variables('frontDoorName')))]"
},
"forwardingProtocol": "HttpsOnly"
},
"frontendEndpoints": [
{
"id": "[format('{0}/frontendEndpoints/DefaultFrontendEndpoint', resourceId('Microsoft.Network/frontDoors', variables('frontDoorName')))]"
}
]
}
}
],
"healthProbeSettings": [
{
"name": "HealthProbeSetting",
"properties": {
"healthProbeMethod": "HEAD",
"path": "/healthz",
"protocol": "Https",
"intervalInSeconds": 30
}
}
],
"loadBalancingSettings": [
{
"name": "LoadBalancingSettings",
"properties": {
"additionalLatencyMilliseconds": 500,
"sampleSize": 4,
"successfulSamplesRequired": 2
}
}
]
},
"dependsOn": [
"apim"
]
},
{
"type": "Microsoft.Portal/dashboards",
"apiVersion": "2015-08-01-preview",
"name": "[guid(resourceGroup().name, parameters('prefix'), 'dashboard')]",
"location": "[variables('location')]",
"tags": {
"hidden-title": "Teams Distributor Statistics"
},
"properties": {
"lenses": {
"0": {
"order": 0,
"parts": {
"0": {
"position": {
"colSpan": 10,
"rowSpan": 5,
"x": 0,
"y": 0
},
"metadata": {
"type": "Extension/Microsoft_OperationsManagementSuite_Workspace/PartType/LogsDashboardPart",
"inputs": [
{
"name": "Scope",
"value": {
"resourceIds": [
"[resourceId('Microsoft.Insights/components', format('{0}appinsights', parameters('prefix')))]"
]
}
},
{
"name": "Dimensions",
"value": {
"xAxis": {
"name": "timestamp",
"type": "datetime"
},
"yAxis": [
{
"name": "Number of Requests",
"type": "long"
}
],
"splitBy": [
{
"name": "Backend",
"type": "string"
}
],
"aggregation": "Sum"
}
},
{
"name": "PartId",
"value": "[guid(resourceGroup().name, 'part0')]"
},
{
"name": "Version",
"value": "2.0"
},
{
"name": "TimeRange",
"value": "PT30M"
},
{
"name": "Query",
"value": "set query_bin_auto_size=5m;\r\nrequests\r\n| extend Backend=tostring(customDimensions['Response-location'])\r\n| where Backend != \"\"\r\n| summarize ['Number of Requests']=count() by Backend, bin_auto(timestamp)\r\n| render areachart"
},
{
"name": "PartTitle",
"value": "Forwarded Requests per Backend"
},
{
"name": "PartSubTitle",
"value": "On 5-Minute aggregation"
},
{
"name": "ControlType",
"value": "FrameControlChart"
},
{
"name": "SpecificChart",
"value": "StackedArea"
}
]
}
},
"1": {
"position": {
"colSpan": 6,
"rowSpan": 5,
"x": 10,
"y": 0
},
"metadata": {
"type": "Extension/Microsoft_OperationsManagementSuite_Workspace/PartType/LogsDashboardPart",
"inputs": [
{
"name": "Scope",
"value": {
"resourceIds": [
"[resourceId('Microsoft.Insights/components', format('{0}appinsights', parameters('prefix')))]"
]
}
},
{
"name": "Dimensions",
"value": {
"xAxis": {
"name": "Region",
"type": "string"
},
"yAxis": [
{
"name": "Count",
"type": "long"
}
],
"splitBy": [],
"aggregation": "Sum"
}
},
{
"name": "PartId",
"value": "[guid(resourceGroup().name, 'part1')]"
},
{
"name": "Version",
"value": "2.0"
},
{
"name": "TimeRange",
"value": "PT30M"
},
{
"name": "Query",
"value": "requests\r\n| summarize Count=count() by Region=tostring(customDimensions.Region)\r\n| render piechart"
},
{
"name": "PartTitle",
"value": "Handled requests per APIM Region"
},
{
"name": "PartSubTitle",
"value": "As load-balanced by Front Door"
},
{
"name": "ControlType",
"value": "FrameControlChart"
},
{
"name": "SpecificChart",
"value": "Pie"
}
]
}
}
}
}
}
},
"dependsOn": [
"[resourceId('Microsoft.Insights/components', format('{0}appinsights', parameters('prefix')))]"
]
},
{
"copy": {
"name": "apim",
"count": "[length(variables('regions'))]"
},
"type": "Microsoft.Resources/deployments",
"apiVersion": "2019-10-01",
"name": "[format('apim-{0}-{1}', variables('regions')[copyIndex()], parameters('deploymentId'))]",
"properties": {
"expressionEvaluationOptions": {
"scope": "inner"
},
"mode": "Incremental",
"parameters": {
"applicationInsightsName": {
"value": "[format('{0}appinsights', parameters('prefix'))]"
},
"location": {
"value": "[variables('regions')[copyIndex()]]"
},
"backends": {
"value": "[parameters('backends')]"
},
"prefix": {
"value": "[parameters('prefix')]"
},
"publisherEmail": {
"value": "[parameters('apimPublisherEmail')]"
},
"publisherName": {
"value": "[parameters('apimPublisherName')]"
},
"loadBalancingMode": {
"value": "[parameters('loadBalancingMode')]"
}
},
"template": {
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"prefix": {
"type": "string"
},
"location": {
"type": "string"
},
"publisherEmail": {
"type": "string"
},
"publisherName": {
"type": "string"
},
"backends": {
"type": "string",
"defaultValue": ""
},
"loadBalancingMode": {
"type": "string",
"defaultValue": "default",
"allowedValues": [
"default",
"userLanguage",
"largeEvent"
]
},
"applicationInsightsName": {
"type": "string"
},
"sasTokenStart": {
"type": "string",
"defaultValue": "[utcNow('yyyy-MM-ddTHH:mm:ssZ')]"
},
"sasTokenExpiry": {
"type": "string",
"defaultValue": "[dateTimeAdd(utcNow('u'), 'P2Y', 'yyyy-MM-ddTHH:mm:ssZ')]"
}
},
"functions": [],
"variables": {
"useDefaultLb": "[equals(parameters('loadBalancingMode'), 'default')]",
"useTableStorage": "[equals(parameters('loadBalancingMode'), 'largeEvent')]",
"useLanguageRouter": "[equals(parameters('loadBalancingMode'), 'userLanguage')]",
"tableName": "Urls",
"accountSasProperties": {
"signedServices": "t",
"signedPermission": "rl",
"signedResourceTypes": "o",
"signedStart": "[parameters('sasTokenStart')]",
"signedExpiry": "[parameters('sasTokenExpiry')]"
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2019-06-01",
"name": "[format('stg{0}{1}', take(parameters('location'), 8), uniqueString(parameters('prefix'), parameters('location'), 'stg'))]",
"location": "[parameters('location')]",
"kind": "StorageV2",
"sku": {
"name": "Standard_ZRS",
"tier": "Standard"
},
"properties": {
"supportsHttpsTrafficOnly": true
}
},
{
"condition": "[variables('useTableStorage')]",
"type": "Microsoft.Storage/storageAccounts/tableServices/tables",
"apiVersion": "2019-06-01",
"name": "[format('{0}/default/{1}', format('stg{0}{1}', take(parameters('location'), 8), uniqueString(parameters('prefix'), parameters('location'), 'stg')), variables('tableName'))]",
"dependsOn": [
"[resourceId('Microsoft.Storage/storageAccounts', format('stg{0}{1}', take(parameters('location'), 8), uniqueString(parameters('prefix'), parameters('location'), 'stg')))]"
]
},
{
"type": "Microsoft.ApiManagement/service",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}{1}apim', parameters('prefix'), parameters('location'))]",
"location": "[parameters('location')]",
"sku": {
"name": "Consumption",
"capacity": 0
},
"properties": {
"publisherEmail": "[parameters('publisherEmail')]",
"publisherName": "[parameters('publisherName')]",
"customProperties": {
"Microsoft.WindowsAzure.ApiManagement.Gateway.Protocols.Server.Http2": "true"
}
}
},
{
"type": "Microsoft.ApiManagement/service/apis",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"properties": {
"apiRevision": "1",
"displayName": "GetBackend",
"subscriptionRequired": false,
"protocols": [
"https"
],
"path": ""
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))]"
]
},
{
"type": "Microsoft.ApiManagement/service/apis",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"properties": {
"apiRevision": "1",
"displayName": "APIM Healthz",
"subscriptionRequired": false,
"protocols": [
"https"
],
"path": "healthz"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))]"
]
},
{
"condition": "[variables('useDefaultLb')]",
"type": "Microsoft.ApiManagement/service/apis/operations",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/getbackendfrompolicy', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))))]",
"properties": {
"displayName": "Get Backend From Policy",
"method": "GET",
"urlTemplate": "/"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/apis', split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]"
]
},
{
"condition": "[variables('useTableStorage')]",
"type": "Microsoft.ApiManagement/service/apis/operations",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/getbackendfromtable', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))))]",
"properties": {
"displayName": "Get Backend From Table Storage",
"method": "GET",
"urlTemplate": "/"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/apis', split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]"
]
},
{
"condition": "[variables('useLanguageRouter')]",
"type": "Microsoft.ApiManagement/service/apis/operations",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/getbackendbylanguage', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))))]",
"properties": {
"displayName": "Get Backend by User Language",
"method": "GET",
"urlTemplate": "/"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/apis', split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]"
]
},
{
"type": "Microsoft.ApiManagement/service/apis/operations",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/apimhealth', format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location'))))]",
"properties": {
"displayName": "ApimHealth",
"method": "HEAD",
"urlTemplate": "/"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/apis', split(format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]"
]
},
{
"condition": "[variables('useDefaultLb')]",
"type": "Microsoft.ApiManagement/service/apis/operations/policies",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/policy', format('{0}/getbackendfrompolicy', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))))]",
"properties": {
"format": "xml",
"value": "<policies>\r\n <inbound>\r\n <base />\r\n <return-response>\r\n <set-status code=\"302\" />\r\n <set-header name=\"X-Apim-Region\" exists-action=\"override\">\r\n <value>@(context.Deployment.Region)</value>\r\n </set-header>\r\n <set-header name=\"Location\" exists-action=\"override\">\r\n <value>@{\r\n var backends = \"{{backend-urls}}\".Split(',');\r\n var i = new Random(context.RequestId.GetHashCode()).Next(0, backends.Length);\r\n return backends[i];\r\n }</value>\r\n </set-header>\r\n </return-response>\r\n </inbound>\r\n <backend>\r\n <base />\r\n </backend>\r\n <outbound>\r\n <base />\r\n </outbound>\r\n <on-error>\r\n <base />\r\n </on-error>\r\n</policies>"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/namedValues', split(format('{0}/backend-urls', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/backend-urls', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]",
"[resourceId('Microsoft.ApiManagement/service/apis/operations', split(format('{0}/getbackendfrompolicy', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[0], split(format('{0}/getbackendfrompolicy', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[1], split(format('{0}/getbackendfrompolicy', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[2])]"
]
},
{
"condition": "[variables('useLanguageRouter')]",
"type": "Microsoft.ApiManagement/service/apis/operations/policies",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/policy', format('{0}/getbackendbylanguage', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))))]",
"properties": {
"format": "xml",
"value": "<policies>\r\n <inbound>\r\n <base />\r\n <return-response>\r\n <set-status code=\"302\" />\r\n <set-header name=\"X-Apim-Region\" exists-action=\"override\">\r\n <value>@(context.Deployment.Region)</value>\r\n </set-header>\r\n <set-header name=\"Location\" exists-action=\"override\">\r\n <value>@{\r\n var dict = \"{{backend-urls}}\".Split(';').Select(item => item.Split('=')).ToDictionary(s => s[0], s => s[1].Split(','));\r\n\r\n string[] languageUrls;\r\n // If the query parameter \"lang\" is present and the requested language exists in the list, we use that. Otherwise we use the header Accept-Language\r\n var langQueryParam = context.Request.OriginalUrl.Query.GetValueOrDefault(\"lang\", \"\").ToLower();\r\n if(!dict.TryGetValue(langQueryParam, out languageUrls))\r\n {\r\n var userLanguage = context.Request.Headers.GetValueOrDefault(\"Accept-Language\", \"en\");\r\n // Sample values for Accept-Language: \"Accept-Language: de\" \"Accept-Language: de-CH\" \"Accept-Language: en-US,en;q=0.5\"\r\n if(userLanguage.Contains(\",\"))\r\n {\r\n userLanguage = userLanguage.Split(',')[0];\r\n }\r\n if(userLanguage.Contains(\"-\"))\r\n {\r\n userLanguage = userLanguage.Split('-')[0];\r\n }\r\n // If the language from the header exists in the list, we use that, otherwise we default to English\r\n languageUrls = dict.ContainsKey(userLanguage) ? dict[userLanguage] : dict[\"en\"];\r\n }\r\n // If the list only contains more than one entry for the selected language, we pick at random\r\n var selectedUrl = languageUrls.Length == 0 ? languageUrls[0] : languageUrls[new Random(context.RequestId.GetHashCode()).Next(0, languageUrls.Length)];\r\n return selectedUrl;\r\n }</value>\r\n </set-header>\r\n </return-response>\r\n </inbound>\r\n <backend>\r\n <base />\r\n </backend>\r\n <outbound>\r\n <base />\r\n </outbound>\r\n <on-error>\r\n <base />\r\n </on-error>\r\n</policies>"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/namedValues', split(format('{0}/backend-urls', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/backend-urls', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]",
"[resourceId('Microsoft.ApiManagement/service/apis/operations', split(format('{0}/getbackendbylanguage', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[0], split(format('{0}/getbackendbylanguage', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[1], split(format('{0}/getbackendbylanguage', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[2])]"
]
},
{
"condition": "[variables('useTableStorage')]",
"type": "Microsoft.ApiManagement/service/apis/operations/policies",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/policy', format('{0}/getbackendfromtable', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))))]",
"properties": {
"format": "xml",
"value": "<policies>\r\n <inbound>\r\n <base />\r\n <!-- Make outbound request to the table API which holds the list of backend URLs -->\r\n <send-request mode=\"new\" response-variable-name=\"tableApiResponse\" timeout=\"20\" ignore-error=\"true\">\r\n <set-url>@(\"{{table-url}}?{{table-sas-token}}&$select=url\")</set-url>\r\n <set-method>GET</set-method>\r\n <set-header name=\"Accept\" exists-action=\"override\">\r\n <value>application/json;odata=nometadata</value>\r\n </set-header>\r\n </send-request>\r\n <set-method>GET</set-method>\r\n <return-response>\r\n <set-status code=\"302\" />\r\n <set-header name=\"X-Apim-Region\" exists-action=\"override\">\r\n <value>@(context.Deployment.Region)</value>\r\n </set-header>\r\n <set-header name=\"Location\" exists-action=\"override\">\r\n <value>@{\r\n try\r\n {\r\n var urls = ((IResponse) context.Variables[\"tableApiResponse\"]).Body.As<JObject>()[\"value\"];\r\n // Generate random rowKey\r\n var rowKey = new Random(context.RequestId.GetHashCode()).Next(0, urls.Count());\r\n return (string)urls[rowKey][\"url\"];\r\n }\r\n catch (Exception e)\r\n {\r\n // If something failed, it is usually because of an transient error. Then we just send the user to the same URL again to retry.\r\n return \"/\";\r\n }\r\n }</value>\r\n </set-header>\r\n </return-response>\r\n </inbound>\r\n <backend>\r\n <base />\r\n </backend>\r\n <outbound>\r\n <base />\r\n </outbound>\r\n <on-error>\r\n <base />\r\n </on-error>\r\n</policies>"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/namedValues', split(format('{0}/table-sas-token', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/table-sas-token', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]",
"[resourceId('Microsoft.ApiManagement/service/namedValues', split(format('{0}/table-url', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/table-url', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]",
"[resourceId('Microsoft.ApiManagement/service/apis/operations', split(format('{0}/getbackendfromtable', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[0], split(format('{0}/getbackendfromtable', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[1], split(format('{0}/getbackendfromtable', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[2])]"
]
},
{
"type": "Microsoft.ApiManagement/service/apis/operations/policies",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/policy', format('{0}/apimhealth', format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location')))))]",
"properties": {
"format": "xml",
"value": "<policies>\r\n <inbound>\r\n <base />\r\n <return-response>\r\n <set-status code=\"200\" />\r\n </return-response>\r\n </inbound>\r\n <backend>\r\n <base />\r\n </backend>\r\n <outbound>\r\n <base />\r\n </outbound>\r\n <on-error>\r\n <base />\r\n </on-error>\r\n</policies>"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/apis/operations', split(format('{0}/apimhealth', format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[0], split(format('{0}/apimhealth', format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[1], split(format('{0}/apimhealth', format('{0}/apimhealthz', format('{0}{1}apim', parameters('prefix'), parameters('location')))), '/')[2])]"
]
},
{
"condition": "[or(variables('useDefaultLb'), variables('useLanguageRouter'))]",
"type": "Microsoft.ApiManagement/service/namedValues",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/backend-urls', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"properties": {
"displayName": "backend-urls",
"value": "[parameters('backends')]"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))]"
]
},
{
"condition": "[variables('useTableStorage')]",
"type": "Microsoft.ApiManagement/service/namedValues",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/table-url', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"properties": {
"displayName": "table-url",
"value": "[format('{0}{1}', reference(resourceId('Microsoft.Storage/storageAccounts', format('stg{0}{1}', take(parameters('location'), 8), uniqueString(parameters('prefix'), parameters('location'), 'stg')))).primaryEndpoints.table, variables('tableName'))]"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"[resourceId('Microsoft.Storage/storageAccounts', format('stg{0}{1}', take(parameters('location'), 8), uniqueString(parameters('prefix'), parameters('location'), 'stg')))]"
]
},
{
"condition": "[variables('useTableStorage')]",
"type": "Microsoft.ApiManagement/service/namedValues",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/table-sas-token', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"properties": {
"displayName": "table-sas-token",
"value": "[listAccountSas(format('stg{0}{1}', take(parameters('location'), 8), uniqueString(parameters('prefix'), parameters('location'), 'stg')), '2019-06-01', variables('accountSasProperties')).accountSasToken]",
"secret": true
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"[resourceId('Microsoft.Storage/storageAccounts', format('stg{0}{1}', take(parameters('location'), 8), uniqueString(parameters('prefix'), parameters('location'), 'stg')))]"
]
},
{
"type": "Microsoft.ApiManagement/service/namedValues",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/logger-credentials', format('{0}{1}apim', parameters('prefix'), parameters('location')))]",
"properties": {
"displayName": "logger-credentials",
"value": "[reference(resourceId('Microsoft.Insights/components', parameters('applicationInsightsName')), '2018-05-01-preview').InstrumentationKey]",
"secret": true
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))]"
]
},
{
"type": "Microsoft.ApiManagement/service/loggers",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/{1}', format('{0}{1}apim', parameters('prefix'), parameters('location')), parameters('applicationInsightsName'))]",
"properties": {
"loggerType": "applicationInsights",
"credentials": {
"instrumentationKey": "{{logger-credentials}}"
},
"isBuffered": true,
"resourceId": "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]"
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))]"
]
},
{
"type": "Microsoft.ApiManagement/service/apis/diagnostics",
"apiVersion": "2020-06-01-preview",
"name": "[format('{0}/applicationinsights', format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))))]",
"properties": {
"alwaysLog": "allErrors",
"httpCorrelationProtocol": "W3C",
"verbosity": "information",
"loggerId": "[resourceId('Microsoft.ApiManagement/service/loggers', split(format('{0}/{1}', format('{0}{1}apim', parameters('prefix'), parameters('location')), parameters('applicationInsightsName')), '/')[0], split(format('{0}/{1}', format('{0}{1}apim', parameters('prefix'), parameters('location')), parameters('applicationInsightsName')), '/')[1])]",
"frontend": {
"response": {
"headers": [
"location"
]
}
}
},
"dependsOn": [
"[resourceId('Microsoft.ApiManagement/service/apis', split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[0], split(format('{0}/getbackend', format('{0}{1}apim', parameters('prefix'), parameters('location'))), '/')[1])]",
"[resourceId('Microsoft.ApiManagement/service/loggers', split(format('{0}/{1}', format('{0}{1}apim', parameters('prefix'), parameters('location')), parameters('applicationInsightsName')), '/')[0], split(format('{0}/{1}', format('{0}{1}apim', parameters('prefix'), parameters('location')), parameters('applicationInsightsName')), '/')[1])]"
]
}
],
"outputs": {
"apimHostname": {
"type": "string",
"value": "[replace(reference(resourceId('Microsoft.ApiManagement/service', format('{0}{1}apim', parameters('prefix'), parameters('location')))).gatewayUrl, 'https://', '')]"
}
}
}
},
"dependsOn": [
"[resourceId('Microsoft.Insights/components', format('{0}appinsights', parameters('prefix')))]"
]
}
],
"outputs": {
"frontDoorUrl": {
"type": "string",
"value": "[variables('frontdoor_default_dns_name')]"
}
},
"metadata": {
"_generator": {
"name": "bicep",
"version": "0.3.104.52030",
"templateHash": "5962523844245559369"
}
}
}

Просмотреть файл

@ -0,0 +1,388 @@
param prefix string
param location string
param publisherEmail string
param publisherName string
param backends string = ''
@allowed([
'default'
'userLanguage'
'largeEvent'
])
param loadBalancingMode string = 'default'
param applicationInsightsName string
param sasTokenStart string = utcNow('yyyy-MM-ddTHH:mm:ssZ')
param sasTokenExpiry string = dateTimeAdd(utcNow('u'), 'P2Y', 'yyyy-MM-ddTHH:mm:ssZ') // Expries in 2 years from deployment time
var useDefaultLb = loadBalancingMode == 'default'
var useTableStorage = loadBalancingMode == 'largeEvent'
var useLanguageRouter = loadBalancingMode == 'userLanguage'
// Because of some issue in bicep/ARM using "if (useTableStorage)" on this resource breaks other dependencies. Thus, we always provision the storage account, even if it is not being used when not running in Table-storage mode.
resource storageAccount 'Microsoft.Storage/storageAccounts@2019-06-01' = {
name: 'stg${take(location, 8)}${uniqueString(prefix, location, 'stg')}'
location: location
kind: 'StorageV2'
sku: {
name: 'Standard_ZRS'
tier: 'Standard'
}
properties: {
supportsHttpsTrafficOnly: true
}
}
var tableName = 'Urls'
resource table 'Microsoft.Storage/storageAccounts/tableServices/tables@2019-06-01' = if (useTableStorage) {
name: '${storageAccount.name}/default/${tableName}'
}
resource apim 'Microsoft.ApiManagement/service@2020-06-01-preview' = {
name: '${prefix}${location}apim'
location: location
sku: {
name: 'Consumption'
capacity: 0
}
properties: {
publisherEmail: publisherEmail
publisherName: publisherName
customProperties: {
'Microsoft.WindowsAzure.ApiManagement.Gateway.Protocols.Server.Http2': 'true'
}
}
}
resource apiGetbackend 'Microsoft.ApiManagement/service/apis@2020-06-01-preview' = {
name: '${apim.name}/getbackend'
properties: {
apiRevision: '1'
displayName: 'GetBackend'
subscriptionRequired: false
protocols: [
'https'
]
path: ''
}
}
resource apiHealthz 'Microsoft.ApiManagement/service/apis@2020-06-01-preview' = {
name: '${apim.name}/apimhealthz'
properties: {
apiRevision: '1'
displayName: 'APIM Healthz'
subscriptionRequired: false
protocols: [
'https'
]
path: 'healthz'
}
}
// Based on the parameter useTableStorage either this operation is being created...
resource operationGetbackend 'Microsoft.ApiManagement/service/apis/operations@2020-06-01-preview' = if (useDefaultLb) {
name: '${apiGetbackend.name}/getbackendfrompolicy'
properties: {
displayName: 'Get Backend From Policy'
method: 'GET'
urlTemplate: '/'
}
}
// ... or this operation is being used. This one will retrieve the backend URLs from the Table storage.
resource operationGetbackendFromTable 'Microsoft.ApiManagement/service/apis/operations@2020-06-01-preview' = if (useTableStorage) {
name: '${apiGetbackend.name}/getbackendfromtable'
properties: {
displayName: 'Get Backend From Table Storage'
method: 'GET'
urlTemplate: '/'
}
}
// ... or this operation is being used. This one will retrieve the backend URLs from the Table storage.
resource operationGetbackendByLanguage 'Microsoft.ApiManagement/service/apis/operations@2020-06-01-preview' = if (useLanguageRouter) {
name: '${apiGetbackend.name}/getbackendbylanguage'
properties: {
displayName: 'Get Backend by User Language'
method: 'GET'
urlTemplate: '/'
}
}
resource operationHealthz 'Microsoft.ApiManagement/service/apis/operations@2020-06-01-preview' = {
name: '${apiHealthz.name}/apimhealth'
properties: {
displayName: 'ApimHealth'
method: 'HEAD'
urlTemplate: '/'
}
}
resource getbackendPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2020-06-01-preview' = if (useDefaultLb) {
name: '${operationGetbackend.name}/policy'
dependsOn: [
namedValueBackends
]
properties: {
format: 'xml'
// The 'backends' parameter gets injected into the policy via the named value
value: '''
<policies>
<inbound>
<base />
<return-response>
<set-status code="302" />
<set-header name="X-Apim-Region" exists-action="override">
<value>@(context.Deployment.Region)</value>
</set-header>
<set-header name="Location" exists-action="override">
<value>@{
var backends = "{{backend-urls}}".Split(',');
var i = new Random(context.RequestId.GetHashCode()).Next(0, backends.Length);
return backends[i];
}</value>
</set-header>
</return-response>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>'''
}
}
resource getbackendPolicyByLanguage 'Microsoft.ApiManagement/service/apis/operations/policies@2020-06-01-preview' = if (useLanguageRouter) {
name: '${operationGetbackendByLanguage.name}/policy'
dependsOn: [
namedValueBackends
]
properties: {
format: 'xml'
// The 'backends' parameter gets injected into the policy via the named value
value: '''
<policies>
<inbound>
<base />
<return-response>
<set-status code="302" />
<set-header name="X-Apim-Region" exists-action="override">
<value>@(context.Deployment.Region)</value>
</set-header>
<set-header name="Location" exists-action="override">
<value>@{
var dict = "{{backend-urls}}".Split(';').Select(item => item.Split('=')).ToDictionary(s => s[0], s => s[1].Split(','));
string[] languageUrls;
// If the query parameter "lang" is present and the requested language exists in the list, we use that. Otherwise we use the header Accept-Language
var langQueryParam = context.Request.OriginalUrl.Query.GetValueOrDefault("lang", "").ToLower();
if(!dict.TryGetValue(langQueryParam, out languageUrls))
{
var userLanguage = context.Request.Headers.GetValueOrDefault("Accept-Language", "en");
// Sample values for Accept-Language: "Accept-Language: de" "Accept-Language: de-CH" "Accept-Language: en-US,en;q=0.5"
if(userLanguage.Contains(","))
{
userLanguage = userLanguage.Split(',')[0];
}
if(userLanguage.Contains("-"))
{
userLanguage = userLanguage.Split('-')[0];
}
// If the language from the header exists in the list, we use that, otherwise we default to English
languageUrls = dict.ContainsKey(userLanguage) ? dict[userLanguage] : dict["en"];
}
// If the list only contains more than one entry for the selected language, we pick at random
var selectedUrl = languageUrls.Length == 0 ? languageUrls[0] : languageUrls[new Random(context.RequestId.GetHashCode()).Next(0, languageUrls.Length)];
return selectedUrl;
}</value>
</set-header>
</return-response>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>'''
}
}
resource getbackendFromTablePolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2020-06-01-preview' = if (useTableStorage) {
name: '${operationGetbackendFromTable.name}/policy'
dependsOn: [
namedValueTableUrl
namedValueTableSasToken
]
properties: {
format: 'xml'
value: '''
<policies>
<inbound>
<base />
<!-- Make outbound request to the table API which holds the list of backend URLs -->
<send-request mode="new" response-variable-name="tableApiResponse" timeout="20" ignore-error="true">
<set-url>@("{{table-url}}?{{table-sas-token}}&$select=url")</set-url>
<set-method>GET</set-method>
<set-header name="Accept" exists-action="override">
<value>application/json;odata=nometadata</value>
</set-header>
</send-request>
<set-method>GET</set-method>
<return-response>
<set-status code="302" />
<set-header name="X-Apim-Region" exists-action="override">
<value>@(context.Deployment.Region)</value>
</set-header>
<set-header name="Location" exists-action="override">
<value>@{
try
{
var urls = ((IResponse) context.Variables["tableApiResponse"]).Body.As<JObject>()["value"];
// Generate random rowKey
var rowKey = new Random(context.RequestId.GetHashCode()).Next(0, urls.Count());
return (string)urls[rowKey]["url"];
}
catch (Exception e)
{
// If something failed, it is usually because of an transient error. Then we just send the user to the same URL again to retry.
return "/";
}
}</value>
</set-header>
</return-response>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>'''
}
}
resource healthzPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2020-06-01-preview' = {
name: '${operationHealthz.name}/policy'
properties: {
format: 'xml'
value: '''
<policies>
<inbound>
<base />
<return-response>
<set-status code="200" />
</return-response>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>'''
}
}
// Section: named values for backend urls
resource namedValueBackends 'Microsoft.ApiManagement/service/namedValues@2020-06-01-preview' = if (useDefaultLb || useLanguageRouter) {
name: '${apim.name}/backend-urls'
properties: {
displayName: 'backend-urls'
value: backends
}
}
// Section: named values for Table storage backend
resource namedValueTableUrl 'Microsoft.ApiManagement/service/namedValues@2020-06-01-preview' = if (useTableStorage) {
name: '${apim.name}/table-url'
properties: {
displayName: 'table-url'
value: '${storageAccount.properties.primaryEndpoints.table}${tableName}'
}
}
// Table storage sas token properties
var accountSasProperties = {
signedServices: 't' // only valid on Table endpoint
signedPermission: 'rl' // Permissions: read and list
signedResourceTypes: 'o' // object-level
signedStart: sasTokenStart
signedExpiry: sasTokenExpiry
}
resource namedValueTableSasToken 'Microsoft.ApiManagement/service/namedValues@2020-06-01-preview' = if (useTableStorage) {
name: '${apim.name}/table-sas-token'
properties: {
displayName: 'table-sas-token'
value: listAccountSas(storageAccount.name, storageAccount.apiVersion, accountSasProperties).accountSasToken
secret: true
}
}
// Section: Logging
resource namedValueAppInsightsKey 'Microsoft.ApiManagement/service/namedValues@2020-06-01-preview' = {
name: '${apim.name}/logger-credentials'
properties: {
displayName: 'logger-credentials'
value: appInsights.properties.InstrumentationKey
secret: true
}
}
resource apimLogger 'Microsoft.ApiManagement/service/loggers@2020-06-01-preview' = {
name: '${apim.name}/${appInsights.name}'
properties: {
loggerType: 'applicationInsights'
credentials: {
instrumentationKey: '{{logger-credentials}}'
}
isBuffered: true
resourceId: appInsights.id
}
}
resource apiGetbackendDiagnostics 'Microsoft.ApiManagement/service/apis/diagnostics@2020-06-01-preview' = {
name: '${apiGetbackend.name}/applicationinsights'
properties: {
alwaysLog: 'allErrors'
httpCorrelationProtocol: 'W3C'
verbosity: 'information'
loggerId: apimLogger.id
frontend: {
response: {
headers: [
'location'
]
}
}
}
}
// Use AppInsights which was created outside of this module
resource appInsights 'Microsoft.Insights/components@2018-05-01-preview' existing = {
name: applicationInsightsName
}
// We only need the hostname, without the protocol
output apimHostname string = replace(apim.properties.gatewayUrl, 'https://', '')

Двоичные данные
media/Azure-ARM-Template.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 53 KiB

Двоичные данные
media/Live-Event-Multi-streams.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 60 KiB

Двоичные данные
media/dashboard.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 178 KiB

Двоичные данные
media/workflow.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 37 KiB

30
testing/call-URL.ps1 Normal file
Просмотреть файл

@ -0,0 +1,30 @@
param
(
[Parameter(Mandatory)]
[string]
${Please provide the URL to test}
)
$URL = ${Please provide the URL to test}
$count = 10
function Get-UrlStatusCode([string] $Url)
{
try
{
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -DisableKeepAlive -MaximumRedirection 0 -SkipHttpErrorCheck -ErrorAction Ignore
if($response.StatusCode -ne 302)
{
Write-Error -Message "Unexpected status code ($($response.StatusCode))" -ErrorAction Stop
}
Write-Host "Success! Status code: $($response.StatusCode) -- Backend: $($response.Headers['Location'])"
}
catch [Exception]
{
$_.Exception.Message
}
}
for($i = 0; $i -lt $count; $i++){ Get-UrlStatusCode $URL }

Просмотреть файл

@ -0,0 +1,48 @@
# Script to load the URL into all Azure Storage accounts deployed as part of the Teams load-balancing solution
# Install Azure PowerShell
# https://docs.microsoft.com/en-us/powershell/azure/install-az-ps?view=azps-5.5.0
# This is the only Az module required for this script (you don't have to install all Az modules)
Install-Module AzTable
# Login to Azure with a browser sign-in token
Connect-AzAccount
# Set to the subscription ID in which the solution got deployed in
Set-AzContext -Subscription "xxxx-xxxx-xxxx-xxxx"
# url-list.txt needs to contain the list of backend URLs, one per line
$urls = Get-Content sample-url-list.txt
Write-Host "Fount $($urls.Count) URLs to import"
$resourceGroup = "teamsdistributor" # <-- Change me!
$tableName = "Urls" # does not need to be changed unless it was changed in the ARM template
$partitionKey = "event1" # does not need to be changed
# Get all storage accounts in the resource group
$storageAccounts = Get-AzStorageAccount -ResourceGroupName $resourceGroup
foreach ($storageAccount in $storageAccounts) {
Write-Host "Importing data into storage account $($storageAccount.StorageAccountName)"
$ctx = $storageAccount.Context
$cloudTable = (Get-AzStorageTable Name $tableName Context $ctx).CloudTable
# First, delete all current entries in the table
$currentRows = Get-AzTableRow -Table $cloudTable
Write-Host "Deleting $($currentRows.Count) old rows from the table"
$currentRows | Remove-AzTableRow -Table $cloudTable
$i = 0
foreach ($url in $urls) {
Add-AzTableRow `
-Table $cloudTable `
-PartitionKey $partitionKey `
-RowKey ($i) `
-Property @{"url" = "$url" } `
-UpdateExisting
$i++
}
}