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.
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-ventasgroups. 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.tfvarsgets 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-ventasand 36 groups come up. Insg-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.
| Shortcut | What it does |
|---|---|
. .\scripts\demo.ps1 env | Loads the credentials into the session (with the dot in front) |
.\scripts\demo.ps1 init | terraform init and the state workspace |
.\scripts\demo.ps1 plan | Plan with the scenario’s .tfvars |
.\scripts\demo.ps1 apply | Plan to a file, confirmation and apply of that file |
.\scripts\demo.ps1 drift | Plan to see what changed outside Terraform |
.\scripts\demo.ps1 qa / sin-qa | Adds or removes the QA environment |
.\scripts\demo.ps1 pausa / reanuda | Pauses or resumes the capacity |
.\scripts\demo.ps1 destroy | Destroy plan, confirmation and destroy |
Before and after
| Before | After |
|---|---|
| An afternoon of clicking per project | An apply that takes a few minutes |
| An outdated Word file as documentation | The code describes how the platform is set up |
| QA and prod similar, but not the same | Every environment comes from the same module |
| “Who removed this role?” with no answer | Drift 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.
Useful links
Found it useful? Clap, no account needed. You can clap more than once.
Comments