In this workshop we're going to cover .NET, Azure, GitHub, and Bicep.
This workshop will highlight the following:
- Health Checks
- Zero Downtime Deployments
- Infrastructure managed by code using Bicep
- Automated builds and deploys
- WhatIf on PRs for Infrastructure Changes
- Follows Azure Naming Standards for naming resources
- Version Endpoint so you know what's deployed
Pre-requisites:
- .NET 8
- Git
- GitHub Account
- Tell Scott the following:
- Email you will use for Azure
- GitHub account
- GitHub repo name
- Should be the same as this one: workshop-dotnet-azure-github-bicep
- Recommended to use VS Code with the Bicep extension for editing Bicep
- Fork this repo
- What is a Subscription?
- What is a Resource Group?
- What is Azure App Service?
- What is Azure App Service Plan?
- Lay of the land
- Each of you have 2 Resource Groups for Dev + Prod where you are a Contributor
- Each of you have a Federated Credential behind each RG where it's Contributor that's authed to your repo
- This is the user Bicep will run under
- Each of you have Reader access to two already deployed Dev and Prod Resource Groups with App Services in them so you can follow along
- View Subscription
- View Costs
- Set Cost Alerts
- Budgets
- Access Control (IAM)
- Resource Groups
- View Resource Group
- See all resources at a glance
- Access Control (IAM)
- Deployments
- Costs
- Go to App Service Plan
- App Service Plan
- Show CPU, Memory, Network
- Show RG
- Show Scale Up and Scale Out
- Show Apps
- View Home Page of an App Service
- Home Page, Stop, Restart
- RG + Subscription
- Location
- Default Domain
- OS
- Health Check
- Show Configuration
- Show Custom Domains
- Show Certificates
- Log Stream
- Advanced Tools (Kudu)
- Bash Terminal
- File System
- Logs
- Deployment Slots
- We have the same for each env - Dev and Prod
- How do we do that without clicking those same settings everywhere... enter Bicep
- Show the Version Endpoint
- Make some change and deploy it, watch it go through the AzurePing service
-
Delete your
.github
folder and yourinfrastructure
folder and commit and push that code. This history is here for reference in case you get stuck. -
Create a new folder called
infrastructure
-
Create a
appservice.bicep
file -
Create a Linux App Service Plan resource
resource appServicePlan 'Microsoft.Web/serverfarms@2022-09-01' = { name: 'asp-dnazghbicep-'PUTYOURUSERNAMEHERE'-dev' location: 'centralus' sku: { name: 'S1' } kind: 'linux' properties: { reserved: true } }
-
Next create an App Service resource, by referencing the App Service Plan's ID (note replace the ???? with the reference's ID)
resource appService 'Microsoft.Web/sites@2022-09-01' = { name: 'app-dnazghbicep-'PUTYOURUSERNAMEHERE'-dev' location: 'centralus' identity: { type: 'SystemAssigned' } properties: { serverFarmId: ???? httpsOnly: true siteConfig: { http20Enabled: true linuxFxVersion: 'DOTNETCORE|8.0' alwaysOn: true ftpsState: 'Disabled' minTlsVersion: '1.2' webSocketsEnabled: true healthCheckPath: '/api/healthz' requestTracingEnabled: true detailedErrorLoggingEnabled: true httpLoggingEnabled: true } } }
-
Add the environment variables for the app service, by referencing the app service object itself (note replace the ???? with the reference)
resource appSettings 'Microsoft.Web/sites/config@2022-09-01' = { name: 'appsettings' kind: 'string' parent: appService properties: { ASPNETCORE_ENVIRONMENT: 'dev' } }
-
Add the App Service Slot. Note: there's a lot of duplication in the properties... maybe we should do something about that?
resource appServiceSlot 'Microsoft.Web/sites/slots@2022-09-01' = { location: 'centralus' parent: appService name: 'slot' identity: { type: 'SystemAssigned' } properties: { serverFarmId: appServicePlan.id httpsOnly: true siteConfig: { http20Enabled: true linuxFxVersion: 'DOTNETCORE|8.0' alwaysOn: true ftpsState: 'Disabled' minTlsVersion: '1.2' webSocketsEnabled: true healthCheckPath: '/api/healthz' requestTracingEnabled: true detailedErrorLoggingEnabled: true httpLoggingEnabled: true } } }
-
Add the environment variables for the app service slot, by referencing the app service object itself (note replace the ???? with the reference)
resource appServiceSlotSetting 'Microsoft.Web/sites/slots/config@2022-09-01' = { name: 'appsettings' kind: 'string' parent: appServiceSlot properties: { ASPNETCORE_ENVIRONMENT: 'dev' } }
-
Review the code and identify duplication
-
If you're in VS Code, you're getting a warning for the location not being parameterized. Add a parameter below and then replace all the 'centralus' with a reference to that parameter:
param location string // replace centralus with location everywhere
-
If you look closely for duplication you'll see we have "dev" repeated a lot, let's parameterize that too, because we'll want to swap that out for the word "prod" later. Also restrict the values to only allow 'dev' and 'prod'
@allowed(['dev', 'prod']) param environment string // replace dev with environment everywhere. Note: ${variableName} is how you do concatenation
-
If you look closely for more duplication, you'll see we have the app name repeated in both the appService and appServicePlan. Extract that out to a parameter
param appName string // down in the appServicePlan replace the name so it looks like this: resource appServicePlan 'Microsoft.Web/serverfarms@2022-09-01' = { name: 'asp-${appName}-${environment}' // the rest } // down in the appService replace the name so it looks like this: resource appService 'Microsoft.Web/sites@2022-09-01' = { name: 'app-${appName}-${environment}' // the rest }
-
Finally, if you look closely you'll see one last bit of duplication. The properties between the appService and the appServiceSlot. Let's extract out those properties to a variable and assign it in one place
// This is reused between the App Service and the Slot var appServiceProperties = { serverFarmId: appServicePlan.id httpsOnly: true siteConfig: { http20Enabled: true linuxFxVersion: 'DOTNETCORE|8.0' alwaysOn: true ftpsState: 'Disabled' minTlsVersion: '1.2' webSocketsEnabled: true healthCheckPath: '/api/healthz' requestTracingEnabled: true detailedErrorLoggingEnabled: true httpLoggingEnabled: true } } // Assign it in the appService and the appServiceSlot resource appService 'Microsoft.Web/sites@2022-09-01' = { // the rest properties: appServiceProperties } resource appServiceSlot 'Microsoft.Web/sites/slots@2022-09-01' = { // the rest properties: appServiceProperties }
-
Awesome! We now have a reusable Module that also enforces our naming standards that follow the Azure Guidelines with the
asp-
prefix for App Service plans andapp-
for App Services. -
Now we need to pass some values to those parameters. So let's create a
main.bicep
file -
Provide a
targetScope
of 'resourceGroup' for themain.bicep
moduletargetScope = 'resourceGroup'
-
Now reference the
appservice.bicep
module you just created and pass the parameters to itmodule app './appservice.bicep' = { name: 'appservice' params: { appName: 'workshop-dnazghbicep-'YOURUSERNAMEHERE'' environment: ??? location: 'centralus' } }
-
Crap - where does the environment come from? I want to pass that in dynamically depending on dev or prod. Enter
.bicepparam
files (these will be passed in dynamically via the CLI) -
Add a parameter for environment and only allow
'dev'
and'prod'
as values then reference that environment below in theapp
module@allowed(['dev', 'prod']) param environment string targetScope = 'resourceGroup' module app './appservice.bicep' = { name: 'appservice' params: { appName: 'workshop-dnazghbicep-'YOURUSERNAMEHERE'' environment: environment location: 'centralus' } }
-
Under the
infrastructure
folder, create a folder calledenvironments
. -
Create a
dev.bicepparam
file with the following contentsusing '../main.bicep' param environment = 'dev'
-
The
using
tells thebicepparam
file what parameters are required. Try taking away theparam environment = 'dev'
and see what happens if you're in VS Code with the Bicep Extension. You will receive an error. -
Now add back
param environment ='dev'
but switch'dev'
to'qa'
or some other invalid value. You will see an error like this if you use VS Code. -
That's it! We now have Bicep ready to be configured... but crap... how do we deploy it?? ππ
-
If it doesn't already exist, create a folder called
.github
and thenworkflows
under that folder. -
Create 6 new secrets based on the email you received at the beginning of this workshop. These secrets will be used to authenticate to Azure. If this wasn't in place, you wouldn't be able to talk to your Azure account, because Azure doesn't just let anyone in.
- Go to your Repo out in GitHub
- Click on Settings
- Click on Secrets and Variables
- Click on Actions
- Click New Repository Secret
- For secret name type
DEV_AZURE_CLIENT_ID
- For the value paste in the value from the Dev value in the email
- Hit Add Secret
- Repeat for
DEV_AZURE_SUBSCRIPTION_ID
andDEV_AZURE_TENANT_ID
- Repeat for the Prod values for
PROD_AZURE_CLIENT_ID
,PROD_AZURE_SUBSCRIPTION_ID
, andPROD_AZURE_TENANT_ID
- Note - the Subscription ID and the Tenant ID are the same. That is just for Workshop demo purposes. In a real world scenario the Subscription ID should be different.
-
Create a
ci.yml
file that looks like this. Note to replace the "" with your GH usernamename: CI - Deploy App and Bicep on: push: branches: [main] workflow_dispatch: permissions: id-token: write pull-requests: write contents: read jobs: build_and_test: runs-on: ubuntu-latest name: Build, Test, Upload Artifact steps: - name: Checkout repo uses: actions/checkout@v1 - name: Run dotnet test run: | dotnet test -c Release - name: Run dotnet publish run: | dotnet publish ./src/WorkshopDemo/WorkshopDemo.csproj -c Release -o ./publish - name: Update version file with GHA run number and git short SHA run: echo $(date +'%Y%m%d.%H%M').${{github.run_number}}-$(git rev-parse --short HEAD) > publish/version.txt - name: Upload artifact uses: actions/upload-artifact@v3 with: name: dotnet-artifact path: publish/ dev: needs: build_and_test uses: ./.github/workflows/step-deploy.yml with: env: dev artifact_name: dotnet-artifact resource_group_name: rg-workshop-dnazghbicep-<YOURUSERNAMEHERE>-dev app_service_name: app-workshop-dnazghbicep-<YOURUSERNAMEHERE>-dev app_service_slot_name: slot # Note: use GH Environment Secrets if using a Pro/Enterprise version of GH secrets: azure_client_id: ${{ secrets.DEV_AZURE_CLIENT_ID }} azure_subscription_id: ${{ secrets.DEV_AZURE_SUBSCRIPTION_ID }} azure_tenant_id: ${{ secrets.DEV_AZURE_TENANT_ID }} prod: needs: dev uses: ./.github/workflows/step-deploy.yml with: env: prod artifact_name: dotnet-artifact resource_group_name: rg-workshop-dnazghbicep-<YOURUSERNAMEHERE>-prod app_service_name: app-workshop-dnazghbicep-<YOURUSERNAMEHERE>-prod app_service_slot_name: slot # Note: use GH Environment Secrets if using a Pro/Enterprise version of GH secrets: azure_client_id: ${{ secrets.PROD_AZURE_CLIENT_ID }} azure_subscription_id: ${{ secrets.PROD_AZURE_SUBSCRIPTION_ID }} azure_tenant_id: ${{ secrets.PROD_AZURE_TENANT_ID }}
-
Note the
needs
in this workflow where thedev
job needsbuild_and_test
and theprod
job needsdev
. That indicates the jobs should be ran sequentially and not in parallel. Specifically in the order ofbuild_and_test
=>dev
=>prod
-
We now need to create the reusable step in the
step-deploy.yml
to reduce duplication. Create astep-deploy.yml
under the.github/workflows
folder and enter in this data:name: "Step - Deploy" on: workflow_call: inputs: env: required: true type: string artifact_name: required: true type: string resource_group_name: required: true type: string app_service_name: required: true type: string app_service_slot_name: required: true type: string secrets: azure_client_id: required: true description: "Client ID for Azure Service Principal" azure_subscription_id: required: true description: "Azure Subscription ID for the targeted Resource Group" azure_tenant_id: required: true description: "Azure Tenant ID for the targeted Resource Group" jobs: deploy: name: Deploy to Azure App Service runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Log in to Azure uses: azure/login@v1 with: client-id: ${{ secrets.azure_client_id }} tenant-id: ${{ secrets.azure_tenant_id }} subscription-id: ${{ secrets.azure_subscription_id }} - name: Run Bicep run: | az deployment group create \ --name ${{ inputs.env }}-deployment-${{ github.run_number }} \ --template-file infrastructure/main.bicep \ --parameters infrastructure/environments/${{ inputs.env }}.bicepparam \ --resource-group ${{ inputs.resource_group_name }} \ --verbose - uses: actions/download-artifact@v3 with: name: ${{ inputs.artifact_name }} path: publish - name: Get publish profile id: publishprofile run: | profile=$(az webapp deployment list-publishing-profiles --resource-group ${{ inputs.resource_group_name }} --name ${{ inputs.app_service_name }} --slot ${{ inputs.app_service_slot_name }} --xml) echo "PUBLISH_PROFILE=$profile" >> $GITHUB_OUTPUT - name: Deploy to Slot uses: azure/webapps-deploy@v2 with: app-name: ${{ inputs.app_service_name }} slot-name: ${{ inputs.app_service_slot_name }} publish-profile: ${{ steps.publishprofile.outputs.PUBLISH_PROFILE }} package: publish/ - name: Swap slots run: | az webapp deployment slot swap -g ${{ inputs.resource_group_name }} -n ${{ inputs.app_service_name }} --slot ${{ inputs.app_service_slot_name }} --target-slot production --verbose
-
When you commit and push this code with both the action and the pipeline, your Action will trigger immediately. Go to the Actions tab in GitHub and follow its progress from Dev all the way to Production
-
Go to your Dev App Service Plan and note that the SKU is an S1. Let's change that to an S2 and commit and push that.
-
Go to your
WeatherForecastController
and get rid of all thesummaries
exceptFreezing
. Then commit and push and watch it deploy. -
Go to your Dev App Service Slot /api/WeatherForecast URL and note that it is still changing the summaries. Whereas the main App Service /api/WeatherForecast URL is ha
-
Take note of the /api/version endpoint and then correlate that back to the Git SHA back in GitHub. Note how in
ci.yml
we are setting that version and putting it inversion.txt
file that gets read by the application.