Azure and Terraform

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.
  • App Service Plan
  • Application Insights
  • App Service
Database db Defines the PostgreSQL server and database.
  • PostgreSQL Server
  • PostgreSQL Firewall Rules
  • PostgreSQL Database
Key Vault kv Defines the secret store and the default secrets.
  • Key Vault
  • Key Vault Access Policies
  • Key Vault Secrets
Resource Group rg Defines the resource group for the given environment that contains all the Azure resources.
  • Resource Group
Storage storage Defines the storage account used by the application.
  • Storage Account
Web web Defines the Single-Page Application hosting services.
  • App Service Plan
  • Application Insights
  • App Service

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.

Single-Page Application Release Pipeline Stages

Single-Page Application Release Pipeline Stages

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.

Single-Page Application Release Pipeline Tasks

Single-Page Application Release Pipeline Tasks

These tasks are relatively straightforward. Note that the Terraform tasks are added from the Marketplace.

  • Download the Terraform binaries
Single-Path Application Release Pipeline: Install Terraform

Single-Path Application Release Pipeline: Install Terraform

  • Initialize the Terraform backend (using azurerm in this case)
Single-Path Application Release Pipeline: Initialize Terraform

Single-Path Application Release Pipeline: Initialize Terraform

  • Apply the Terraform configuration to the specified environment
Single-Path Application Release Pipeline: Apply Terraform

Single-Path Application Release Pipeline: Apply Terraform

  • Deploy the dist directory (the built application) into the previously-created App Service
Single-Path Application Release Pipeline: Deploy

Single-Path Application Release Pipeline: Deploy

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.

Web Services Release Pipeline Stages

Web Services Release Pipeline Stages

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.

Web Services Release Pipeline Tasks

Web Services Release Pipeline Tasks

The tasks for this application’s release pipeline can be seen below.

  • Download the Terraform binaries
Web Services Release Pipeline: Install Terraform

Web Services Release Pipeline: Install Terraform

  • Initialize the Terraform backend (using azurerm in this case)
Web Services Release Pipeline: Initialize Terraform

Web Services Release Pipeline: Initialize Terraform

  • Apply the Terraform configuration to the specified environment
Web Services Release Pipeline: Apply Terraform

Web Services Release Pipeline: Apply Terraform

  • Run the database migrator application
Web Services Release Pipeline: Run Migrator

Web Services Release Pipeline: Run Migrator

  • Run the database seeder application
Web Services Release Pipeline: Run Seeder

Web Services Release Pipeline: Run Seeder

  • Deploy the packaged web application into the previously-created App Service
Web Services Release Pipeline: Deploy

Web Services Release Pipeline: Deploy

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.