← All posts

Terraform and Microsoft Fabric in practice: a medallion in a single apply

Step-by-step demo with video: a capacity, 9 workspaces, 36 Entra ID groups and their roles in Microsoft Fabric with Terraform. Plan, apply, drift and destroy.

DevOpsFabricArchitecture Translated from the Spanish original

In the previous article we covered the theory: what Infrastructure as Code is, how to write a resource, what state is and why you read the plan before every apply.

Today it’s the demo. The brief: the medallion for a project called “ventas” (sales). Three layers (bronze, silver and gold) times three environments (dev, test and prod), each workspace with its four security groups and its four roles. And the capacity underneath, which doesn’t exist yet either.

By hand that would be nine workspaces, 36 groups and 36 role assignments. With Terraform it’s one apply.

This is the video of the demo Alfonso Ming and I did at NetCoreConf.

The video has no sound: I walk you through every step below.

The code is at github.com/marlizana/FabricAsCode.


Step 0: there’s nothing there

Before starting, we check the starting point is empty:

  • Fabric: filter the workspace list by “ventas”. Nothing comes up.
  • Azure: under Fabric capacities there’s no capacity for the project.
  • Entra ID: search for the sg-fbc-ventas groups. None.

Step 1: the Terraform code, top to bottom

The repo looks like this:

FabricAsCode/
├── versions.tf          # providers and versions
├── main.tf              # wires the pieces together
├── variables.tf
├── outputs.tf
├── environments/        # one .tfvars per scenario
├── modules/
│   ├── capacity/
│   ├── workspace-with-roles/
│   └── templates/medallion-cicd/
└── scripts/demo.ps1     # shortcuts for the demo

versions.tf: the providers

terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
    azuread = { source = "hashicorp/azuread", version = "~> 3.0" }
    fabric  = { source = "microsoft/fabric",  version = "~> 1.14" }
  }
}

provider "azurerm" { features {} }
provider "azuread" {}
provider "fabric" {}

azurerm creates the capacity, azuread the groups and fabric the workspaces and roles. The provider blocks are empty on purpose: the service principal’s credentials come in through environment variables.

environments/ventas.tfvars: the brief

capacity_name = "fbcventas"
capacity_sku  = "F2"
region        = "spaincentral"

template     = "medallion-cicd"
project_name = "ventas"
layers       = ["bronze", "silver", "gold"]
environments = ["dev", "test", "prod"]

admin_group_members = [
  "mar@contoso.onmicrosoft.com",
  "alfonso@contoso.onmicrosoft.com",
]

Everything you change from one project to the next lives here: the capacity size, the region, the layers, the environments and who goes into the Admin groups. For a new project you copy this file and leave the code alone.

main.tf: wires the pieces together

It calls the capacity module and the medallion-cicd template, and passes the capacity ID to the template.

modules/capacity/main.tf: the Fabric capacity

A resource group and an azurerm_fabric_capacity with SKU F2. And a detail that cost me a failed plan:

# The Azure ID isn't the Fabric ID: this data source bridges the two.
data "fabric_capacity" "this" {
  display_name = azurerm_fabric_capacity.this.name
}

azurerm returns the Azure resource ID, but a workspace expects the Fabric GUID. The data source looks the capacity up by name, and that gives you the ID the workspace needs.

modules/templates/medallion-cicd/main.tf: the combinations

locals {
  workspace_specs = {
    for pair in setproduct(var.layers, var.environments) :
    "${pair[0]}-${pair[1]}" => { layer = pair[0], env = pair[1] }
  }
}

module "workspace" {
  source   = "../../workspace-with-roles"
  for_each = local.workspace_specs

  workspace_name  = "${var.project_name}-${each.value.layer}-${each.value.env}"          # ventas-bronze-dev
  ad_group_prefix = "sg-fbc-${var.project_name}-${each.value.layer}-${each.value.env}"   # sg-fbc-ventas-bronze-dev
  capacity_id     = var.capacity_id
  admin_members   = var.admin_members
}

setproduct combines the two lists into nine layer-environment pairs. For each one, the same module is called with its name and its group prefix.

modules/workspace-with-roles/main.tf: workspaces, groups and roles

resource "fabric_workspace" "this" {
  display_name = var.workspace_name
  capacity_id  = var.capacity_id
}

resource "azuread_group" "roles" {
  for_each         = var.roles   # Admin, Contributor, Member, Viewer
  display_name     = "${var.ad_group_prefix}-${lower(each.value)}"
  security_enabled = true
  members          = each.value == "Admin" ? var.admin_members : null
}

resource "fabric_workspace_role_assignment" "this" {
  for_each     = azuread_group.roles
  workspace_id = fabric_workspace.this.id
  role         = each.key
  principal    = { id = each.value.object_id, type = "Group" }
}

One workspace, one group per role and an assignment tying each group to its role. This module, called nine times, is what produces the 36 groups and the 36 assignments.


Step 2: terraform plan

With the credentials in environment variables:

terraform init
terraform plan "-var-file=environments/ventas.tfvars" "-out=ventas.tfplan"

Heads up: in Windows PowerShell 5.1, quote arguments that contain a dot. Without quotes, -var-file=environments/ventas.tfvars gets split in two and Terraform complains about “Too many command line arguments”.

In the plan, I’d stop at four places: the capacity, a workspace, a group and a role assignment. Then at the last line:

Plan: 84 to add, 0 to change, 0 to destroy.

The 84 are nine workspaces, 36 groups, 36 assignments, the capacity, its resource group and the random suffix in its name. No changes and no deletions, which is what you expect the first time.


Step 3: terraform apply

terraform apply ventas.tfplan

The capacity goes first, and it takes a couple of minutes. Then, in parallel, the groups and the workspaces. At the end:

Apply complete! Resources: 84 added, 0 changed, 0 destroyed.

While it runs, there’s time for a coffee.


Step 4: what it created

  • Azure: the capacity, in its own resource group.
  • Entra ID: search for sg-fbc-ventas and 36 groups come up. In sg-fbc-ventas-silver-prod-admin, Members tab, there’s Alfonso and me.
  • Fabric: filter by “ventas” and the nine workspaces come up. In ventas-silver-prod, Manage access shows the four groups, each with its role. And under Workspace settings → License, the capacity.

You get nine workspaces with the same naming pattern and exactly the same permissions.


Step 5: running the plan again

What happens if I run plan again without changing anything?

No changes. Your infrastructure matches the configuration.

Nothing. As I said in the theory, apply behaves like a MERGE: you can run it ten times and you still have nine workspaces.


Step 6: drift, someone touched the portal

Now I play the colleague in a hurry: I go into ventas-silver-prod, Manage access, and remove the sg-fbc-ventas-silver-prod-viewer group. “Just for a moment, to test something”.

Plan again:

  # module.medallion_cicd[0].module.workspace["silver-prod"].fabric_workspace_role_assignment.this["Viewer"] will be created
  + resource "fabric_workspace_role_assignment" "this" { ... }

Plan: 1 to add, 0 to change, 0 to destroy.

Terraform checked Fabric, saw the assignment I’d removed was gone and wants to create it again. One apply and the group has its role back.


Step 7: one more environment in one line

What if tomorrow you need QA? You add it to the .tfvars:

environments = ["dev", "test", "qa", "prod"]

And the plan:

Plan: 27 to add, 0 to change, 0 to destroy.

Three workspaces (QA bronze, silver and gold), twelve groups and twelve assignments, with the same pattern as the rest. The environments you already had aren’t touched.


Step 8: terraform destroy

When the project, the demo or the proof of concept is over:

terraform plan -destroy "-var-file=environments/ventas.tfvars" "-out=borrar.tfplan"
terraform apply borrar.tfplan

Read the plan first: it should say 84 to destroy and nothing else. You lose the workspaces, the groups and the capacity, and once the capacity’s gone you’re not paying for it.

Heads up: with the capacity paused, the Fabric provider won’t let you plan, apply or destroy. Resume it first and wait until it’s Active.


The demo script

In the repo you’ll find scripts/demo.ps1. It wraps these same commands so I don’t have to type them in front of 200 people.

ShortcutWhat it does
. .\scripts\demo.ps1 envLoads the credentials into the session (with the dot in front)
.\scripts\demo.ps1 initterraform init and the state workspace
.\scripts\demo.ps1 planPlan with the scenario’s .tfvars
.\scripts\demo.ps1 applyPlan to a file, confirmation and apply of that file
.\scripts\demo.ps1 driftPlan to see what changed outside Terraform
.\scripts\demo.ps1 qa / sin-qaAdds or removes the QA environment
.\scripts\demo.ps1 pausa / reanudaPauses or resumes the capacity
.\scripts\demo.ps1 destroyDestroy plan, confirmation and destroy

Before and after

BeforeAfter
An afternoon of clicking per projectAn apply that takes a few minutes
An outdated Word file as documentationThe code describes how the platform is set up
QA and prod similar, but not the sameEvery environment comes from the same module
“Who removed this role?” with no answerDrift shows up in the next plan

The next project starts by copying a .tfvars instead of opening the portal. If you try it with the repo, tell me in the comments how it went.

Pris, my cat, watched the whole video and her only contribution was sitting on the keyboard during the apply. Luckily I was already applying the saved plan.


Found it useful? Clap, no account needed. You can clap more than once.

Comments