Creating reusable modules with Bicep, Terraform or Pulumi

Over the years, I’ve been working with several infrastructure as code tools. One of the things that I always find essential, no matter the tool, is to write readable and maintainable code. One way to do that is to create a proper structure for what you are building. All of the IaC tools I have used allow you to modularize your code somehow. A module is a piece of code responsible for one specific thing. You could think of creating a module that deploys a Key Vault always in one particular, compliant way. These modules allow for code reuse, even across a project, and make the whole solution easier to read, understand and maintain. This blog will look at creating and using these components in Bicep, terraform, and Pulumi.

Bicep

Let’s start with Bicep. Bicep is a Domain Specific Language (DSL) that compiles to ARM templates and is created by Microsoft. The predecessor of Bicep is ARM Templates. Creating a modularized setup using that was quite a challenge. One needed to work with what is called linked templates. From one template, you could refer to another one. However, the hard part was that these templates had to live somewhere they could be reached over HTTPS. Often that meant storing them on a storage account in Azure. Creating a module in Bicep is a lot easier. All you need to do is create a new file. That files can then be referenced by another Bicep file that now can be a local file. Let’s first create a module that creates an Azure Web App. We will create a file called ‘appService.bicep’ and add the following code to it:

param name string
param location string

resource appServicePlan 'Microsoft.Web/serverfarms@2019-08-01' = {
  name: 'asp-${name}'
  location: location
  sku: {
    name: 'B1'
    capacity: 1
  }
}

resource webApplication 'Microsoft.Web/sites@2018-11-01' = {
  name: 'app-${name}'
  location: location
  properties: {
    serverFarmId: appServicePlan.id

    siteConfig: {
      netFrameworkVersion: 'v6.0'
    }
  }
}

output webApplicationUrl string = webApplication.properties.defaultHostName

This module creates an App Service Plan and a Web Application. It takes two parameters: the name of the App Service Plan and the location where it should be created. It also outputs its default hostname that we could use as input for other modules. We can now create a new Bicep file that uses this module. We will call it ‘main.bicep’ and add the following code to it:

targetScope = 'subscription'

param location string = 'westeurope'

resource resourcegroup 'Microsoft.Resources/resourceGroups@2021-04-01' = {
  name: 'rg-bicep-article'
  location: location
}

module appService 'Modules/appService.bicep' = {
  scope: resourcegroup
  name: 'appService'
  params: {
    name: 'bicep-article'
    location: location
  }
}

// Output the URL of the Web Application
output webApplicationUrl string = appService.outputs.webApplicationUrl

Using a module is quite simple. Instead of the ‘resource’ keyword you would use while creating a resource, you now use the ‘module’ keyword. You need to specify the path to the module and the name of the module. You can also pass parameters to the module. In this case, we pass the name and location of the App Service Plan. The module also outputs the default hostname of the Web Application. We can now use this output in our main Bicep file. We can now deploy this Bicep file and see that the App Service Plan and Web Application are created.

// TODO: Refer

Terraform

Terraform is another IaC tool that is created by HashiCorp. Terraform is also relatively easy to use for creating reusable modules. You can create a module by creating a new folder and adding a ‘main.tf’ file. Let’s create a module that creates an Azure Web App. We will create a folder called ‘website’ and add the following code to the ‘main.tf’ file:

resource "azurerm_service_plan" "app_service_plan" {
  name                = "asp-${var.project_name}-${var.environment}"
  resource_group_name = var.resource_group_name
  location            = var.location
  os_type             = "Linux"
  sku_name            = "B1"
}

resource "azurerm_linux_web_app" "app_service" {
  name                      = "app-${var.project_name}-${var.environment}"
  resource_group_name       = var.resource_group_name
  location                  = var.location
  service_plan_id           = azurerm_service_plan.app_service_plan.id

  site_config {}
}

Unlike in Bicep, in Terraform, we can use a different file for the parameters. We will create a file called ‘variables.tf’ and add the following code to it:

variable "environment" {
  type = string
}

variable "project_name" {
  type = string
}

variable "location" {
  type = string
}

variable "resource_group_name" {
  type = string
}

The same goes for the outputs. We will create a file called ‘outputs.tf’ and add the following code to it:

output "hostname" {
  value = azurerm_linux_web_app.app_service.default_hostname
}

Using this module is easy. In our main.tf in the root we can now add the following code:

locals {
  project_name = "tf-article"
  location     = "westeurope"
}

resource "azurerm_resource_group" "rg" {
  name     = "rg-${local.project_name}"
  location = local.location
}

module "website" {
  source               = "./modules/website"

  project_name         = local.project_name
  environment          = var.envirnment
  location             = local.location
  resource_group_name  = azurerm_resource_group.rg.name
}

Pulumi

Creating a module in Pulumi is done by creating a new class that inherits from the ComponentResource class. Let’s create a module and examine what that contains. We will create a new class called ‘AppService’ and add the following code to it:

using System.Collections.Generic;
using Pulumi;
using Pulumi.AzureNative.Web;
using Pulumi.AzureNative.Web.Inputs;

class AppService : Pulumi.ComponentResource
{

    [Output("AppServiceEndpoint")]
    public Output<string> AppServiceEndpoint { get; private set; }

    public AppService(string name, AppServiceArgs args, ComponentResourceOptions? opts = null)
        : base("azure:custom:appservice", name, opts)
    {
        var appServicePlan = new AppServicePlan($"asp-{name}", new AppServicePlanArgs
        {
            ResourceGroupName = args.ResourceGroupName,
            Kind = "App",
            Sku = new SkuDescriptionArgs
            {
                Tier = "Basic",
                Name = "B1",
            },
        }, new Pulumi.CustomResourceOptions { Parent = this });

        var app = new WebApp($"app-{name}", new WebAppArgs
        {
            ResourceGroupName = args.ResourceGroupName,
            ServerFarmId = appServicePlan.Id
        }, new Pulumi.CustomResourceOptions { Parent = this });

        AppServiceEndpoint = app.DefaultHostName;

        this.RegisterOutputs();
    }
}

This creates an App Service Plan and a Web Application like the other two examples. The entry point of the module is the constructor of the class. The constructor takes a name, an input object, and a ComponentResourceOptions object. The name is used to create a unique name for the resources that are created. The input object is used to pass parameters to the module. In this case, that object looks like this:

using Pulumi;

public sealed class AppServiceArgs : ResourceArgs
{
    [Input("resourceGroupName", true, false)]
    public Input<string> ResourceGroupName { get; set; }
}

The input object is a class that inherits from the ResourceArgs class. The input object contains a property called ‘ResourceGroupName’. This property is marked with the ‘Input’ attribute. The first parameter of the attribute is the name of the property. The second parameter is a boolean that indicates if the property is required. The third parameter is a boolean that indicates if the property is a secret.

The ComponentResourceOptions object can, for example, be used to specify the module’s parent. That allows you to set a hierarchy for the created resources. You also see this in action in all resources created in this module, for example, on the last line of the App Service creation. The constructor creates the App Service Plan and the Web Application. It also sets the output of the module. The output is a property called ‘AppServiceEndpoint’. This property is marked with the ‘Output’ attribute. The last line of the constructor calls the ‘RegisterOutputs’ method. This method is used to register the outputs of the module and tell Pulumi that the creation of the resources by this module is done. This ComponentResourceOptions can also be used to set a DependsOn when the creation of the resources in the module depends on the creation of other resources, and Pulumi could not automatically determine this dependency.

Calling the module is done by creating a new instance of the class. For example:

var app = new AppService("pulumi-article", new AppServiceArgs
{
    ResourceGroupName = resourceGroup.Name
});

Returning the module’s output is done by accessing the property of the module. For example:

return new Dictionary<string, object?>
{
    ["AppServiceEndpoint"] = app.AppServiceEndpoint
};