I have always believed that Delivery is one of the most important aspects of software development. I blogged about it previously (My Core Values). Software delivery isn’t just putting the bits into the final resting location; it must also include the infrastructure provisioning to explicitly define where the bits will actually land. Terraform helps bridge that gap, especially given a public cloud offering like Azure.
The Project
I was recently contracted to implement a deployment pipeline for a financial services startup. The client had a special need to have the application environments built out in a reliable, scalable manner. There were two applications within the client’s solution: a single-page application and a web service tier.
From a non-functional perspective, there were a number of requirements from the technology team:
- The development team must be able to deploy updated code assets to the environment in a reliable, well-known manner, without manual intervention
- The QA team must be able to deploy a feature branch to a specified environment for feature-specific testing
- System administrators must be able to quickly commission and decommission all aspects of environments, including the entire environment itself
The Proposal
The non-functional requirements detailed above pointed me in an obvious location: host the application in Microsoft Azure and to utilize as many platform services as possible. Additionally, utilize Azure DevOps products to enable tight integration between the application code and the application environments.
Microsoft Azure
Within Azure, App Services would be used as application hosts and Azure Database for PostgreSQL would be used for the database management. In addition to the core platform services, there will also be a few other products involved:
- Key Vault: Used to manage application secrets (e.g., connection strings, encryption keys, etc.)
- Application Insights: Used to provide Application Performance Monitoring for both the single-page application and the web service
- Storage: Used as a blob storage location for the applications
Azure DevOps
Azure DevOps (previously known as Visual Studio Team Services, previously known as Team Foundation Server) was chosen as the set of tools to manage source control and the build and release pipelines. Azure Repos is the remote source control repository and Azure Pipelines is the build and release pipeline tool.
In a future iteration, it is possible that the work item tracking will migrate to Azure Boards from Jira and to Azure Test Plans from Zephyr, but this was not on the initial set of work.
Terraform
Terraform is a product from HashiCorp to implement Infrastructure-as-Code.
That sounds like a lot of jargon, so let’s boil it down: Infrastructure-as-Code (IaC) allows you to prescriptively define your infrastructure implementation in source control. Terraform is then a tool that executes these definitions to ensure the implemented infrastructure is equivalent to the specification. It moves the best-of-breed software development practices (e.g., source control, code reviews, deployment pipelines, etc.) into the infrastructure management realm.
Additionally, Terraform was chosen as the IaC tool rather than Azure Resource Manager Templates (ARM Templates) due to the extensive Terraform community and my personal expertise. I’ve worked with ARM Templates previously, but Terraform offered the same output with less initial startup work. While I hate YAML with an undying passion, it is much less verbose than the JSON that is used with ARM Templates.
The Solution
Implementing the solutions for each of the applications was generally the same. The only main difference was the Terraform implementation and the steps within the Azure Build Pipeline itself, but the concepts are similar.
Azure Pipelines
Azure Pipelines has two concepts: Build and Release Pipelines. While I generally believe this should be one pipeline in a perfect world, I can understand why they’re separated. The Build Pipeline is used to generate artifacts, and the Release Pipeline is used to move those artifacts to separate stages (i.e., environments).
Recently, Azure Pipelines added source control-defined Build Pipelines to its
offering. As a firm believer in source controlling everything, an
azure-pipeline.yml
file now exists in both the single-page application’s
repository and the web service’s repository. These are detailed in the sections
below.
Single-Page Application Build Pipeline
The single-page application’s pipeline can be seen in its entirety below. At a high level, it’s a straightforward build:
- Run
npm install
to get the application’s dependencies - Lint the application to ensure it conforms to the Angular’s specifications
- Build the application
- Publish the built application
- Publish the Terraform artifacts
Both the built application and the Terraform artifacts are used in the related Release pipeline.
It is important to note that this build pipeline is used for all branches. This is to ensure the entire repository is deployable at any given commit, assuming it passes the build pipeline successfully.
In the future, the application team plans to implement automated tests in this pipeline to ensure the application is stable enough to push through to a release pipeline.
# Single Page Application
pool:
vmImage: 'Ubuntu-16.04'
variables:
publishPath: 'dist/single-page-app'
terraformPath: 'terraform'
steps:
- task: NodeTool@0
inputs:
versionSpec: '8.x'
displayName: 'Install Node.js'
- script: |
npm install -g @angular/cli
npm install
displayName: 'npm install'
- script: |
ng lint
displayName: 'ng lint'
- script: |
ng build
displayName: 'ng build'
- task: PublishBuildArtifacts@1
displayName: 'Publish Website Artifacts'
inputs:
pathtoPublish: '$(publishPath)'
artifactName: drop
- task: PublishBuildArtifacts@1
displayName: 'Publish Terraform'
inputs:
pathtoPublish: '$(terraformPath)'
artifactName: terraform
Web Services Build Pipeline
While the web services pipeline has many more steps, it accomplishes roughly the same concepts as the single-page application:
- Restore the solution’s dependencies with
nuget restore
- Build the background data processor
- Build the web service
- Build the database migrator application (used to run SQL scripts needed for the environment (e.g., schema changes, data changes, etc.))
- Build the database seeder application (used to setup first-time data for the SQL database (i.e., default database users and their roles))
- Publish each of the built applications and Terraform
As before, the built applications and Terraform are used in the future release pipeline. Additionally, this build is run for all branches for the same reason as before: having a deployable codebase is vitally important.
You’ll notice that dotnet publish
is used for both the database migrator and
database seeder projects; this is on purpose, as the application is planned to
move toward .NET Core in the future.
The absence of any automated testing (e.g., unit tests) is especially glaring in this build pipeline. The application development team is planning on adding those in the future, as with the single-page application.
# Web Service
pool:
vmImage: 'VS2017-Win2016'
variables:
solution: '**/*.sln'
buildConfiguration: 'Release'
processorProject: 'WebService.Processor.csproj'
webServiceProject: 'WebService.csproj'
migratorProject: 'WebService.DbMigrator.csproj'
seederProject: 'WebService.DbSeeder.csproj'
webServicePublishPath: 'WebService/obj/Release/Package'
migratorPublishPath: 'WebService.DbMigrator/bin/Debug/netcoreapp2.1/publish'
seederPublishPath: 'WebService.DbSeeder/bin/Debug/netcoreapp2.1/publish'
steps:
- task: NuGetToolInstaller@0
displayName: 'Install NuGet'
- task: NuGetCommand@2
displayName: 'Solution NuGet Restore'
inputs:
restoreSolution: '$(solution)'
- task: VSBuild@1
displayName: 'Build Processor'
inputs:
solution: '$(processorProject)'
configuration: '$(buildConfiguration)'
- task: VSBuild@1
displayName: 'Build Web Service'
inputs:
solution: '$(webServiceProject)'
msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true'
configuration: '$(buildConfiguration)'
- script: dotnet publish $(migratorProject)
displayName: 'Build and Package Database Migrator'
- script: dotnet publish $(seederProject)
displayName: 'Build and Package Database Seeder'
- task: PublishBuildArtifacts@1
displayName: 'Publish Web Service Artifacts'
inputs:
pathtoPublish: '$(webServicePublishPath)'
artifactName: drop
- task: PublishBuildArtifacts@1
displayName: 'Publish Terraform Artifacts'
inputs:
pathtoPublish: 'terraform'
artifactName: terraform
- task: PublishBuildArtifacts@1
displayName: 'Publish Database Migrator Artifacts'
inputs:
pathtoPublish: '$(migratorPublishPath)'
artifactName: migrator
- task: PublishBuildArtifacts@1
displayName: 'Publish Database Seeder Artifacts'
inputs:
pathtoPublish: '$(seederPublishPath)'
artifactName: seeder
Terraform Configuration
For each of the applications, I define the infrastructure and platform services
alongside the application’s source code, generally within a terraform
folder.
Each terraform
folder is organized as such:
terraform
env-dev
destroy.bat
main.tf
setup.bat
variables.tf
env-production
destroy.bat
main.tf
setup.bat
variables.tf
env-staging
destroy.bat
main.tf
setup.bat
variables.tf
env-test-1
destroy.bat
main.tf
setup.bat
variables.tf
env-test-2
destroy.bat
main.tf
setup.bat
variables.tf
env-test-3
destroy.bat
main.tf
setup.bat
variables.tf
modules
... some number of modules ...
Modules
I use Terraform modules to encapsulate specific sections of the infrastructure. The modules that I’m using across the two applications are detailed below.
Name | Short Name | Description | Azure Resources |
---|---|---|---|
API | api | Defines the Web Services hosting services. |
|
Database | db | Defines the PostgreSQL server and database. |
|
Key Vault | kv | Defines the secret store and the default secrets. |
|
Resource Group | rg | Defines the resource group for the given environment that contains all the Azure resources. |
|
Storage | storage | Defines the storage account used by the application. |
|
Web | web | Defines the Single-Page Application hosting services. |
|
To be a bit more explicit, below are the exact files that I am using for each of these modules.
API
Folder Structure
modules
api
main.tf
variables.tf
Files
# main.tf
resource "azurerm_app_service_plan" "default" {
name = "${var.environment_prefix}-ws-plan"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
tags = "${var.tags}"
sku {
tier = "${var.app_service_plan_sku_tier}"
size = "${var.app_service_plan_sku_size}"
}
}
resource "azurerm_application_insights" "default" {
name = "${var.environment_prefix}-ws-ai"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
application_type = "Web"
tags = "${var.tags}"
}
resource "azurerm_app_service" "api" {
name = "${var.environment_prefix}-ws"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
app_service_plan_id = "${azurerm_app_service_plan.default.id}"
tags = "${var.tags}"
app_settings {
"APPINSIGHTS_INSTRUMENTATIONKEY" = "${azurerm_application_insights.default.instrumentation_key}"
"APPINSIGHTS_PROFILERFEATURE_VERSION" = "1.0.0"
"APPINSIGHTS_SNAPSHOTFEATURE_VERSION" = "1.0.0"
"ApplicationInsightsAgent_EXTENSION_VERSION" = "~2"
"DiagnosticServices_EXTENSION_VERSION" = "~3"
"InstrumentationEngine_EXTENSION_VERSION" = "~1"
"SnapshotDebugger_EXTENSION_VERSION" = "~1"
"XDT_MicrosoftApplicationInsights_BaseExtensions" = "~1"
"XDT_MicrosoftApplicationInsights_Mode" = "recommended"
}
}
# variables.tf
variable "environment_prefix" {}
variable "location" {}
variable "tags" {
type = "map"
}
variable "resource_group_name" {}
variable "app_service_plan_sku_tier" {}
variable "app_service_plan_sku_size" {}
Database
Folder Structure
modules
db
main.tf
variables.tf
Files
# main.tf
resource "azurerm_postgresql_server" "default" {
name = "${var.environment_prefix}-app-db"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
tags = "${var.tags}"
sku {
name = "${var.db_sku}"
capacity = "${var.db_capacity}"
tier = "${var.db_tier}"
family = "${var.db_family}"
}
storage_profile {
storage_mb = "${var.db_storage_mb}"
backup_retention_days = "${var.db_backup_retention_days}"
geo_redundant_backup = "${var.db_geo_redundant_backup}"
}
administrator_login = "${var.db_administrator_login}"
administrator_login_password = "${var.db_administrator_login_password}"
version = "${var.db_version}"
ssl_enforcement = "${var.db_ssl_enforcement}"
}
resource "azurerm_postgresql_firewall_rule" "azure" {
name = "azure"
resource_group_name = "${var.resource_group_name}"
server_name = "${azurerm_postgresql_server.default.name}"
start_ip_address = "0.0.0.0"
end_ip_address = "0.0.0.0"
}
resource "azurerm_postgresql_firewall_rule" "userhome" {
name = "user-home"
resource_group_name = "${var.resource_group_name}"
server_name = "${azurerm_postgresql_server.default.name}"
start_ip_address = "xx.xx.xxx.xxx"
end_ip_address = "xx.xx.xxx.xxx"
}
resource "azurerm_postgresql_database" "app" {
name = "app"
resource_group_name = "${var.resource_group_name}"
server_name = "${azurerm_postgresql_server.default.name}"
charset = "UTF8"
collation = "English_United States.1252"
}
# variables.tf
variable "environment_prefix" {}
variable "location" {}
variable "tags" {
type = "map"
}
variable "resource_group_name" {}
variable "db_sku" {}
variable "db_capacity" {}
variable "db_tier" {}
variable "db_family" {}
variable "db_storage_mb" {}
variable "db_backup_retention_days" {}
variable "db_geo_redundant_backup" {}
variable "db_administrator_login" {}
variable "db_administrator_login_password" {}
variable "db_version" {}
variable "db_ssl_enforcement" {}
Key Vault
Folder Structure
modules
kv
main.tf
variables.tf
Files
# main.tf
data "azurerm_client_config" "current" {}
resource "azurerm_key_vault" "default" {
name = "${var.environment_prefix}-app-kv"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
tags = "${var.tags}"
enabled_for_template_deployment = true
tenant_id = "${var.tenant_id}"
sku {
name = "standard"
}
}
resource "azurerm_key_vault_access_policy" "azuredevops" {
vault_name = "${azurerm_key_vault.default.name}"
resource_group_name = "${var.resource_group_name}"
tenant_id = "${var.tenant_id}"
object_id = "${var.azuredevops_object_id}"
secret_permissions = [
"get",
"list",
"set",
"delete",
]
}
resource "azurerm_key_vault_access_policy" "current" {
vault_name = "${azurerm_key_vault.default.name}"
resource_group_name = "${var.resource_group_name}"
tenant_id = "${data.azurerm_client_config.current.tenant_id}"
object_id = "${data.azurerm_client_config.current.service_principal_object_id}"
secret_permissions = [
"get",
"list",
"set",
"delete",
]
}
resource "azurerm_key_vault_access_policy" "adminuser" {
vault_name = "${azurerm_key_vault.default.name}"
resource_group_name = "${var.resource_group_name}"
tenant_id = "${var.tenant_id}"
object_id = "${var.adminuser_object_id}"
secret_permissions = [
"get",
"list",
"set",
"delete",
"recover",
"backup",
"restore",
]
certificate_permissions = [
"create",
"delete",
"deleteissuers",
"get",
"getissuers",
"import",
"list",
"listissuers",
"managecontacts",
"manageissuers",
"purge",
"recover",
"setissuers",
"update",
"backup",
"restore",
]
key_permissions = [
"get",
"list",
"update",
"create",
"import",
"delete",
"recover",
"backup",
"restore",
]
}
resource "azurerm_key_vault_secret" "secret_1" {
name = "secret_1"
value = "${var.key_vault_secret_1}"
vault_uri = "${azurerm_key_vault.default.vault_uri}"
tags = "${var.tags}"
}
resource "azurerm_key_vault_secret" "secret_2" {
name = "secret_2"
value = "${var.key_vault_secret_2}"
vault_uri = "${azurerm_key_vault.default.vault_uri}"
tags = "${var.tags}"
}
# variables.tf
variable "environment_prefix" {}
variable "location" {}
variable "tags" {
type = "map"
}
variable "resource_group_name" {}
variable "tenant_id" {
type = "string"
description = "The Azure Active Directory tenant ID that should be used for authenticating requests to the key vault."
default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
variable "azuredevops_object_id" {
default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
variable "adminuser_object_id" {
default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
variable "key_vault_secret_1" {}
variable "key_vault_secret_2" {}
Resource Group
Folder Structure
modules
rg
main.tf
output.tf
variables.tf
Files
# main.tf
resource "azurerm_resource_group" "default" {
name = "${var.environment_prefix}-app-rg"
location = "${var.location}"
tags = "${var.tags}"
}
# output.tf
output "name" {
value = "${azurerm_resource_group.default.name}"
}
output "location" {
value = "${azurerm_resource_group.default.location}"
}
# variables.tf
variable "environment_prefix" {}
variable "location" {}
variable "tags" {
type = "map"
}
Storage
Folder Structure
modules
storage
main.tf
variables.tf
Files
# main.tf
resource "azurerm_storage_account" "default" {
name = "${var.environment_prefix}appstorage"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
tags = "${var.tags}"
account_tier = "Standard"
account_replication_type = "LRS"
}
# variables.tf
variable "environment_prefix" {}
variable "location" {}
variable "tags" {
type = "map"
}
variable "resource_group_name" {}
Web
Folder Structure
modules
web
main.tf
variables.tf
Files
# main.tf
resource "azurerm_app_service_plan" "default" {
name = "${var.environment_prefix}-web-plan"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
sku {
tier = "${var.app_service_plan_sku_tier}"
size = "${var.app_service_plan_sku_size}"
}
}
resource "azurerm_application_insights" "default" {
name = "${var.environment_prefix}-web-ai"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
application_type = "Web"
}
resource "azurerm_app_service" "web" {
name = "${var.environment_prefix}-web"
location = "${var.location}"
resource_group_name = "${var.resource_group_name}"
app_service_plan_id = "${azurerm_app_service_plan.default.id}"
app_settings {
"APPINSIGHTS_INSTRUMENTATIONKEY" = "${azurerm_application_insights.default.instrumentation_key}"
"APPINSIGHTS_PROFILERFEATURE_VERSION" = "1.0.0"
"APPINSIGHTS_SNAPSHOTFEATURE_VERSION" = "1.0.0"
"ApplicationInsightsAgent_EXTENSION_VERSION" = "~2"
"DiagnosticServices_EXTENSION_VERSION" = "~3"
"InstrumentationEngine_EXTENSION_VERSION" = "~1"
"SnapshotDebugger_EXTENSION_VERSION" = "~1"
"XDT_MicrosoftApplicationInsights_BaseExtensions" = "~1"
"XDT_MicrosoftApplicationInsights_Mode" = "recommended"
}
}
# variables.tf
variable "environment_prefix" {}
variable "location" {}
variable "tags" {
type = "map"
}
variable "resource_group_name" {}
variable "app_service_plan_sku_tier" {}
variable "app_service_plan_sku_size" {}
Environments
Generally, each of the environments is the same look and feel. The point of
having each of these separate environment folders (e.g., env-dev
,
env-production
, etc.) is to allow Terraform to easily run its normal scripts
without any more configuration in the release pipelines. For example, if we are
deploying the application to the development environment, we change the current
working directory to env-dev
and execute the same scripts that we would
elsewhere.
A more detailed look at the files can be seen below.
Single-Page Application Environments
The single-page application contains only a few Terraform modules, as its implementation is much simpler.
# main.tf
terraform {
backend "azurerm" {
container_name = "terraform"
key = "tfbackend-web.tfstate"
}
}
provider "azurerm" {
version = "=1.20.0"
}
module "rg" {
source = "../modules/rg"
environment_prefix = "${var.environment_prefix}"
location = "${var.location}"
tags = "${var.tags}"
}
module "web" {
source = "../modules/web"
environment_prefix = "${var.environment_prefix}"
resource_group_name = "${module.rg.name}"
location = "${module.rg.location}"
tags = "${var.tags}"
app_service_plan_sku_tier = "${var.app_service_plan_sku_tier}"
app_service_plan_sku_size = "${var.app_service_plan_sku_size}"
}
# variables.tf
variable "environment_prefix" {
description = "The prefix for the environment."
type = "string"
default = "d"
}
variable "location" {
description = "Specifies the supported Azure location where the resource exists. Changing this forces a new resource to be created."
type = "string"
default = "eastus"
}
variable "tags" {
description = "The tags to associate with the resources."
type = "map"
default = {
"terraform" = "true"
}
}
variable "app_service_plan_sku_tier" {
type = "string"
description = "Specifies the plan's pricing tier."
default = "Shared" # Shared | Basic | Standard | ...
}
variable "app_service_plan_sku_size" {
type = "string"
description = "Specifies the plan's instance size."
default = "D1" # D1 | B1 | S1 | ...
}
Web Services Environments
The Web Services contain a significant amount more infrastructure than the single-page application does. This is mainly to accommodate the required database for the application and some configuration secrets.
# main.tf
terraform {
backend "azurerm" {
container_name = "terraform"
key = "tfbackend"
}
}
provider "azurerm" {
version = "=1.20.0"
}
module "rg" {
source = "../modules/rg"
environment_prefix = "${var.environment_prefix}"
location = "${var.location}"
tags = "${var.tags}"
}
module "db" {
source = "../modules/db"
environment_prefix = "${var.environment_prefix}"
location = "${module.rg.location}"
tags = "${var.tags}"
resource_group_name = "${module.rg.name}"
db_sku = "${var.db_sku}"
db_capacity = "${var.db_capacity}"
db_tier = "${var.db_tier}"
db_family = "${var.db_family}"
db_storage_mb = "${var.db_storage_mb}"
db_backup_retention_days = "${var.db_backup_retention_days}"
db_geo_redundant_backup = "${var.db_geo_redundant_backup}"
db_administrator_login = "${var.db_administrator_login}"
db_administrator_login_password = "${var.db_administrator_login_password}"
db_version = "${var.db_version}"
db_ssl_enforcement = "${var.db_ssl_enforcement}"
}
module "api" {
source = "../modules/api"
environment_prefix = "${var.environment_prefix}"
resource_group_name = "${module.rg.name}"
location = "${module.rg.location}"
tags = "${var.tags}"
app_service_plan_sku_tier = "${var.app_service_plan_sku_tier}"
app_service_plan_sku_size = "${var.app_service_plan_sku_size}"
}
module "kv" {
source = "../modules/kv"
environment_prefix = "${var.environment_prefix}"
location = "${var.location}"
tags = "${var.tags}"
resource_group_name = "${module.rg.name}"
tenant_id = "${var.tenant_id}"
azuredevops_object_id = "${var.azuredevops_object_id}"
key_vault_secret_1 = "${var.key_vault_secret_1}"
key_vault_secret_2 = "${var.key_vault_secret_2}"
}
module "storage" {
source = "../modules/storage"
environment_prefix = "${var.environment_prefix}"
location = "${var.location}"
tags = "${var.tags}"
resource_group_name = "${module.rg.name}"
}
# variables.tf
variable "environment_prefix" {
description = "The prefix for the environment."
type = "string"
default = "d" # this is different for each environment
}
variable "location" {
description = "Specifies the supported Azure location where the resource exists. Changing this forces a new resource to be created."
type = "string"
default = "eastus"
}
variable "tags" {
description = "The tags to associate with the resources."
type = "map"
default = {
"terraform" = "true"
}
}
variable "tenant_id" {
type = "string"
description = "The Azure Active Directory tenant ID that should be used for authenticating requests to the key vault."
default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
variable "azuredevops_object_id" {
default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
variable "adminuser_object_id" {
default = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
variable "app_service_plan_sku_tier" {
type = "string"
description = "Specifies the plan's pricing tier."
default = "Shared" # Shared | Basic | Standard | ...
}
variable "app_service_plan_sku_size" {
type = "string"
description = "Specifies the plan's instance size."
default = "D1" # D1 | B1 | S1 | ...
}
variable "db_sku" {
type = "string"
description = "Specifies the SKU Name for this PostgreSQL Server. The name of the SKU, follows the tier + family + cores pattern (e.g. B_Gen4_1, GP_Gen5_8)."
default = "B_Gen5_1"
}
variable "db_capacity" {
type = "string"
description = "The scale up/out capacity, representing server's compute units."
default = 1
}
variable "db_tier" {
type = "string"
description = "The tier of the particular SKU. Possible values are Basic, GeneralPurpose, and MemoryOptimized. "
default = "Basic"
}
variable "db_family" {
type = "string"
description = "The family of hardware Gen4 or Gen5, before selecting your family check the product documentation for availability in your region."
default = "Gen5"
}
variable "db_storage_mb" {
type = "string"
description = "Max storage allowed for a server. Possible values are between 5120 MB(5GB) and 1048576 MB(1TB) for the Basic SKU and between 5120 MB(5GB) and 4194304 MB(4TB) for General Purpose/Memory Optimized SKUs."
default = 51200
}
variable "db_backup_retention_days" {
type = "string"
description = "Backup retention days for the server, supported values are between 7 and 35 days."
default = 7
}
variable "db_geo_redundant_backup" {
type = "string"
description = "Enable Geo-redundant or not for server backup. Valid values for this property are Enabled or Disabled, not supported for the basic tier."
default = "Disabled"
}
variable "db_administrator_login" {
type = "string"
description = "The Administrator Login for the PostgreSQL Server. Changing this forces a new resource to be created."
default = "adminuser"
}
variable "db_administrator_login_password" {
type = "string"
description = "The Administrator Login's password for the PostgreSQL Server. Required to be passed in."
}
variable "db_version" {
type = "string"
description = "Specifies the version of PostgreSQL to use. Valid values are 9.5, 9.6, and 10.0. Changing this forces a new resource to be created."
default = "10.0"
}
variable "db_ssl_enforcement" {
type = "string"
description = "Specifies if SSL should be enforced on connections. Possible values are Enabled and Disabled."
default = "Enabled"
}
variable "key_vault_secret_1" {
type = "string"
description = "The first secret"
}
variable "key_vault_secret_2" {
type = "string"
description = "The second secret"
}
Setup and Destroy Scripts
For those of you who were paying attention, you’ll notice that I have two
scripts in each of the environment folders: setup.bat
and destroy.bat
. Each
of these files are ignored in the .gitignore
file, as they contain Azure
secrets, but they’re useful for while I was developing the solutions. The
contents can be seen below.
rem setup.bat
@ECHO OFF
set ARM_ACCESS_KEY=xxxxx
terraform init -backend-config="storage_account_name=dterraformstorage"
terraform fmt
terraform validate
rem destroy.bat
@ECHO OFF
set ARM_ACCESS_KEY=xxxxx
terraform init -backend-config="storage_account_name=dterraformstorage"
terraform destroy
Azure Release Pipeline
I detailed the source-controlled Build Pipelines above, but I explicitly left out the Release Pipelines in the conversation. Azure Pipelines currently has no support for configuration-as-code for Release Pipelines yet. While this is a detriment to the product offering as a whole, the web UI of the Release Pipeline is generally a good one.
Single-Page Application Release Pipeline
There exists a single Release Pipeline for the entire single-page application, each with a number of stages defined. Within each of these stages, a number of tasks are run.
SPA Release Pipeline Stages
A given stage can be seen as the functional equivalent to an environment.
The image defines a few paths that a given set of artifacts from the Build Pipeline can take:
- Automatically deployed to the Development stage if it originates from the
develop
branch - Manually deployed to any of the three Testing environments
- Automatically deployed to the Staging stage if it originates from the
master
branch. It can then get manually elevated to the Production stage if team members approve the release.
SPA Release Pipeline Stage Tasks
You’ll notice that each of the stages have four tasks: each of these tasks is generally the same, minus a few environment variables.
These tasks are relatively straightforward. Note that the Terraform tasks are added from the Marketplace.
- Download the Terraform binaries
- Initialize the Terraform backend (using
azurerm
in this case)
- Apply the Terraform configuration to the specified environment
- Deploy the
dist
directory (the built application) into the previously-created App Service
Web Services Release Pipeline
The web services release pipeline is very similar to the single-page application’s release pipeline in its concepts. There just happen to be a few more steps, specifically related to the database automation.
WS Release Pipeline Stages
Each of the stages below, just like the previous release pipeline, is analogous to a given environment for the application.
The paths an artifact can take are the same as the single-page application:
- Automatically deployed to the Development stage if it originates from the
develop
branch - Manually deployed to any of the three Testing environments
- Automatically deployed to the Staging stage if it originates from the
master
branch. It can then get manually elevated to the Production stage if team members approve the release.
WS Release Pipeline Stage Tasks
Each of the stages for this Release Pipeline has six tasks. Each of the stages are the same six steps, just with some differences in the environment variables.
The tasks for this application’s release pipeline can be seen below.
- Download the Terraform binaries
- Initialize the Terraform backend (using
azurerm
in this case)
- Apply the Terraform configuration to the specified environment
- Run the database migrator application
- Run the database seeder application
- Deploy the packaged web application into the previously-created App Service
Next Steps
Sorry, this got really long.
As stated in the previous sections, there’s still a bit of work to do from the application development teams with respect to automated testing. The pipelines detailed above allow for easy adaptation to utilize any of the testing frameworks needed.
There are a number of small improvements or refactorings that can be done across the application tiers, but those will be pushed to later in the project’s lifecycle.