Object Oriented Your Azure Infrastructure with Cloud Development Kit for Terraform (CDKTF)
Published Jun 07 2022 01:51 AM 6,509 Views
Iron Contributor

Introduction

The beauty of cloud computing is “Everything as Code”, and we can get rid of the physical constraint to create and manage IT infrastructure as code (IasC). At this moment, there are many IasC tools for Azure. Most of them are in descriptive style and like HTML which is a template and rendering engine creates the elements or resources.

This direction facilitates non-programmer users to pick up the tools easily. Users are hard to adopt software engineering technique to build a high quality IasC software. For experience programmers, they feel low level template code is leak of function and hard to maintain.

This post will show you the way to create Azure Infrastructure in TypeScript with Object Oriented programming style.

 

Brief Introduction of CDKTF

Cloud Development Kit for Terraform (CDKTF) allows you to use familiar programming languages to define and provision infrastructure. This gives you access to the entire Terraform ecosystem without learning HashiCorp Configuration Language (HCL) and lets you leverage the power of your existing toolchain for testing, dependency management, etc.”

The CDKTF framework is in TypeScript and developers can use JSII to create bundle for other programming languages such as C#, Java, Python, and Go. However, I do suggest you use TypeScript directly, as you will find it more resources from internet.

The second point is very powerful! CDKTF is just a wrapper generator for Terraform providers and you can manage everything multi-cloud, DevOps tools, database, …. So, CDKTF can do everything Terraform can do plus the capability comes from TypeScript. You can find basic information easily and I will not repeat it here.

 

Using prebuilt package or generate your own from provider?

If you follow the official guideline and tutorial, after you initialize a CDKTF project, it asks you to install prebuild packages.

cyruswong_0-1654576829110.png

This is for convenient and works fine for most of the cases, but it may not be up-to-date all the time. For example, the latest version azurerm is 3.9.0 but the pre-built package is still 2.x.x. Therefore, I don’t use the pre-built package for Azure development. Also, you sometimes need azuread provider to manage Azure Active Directory, and you need to generate yourself.

Read my blog post to take generate wrapper.

https://www.linkedin.com/pulse/cdk-tf-trick-create-azure-ad-resources-wong-chun-yin-cyrus-%E9%BB%83%...

Don’t forget this line of code change!

 

const app = new App({skipValidation: true})

 

 

Prerequisite

You need to install and configure Terraform.

https://docs.microsoft.com/en-us/azure/developer/terraform/quickstart-configure

Run this command

 

az config set extension.use_dynamic_install=yes_without_prompt

 

Install and setup CDKTF

https://learn.hashicorp.com/tutorials/terraform/cdktf-install?in=terraform/cdktf

 

Code Structure

There are 2 basic packages cdktf-azure-providers and azure-common-construct.

cdktf-azure-providers is the generated wrapper for 7 common providers:

  1. "azurerm@~> 3.7.0"
  2. "azuread@~>2.22.0"
  3. "random@~>3.2.0"
  4. "null@~>3.1.1"
  5. "external@~>2.2.2"
  6. "archive@~>2.2.0"
  7. "http@~>2.1.0"

azure-common-construct contains some common Azure L3 CDK-TF patterns. L3 constructs define common design patterns and larger pieces of functionality. For example, you could create a custom L3 construct that configures all of the necessary resources to deploy and host a static website frontend.

  1. AzureFunctionLinuxConstruct – C# Azure function in Linux with consumption plan and handle publishing application.
  2. AzureIotConstruct - Azure IoTHub and return primary connection string.
  3. AzureIotEventHubConstruct - Child class of AzureIotConstruct, added Event hub sink and return EventHub primary connection string.
  4. AzureIotDeviceConstruct – Using terraform external data provider and Azure CLI to create Azure IoT Device and return device key.
  5. AzureStaticConstainerConstruct - Build static Docker image and put it in ACR.

Do remember that behind the scenes, some virtual construct which is not supported by Terraform directly, we can use Azure CLI to simulate as construct.

Dependency is

Your CDKTF project -> azure-common-construct -> cdktf-azure-providers

However, you can also use L1 construct from cdktf-azure-providers directly.

 

import { AzurermProvider } from "cdktf-azure-providers/.gen/providers/azurerm";
….
    new AzurermProvider(this, "AzureRm", {
      features: {}
    })

 

I don’t think there is any L2 construct for CDKTF Azure. L1 is the low-level API wrapping Terraform and it is not strongly typing. For example, it still uses string from a lot of settings such as sku. L2 construct should at least convert all options to Enum and prevents user from have typo error. Hope there will be L2 construct for Azure in the coming future.

 

Code Sample

Azure Hybrid Cloud Lab Environment

cyruswong_0-1654577095619.png

 

https://techcommunity.microsoft.com/t5/educator-developer-blog/azure-hybrid-cloud-lab-environment-is...

 

 

import { Construct } from "constructs";
import { App, TerraformOutput, TerraformStack } from "cdktf";
import { AzurermProvider, ResourceGroup, StorageAccount, StorageQueue, StorageTable } from "cdktf-azure-providers/.gen/providers/azurerm";
import { StringResource } from 'cdktf-azure-providers/.gen/providers/random'
import { DataExternal } from "cdktf-azure-providers/.gen/providers/external";
import { AzureFunctionLinuxConstruct, PublishMode } from "azure-common-construct/patterns/AzureFunctionLinuxConstruct";
import { AzureIotEventHubConstruct } from "azure-common-construct/patterns/AzureIotEventHubConstruct";
import { AzureStaticConstainerConstruct } from "azure-common-construct/patterns/AzureStaticConstainerConstruct";

import * as path from "path";
import * as dotenv from 'dotenv';
dotenv.config({ path: __dirname + '/.env' });

class AzureHybridCloudLabEnvironmentStack extends TerraformStack {
  constructor(scope: Construct, name: string) {
    super(scope, name);

    new AzurermProvider(this, "AzureRm", {
      features: {
        resourceGroup: {
          preventDeletionIfContainsResources: false
        }
      }
    })

    const prefix = "AzureHybridLab"
    const environment = "dev"

    const resourceGroup = new ResourceGroup(this, "ResourceGroup", {
      location: "EastAsia",
      name: prefix + "ResourceGroup"
    })

    const azureIotConstruct = new AzureIotEventHubConstruct(this, "AzureIotEventHubConstruct", {
      environment,
      prefix,
      resourceGroup,
    })

    const azureStaticConstainerConstruct = new AzureStaticConstainerConstruct(this, "AzureStaticConstainerConstruct", {
      environment,
      prefix,
      resourceGroup,
      gitHubUserName: "wongcyrus",
      gitHubRepo: "ssh-tunneling-bastion",
      gitAccessToken: "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"
    })

    const suffix = new StringResource(this, "Random", {
      length: 5,
      special: false,
      lower: true,
      upper: false,
    })
    const storageAccount = new StorageAccount(this, "StorageAccount", {
      name: prefix.toLocaleLowerCase() + environment.toLocaleLowerCase() + suffix.result,
      location: resourceGroup.location,
      resourceGroupName: resourceGroup.name,
      accountTier: "Standard",
      accountReplicationType: "LRS"
    })

    const tables = ["Computer", "SshConnection", "ComputerErrorLog"];
    tables.map(t => new StorageTable(this, t + "StorageTable", {
      name: t,
      storageAccountName: storageAccount.name
    }))
    new StorageQueue(this, "StorageQueue", {
      name: "allocate-pc",
      storageAccountName: storageAccount.name
    })

    const appSettings = {
      "IotHubPrimaryConnectionString": azureIotConstruct.iothubPrimaryConnectionString,
      "EventHubPrimaryConnectionString": azureIotConstruct.eventhubPrimaryConnectionString,
      "EventHubName": azureIotConstruct.eventhub.name,
      "IotHubName": azureIotConstruct.iothub.name,
      "BastionArcAdminUsername": azureStaticConstainerConstruct.containerRegistry.adminUsername,
      "BastionArcAdminPassword": azureStaticConstainerConstruct.containerRegistry.adminPassword,
      "BastionArcLoginServer": azureStaticConstainerConstruct.containerRegistry.loginServer,
      "EmailSmtp": process.env.EMAIL_SMTP!,
      "EmailUserName": process.env.EMAIL_USERNAME!,
      "EmailPassword": process.env.EMAIL_PASSWORD!,
      "EmailFromAddress": process.env.EMAIL_FROM_ADDRESS!,
      "AdminEmail": process.env.ADMIN_EMAIL!,
      "Salt": prefix,
      "StorageAccountName": storageAccount.name,
      "StorageAccountKey": storageAccount.primaryAccessKey,
      "StorageAccountConnectionString": storageAccount.primaryConnectionString
    }

    const azureFunctionConstruct = new AzureFunctionLinuxConstruct(this, "AzureFunctionConstruct", {
      environment,
      prefix,
      resourceGroup,
      appSettings,
      vsProjectPath: path.join(__dirname, "..", "PcHubFunctionApp/"),
      publishMode: PublishMode.Always
    })

    const psScriptPath = path.join(__dirname, "GetFunctionKey.ps1");
    const getDeviceConnectionStringFunctionKeyExternal = new DataExternal(this, "GetDeviceConnectionStringFunctionKeyExternal", {
      program: ["PowerShell", psScriptPath],
      query: {
        resourceGroup: resourceGroup.name,
        functionAppName: azureFunctionConstruct.functionApp.name,
        functionName: "GetDeviceConnectionStringFunction"
      }
    })
    const getDeviceConnectionStringFunctionKey = getDeviceConnectionStringFunctionKeyExternal.result.lookup("FunctionKey")

    const addSshConnectionFunctionKeyExternal = new DataExternal(this, "AddSshConnectionFunctionKeyExternal", {
      program: ["PowerShell", psScriptPath],
      query: {
        resourceGroup: resourceGroup.name,
        functionAppName: azureFunctionConstruct.functionApp.name,
        functionName: "AddSshConnectionFunction"
      }
    })
    const addSshConnectionFunctionKey = addSshConnectionFunctionKeyExternal.result.lookup("FunctionKey")

    new TerraformOutput(this, "GetDeviceConnectionStringFunctionKey", {
      value: getDeviceConnectionStringFunctionKey
    });
    new TerraformOutput(this, "AddSshConnectionFunctionKey", {
      value: addSshConnectionFunctionKey
    });

    new TerraformOutput(this, "FunctionAppHostname", {
      value: azureFunctionConstruct.functionApp.name
    });


    new TerraformOutput(this, "AzureFunctionBaseUrl", {
      value: `https://${azureFunctionConstruct.functionApp.name}.azurewebsites.net`
    });

    new TerraformOutput(this, "LifeCycleHookUrl", {
      value: `https://${azureFunctionConstruct.functionApp.name}.azurewebsites.net/api/AddSshConnectionFunction?code=${addSshConnectionFunctionKey}`
    });

    new TerraformOutput(this, "Environment", {
      value: environment
    });

    new TerraformOutput(this, "AzureWebJobsStorage", {
      sensitive: true,
      value: azureFunctionConstruct.storageAccount.primaryConnectionString
    });

    for (let [key, value] of Object.entries(appSettings)) {
      new TerraformOutput(this, key, {
        sensitive: true,
        value: value
      });
    }

  }
}

const app = new App({ skipValidation: true });
new AzureHybridCloudLabEnvironmentStack(app, "infrastructure");
app.synth();

 

 

Azure Cloud Lab Environment

cyruswong_1-1654577148999.png

 

https://techcommunity.microsoft.com/t5/educator-developer-blog/azure-cloud-lab-environment/ba-p/3251...

 

 

import { Construct } from "constructs";
import { App, TerraformOutput, TerraformStack } from "cdktf";
import { AzurermProvider, ResourceGroup, StorageAccount, StorageQueue, StorageTable, StorageContainer, StorageShare, RoleDefinition, RoleAssignment } from "cdktf-azure-providers/.gen/providers/azurerm";
import { StringResource } from 'cdktf-azure-providers/.gen/providers/random'
import { AzureFunctionLinuxConstruct, PublishMode } from "azure-common-construct/patterns/AzureFunctionLinuxConstruct";
import { AzureStaticConstainerConstruct } from "azure-common-construct/patterns/AzureStaticConstainerConstruct";

import * as path from "path";
import * as dotenv from 'dotenv';
dotenv.config({ path: __dirname + '/.env' });

class AzureCloudLabEnvironmentStack extends TerraformStack {
  constructor(scope: Construct, name: string) {
    super(scope, name);

    new AzurermProvider(this, "AzureRm", {
      features: {}
    })

    const prefix = "AzureCloudLab"
    const environment = "dev"

    const resourceGroup = new ResourceGroup(this, "ResourceGroup", {
      location: "EastAsia",
      name: prefix + "ResourceGroup"
    })
    const terraformResourceGroup = new ResourceGroup(this, "TerraformResourceGroupName", {
      location: "EastAsia",
      name: prefix + "TerraformResourceGroup"
    })

    const azureStaticConstainerConstruct = new AzureStaticConstainerConstruct(this, "AzureStaticConstainerConstruct", {
      environment,
      prefix,
      resourceGroup,
      gitHubUserName: "wongcyrus",
      gitHubRepo: "terraform-azure-cli",
      gitAccessToken: "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",
      branch: "master",
      dockerBuildArguments: {
        "AZURE_CLI_VERSION": "2.37.0",
        "TERRAFORM_VERSION": "1.2.2"
      }
    })

    const suffix = new StringResource(this, "Random", {
      length: 5,
      special: false,
      lower: true,
      upper: false,
    })
    const storageAccount = new StorageAccount(this, "StorageAccount", {
      name: prefix.toLocaleLowerCase() + environment.toLocaleLowerCase() + suffix.result,
      location: resourceGroup.location,
      resourceGroupName: resourceGroup.name,
      accountTier: "Standard",
      accountReplicationType: "LRS"
    })

    const tables = ["OnGoingEvent", "CompletedEvent", "LabCredential", "Deployment", "ErrorLog", "Subscription"];
    tables.map(t => new StorageTable(this, t + "StorageTable", {
      name: t,
      storageAccountName: storageAccount.name
    }))

    const queues = ["start-event", "end-event"];
    queues.map(q =>
      new StorageQueue(this, q + "StorageQueue", {
        name: q,
        storageAccountName: storageAccount.name
      })
    )

    new StorageContainer(this, "LabVariables", {
      name: "lab-variables",
      storageAccountName: storageAccount.name
    })

    new StorageShare(this, "containershare", {
      name: "containershare",
      storageAccountName: storageAccount.name,
      quota: 500,
      accessTier: "Hot"
    })

    const appSettings = {
      "TerraformResourceGroupName": terraformResourceGroup.name,
      "AcrUserName": azureStaticConstainerConstruct.containerRegistry.adminUsername,
      "AcrPassword": azureStaticConstainerConstruct.containerRegistry.adminPassword,
      "AcrUrl": azureStaticConstainerConstruct.containerRegistry.loginServer,
      "CalendarUrl": process.env.CALENDAR_URL!,
      "EmailSmtp": process.env.EMAIL_SMTP!,
      "EmailUserName": process.env.EMAIL_USERNAME!,
      "EmailPassword": process.env.EMAIL_PASSWORD!,
      "EmailFromAddress": process.env.EMAIL_FROM_ADDRESS!,
      "AdminEmail": process.env.ADMIN_EMAIL!,
      "Salt": prefix,
      "StorageAccountName": storageAccount.name,
      "StorageAccountKey": storageAccount.primaryAccessKey,
      "StorageAccountConnectionString": storageAccount.primaryConnectionString
    }

    const azureFunctionConstruct = new AzureFunctionLinuxConstruct(this, "AzureFunctionConstruct", {
      environment,
      prefix,
      resourceGroup,
      appSettings,
      vsProjectPath: path.join(__dirname, "..", "AzureCloudLabEnvironmentFunctionApp/"),
      publishMode: PublishMode.AfterCodeChange
    })

    const runAciRoleDefinition = new RoleDefinition(this, "RunAciRoleDefinition", {
      name: prefix + environment + "run_azure_container_instance",
      scope: terraformResourceGroup.id,
      permissions: [{
        actions: ["Microsoft.Resources/subscriptions/resourcegroups/read",
          "Microsoft.ContainerInstance/containerGroups/read",
          "Microsoft.ContainerInstance/containerGroups/write",
          "Microsoft.ContainerInstance/containerGroups/delete"], notActions: []
      }],
      assignableScopes: [terraformResourceGroup.id]
    })

    new RoleAssignment(this, "RoleAssignment", {     
      scope: terraformResourceGroup.id,
      roleDefinitionId: runAciRoleDefinition.roleDefinitionResourceId,
      principalId: azureFunctionConstruct.functionApp.identity.principalId
    })

    new TerraformOutput(this, "FunctionAppHostname", {
      value: azureFunctionConstruct.functionApp.name
    });


    new TerraformOutput(this, "AzureFunctionBaseUrl", {
      value: `https://${azureFunctionConstruct.functionApp.name}.azurewebsites.net`
    });

  }
}

const app = new App({ skipValidation: true });
new AzureCloudLabEnvironmentStack(app, "infrastructure");
app.synth();

 

 

Conclusion

At this moment, CDKTF for Azure is imperfect as there is not L2 construct available which contains Azure domain knowledge. However, we can still build L3 construct to create reusable code base and create Azure infrastructure in Object Oriented style. “Do everything as code if you can!” and this is also the step to achieve DevSecOps and highly automate everything!

Resources

Azure Hybrid Cloud Lab Environment

https://github.com/wongcyrus/AzureHybridCloudLabEnvironment 

Azure Cloud Lab Environment

https://github.com/wongcyrus/AzureCloudLabEnvironment

cdktf-azure-providers

https://github.com/wongcyrus/cdktf-azure-providers 

azure-common-construct

https://github.com/wongcyrus/azure-common-construct 

 

 

 

2 Comments
Co-Authors
Version history
Last update:
‎Jun 06 2022 10:09 PM
Updated by: