🗂️ Workspaces: varios estados para un mismo código¶
1. Qué es un workspace (y qué comparte)¶
┌── workspace "default" → terraform.tfstate (local)
un solo código ──┼── workspace "ana" → terraform.tfstate.d/ana/terraform.tfstate
un solo backend ├── workspace "pr-142" → terraform.tfstate.d/pr-142/terraform.tfstate
una credencial └── …
con backend azurerm, key = "moodle.tfstate":
default → tfstate/moodle.tfstate
ana → tfstate/moodle.tfstateenv:ana ◄── mismo contenedor, sufijo "env:"
pr-142 → tfstate/moodle.tfstateenv:pr-142
| Cambia por workspace | Es común a todos los workspaces |
|---|---|
El archivo de estado y su lock · el valor de terraform.workspace en el código · los recursos desplegados (si el código los nombra distinto) |
El código .tf · el bloque backend (cuenta, contenedor, credenciales) · la configuración del provider (suscripción, tenant) · la versión de Terraform y providers (.terraform.lock.hcl) · el directorio .terraform/ |
⚠️ Los nombres de los recursos deben incluir el workspace. Si el código dice
name = "rg-moodle-001", el segundo workspace intentará crear otro grupo con el mismo nombre en la misma suscripción: para grupos de recursos lo "adoptará" silenciosamente y los dos estados creerán ser dueños del mismo objeto; para cuentas de almacenamiento fallará con already taken. Todo nombre lleva${terraform.workspace}o un derivado.
2. Comandos¶
| Comando | Qué hace | Detalle que importa |
|---|---|---|
workspace list |
Lista; el activo lleva * |
Con backend azurerm lista los blobs …env:* del contenedor |
workspace new ana |
Crea un estado vacío y lo selecciona | El select posterior del original era redundante. -state=archivo lo crea a partir de un estado existente |
workspace select ana |
Cambia el activo | -or-create (≥ 1.4) lo crea si no existe: el comando de CI |
workspace show |
Imprime el activo | Ponlo en el prompt de la shell si trabajas con varios |
workspace delete ana |
Borra el estado | Rechaza si el estado no está vacío (destroy antes) o si está activo. -force borra el estado y deja los recursos huérfanos en Azure. default no se puede borrar |
TF_WORKSPACE=ana terraform plan |
Fija el workspace para ese comando sin cambiar el activo | Lo que usa un pipeline: sin estado "activo" que dependa del runner. Debe existir ya (o select -or-create antes) |
terraform.workspace |
Expresión: nombre del activo | Válido en locals, nombres, tags. No en el bloque backend |
3. Workspaces o directorio por entorno¶
El original proponía "un workspace por entorno" y a la vez un directorio por entorno. Son dos patrones distintos y hay que elegir. La regla práctica: si dos copias de la infraestructura deben tener distinta suscripción, distintos permisos o distinta gente que puede aplicar, son directorios. Si son copias equivalentes del mismo equipo, son workspaces.
| Criterio | Workspaces | Directorio por entorno (página 7.5) |
|---|---|---|
| Suscripción / tenant | La misma para todos (el provider es común) |
Una por entorno: pro en su propia suscripción |
| Permisos sobre el estado | Mismo contenedor: quien lee dev lee pro (salvo ABAC, 10.7) |
Contenedor o cuenta distintos; RBAC distinto; OIDC con subject distinto |
| Riesgo de aplicar en el sitio equivocado | Alto: un select olvidado y el apply va a pro |
Bajo: estás en otro directorio con otro backend |
| Diferencias entre copias | Solo tamaños y cantidades (mapa por workspace). Recursos que existen en uno y no en otro: count condicional, se vuelve ilegible |
Cada directorio compone módulos como quiera; pro puede tener Front Door y dev no |
| Versiones de módulos | La misma para todos: no puedes probar el módulo v2 en dev con pro en v1 |
source = "…?ref=v2.0.0" por directorio: promoción gradual |
| Crear y destruir copias | Un comando: ideal para lo efímero | Un directorio nuevo + backend + pipeline: pesado para lo efímero |
| Úsalo para | Sandbox por desarrollador, entorno por PR o rama, una copia por cliente o región del mismo nivel | dev / pre / pro y cualquier separación con distinto propietario o nivel de confianza |
🔷 Se combinan bien. Directorio
entornos/dev/con su backend y, dentro, workspaces por desarrollador o por PR (dev+ana,dev+pr-142). Directorioentornos/pro/con un único workspacedefault. Así cada herramienta hace lo que sabe hacer.
4. Parametrizar por workspace¶
El patrón: un mapa en locals con la configuración de cada workspace permitido, indexado por terraform.workspace. Tiene una virtud escondida: si el workspace activo no está en el mapa (el default tras olvidar el select, o un error tipográfico), el plan falla antes de tocar nada.
# locals.tf
locals {
entornos = {
ana = { cidr = "10.90.0.0/16", subredes = ["web"], replicacion = "LRS", retencion = 7, criticidad = "baja" }
pr-142 = { cidr = "10.91.0.0/16", subredes = ["web"], replicacion = "LRS", retencion = 7, criticidad = "baja" }
pre = { cidr = "10.92.0.0/16", subredes = ["web", "datos"], replicacion = "ZRS", retencion = 14, criticidad = "media" }
}
ws = terraform.workspace
cfg = local.entornos[local.ws] # "default" o un typo → error de clave: nada se planifica
tags = { proyecto = "moodle", entorno = local.ws, gestion = "terraform", criticidad = local.cfg.criticidad }
}
# El tamaño de la VM o el SKU de la base de datos (página 4, solo Azure real) irían en el mismo mapa:
# vm_size = "Standard_B2s" / "Standard_D4s_v5", db_sku = "B_Standard_B1ms" / "GP_Standard_D2ds_v4"
# Lo que NUNCA va en el mapa ni en un tfvars: contraseñas. random_password + Key Vault (página 6) o ephemeral (≥ 1.10).
| Alternativa | Cuándo | Pega |
|---|---|---|
Mapa en locals (arriba) |
Pocos workspaces conocidos de antemano | Cada workspace nuevo exige tocar el código (que es también una ventaja: queda en Git) |
terraform apply -var-file=vars/${ws}.tfvars |
Muchos valores por workspace | Nadie te impide pasar pre.tfvars con el workspace ana activo. Un precondition que compare var.entorno == terraform.workspace lo evita |
Un valor por defecto para workspaces desconocidos (try(local.entornos[ws], local.entornos.ana)) |
Entornos por PR con nombre dinámico (pr-<n>) |
Pierdes la protección contra default. Compénsalo con precondition { condition = terraform.workspace != "default" } |
environments/dev/terraform.tfvars (el original) |
Nunca así | Terraform solo carga automáticamente terraform.tfvars y *.auto.tfvars del directorio actual. Ese archivo no se lee: el apply iría con los valores por defecto |
5. Laboratorio en Topaz¶
mkdir -p ~/tf-ws && cd ~/tf-ws && cp ~/tf-st/providers.tf .
# locals.tf: el bloque de 10.4 tal cual
cat > main.tf <<'EOF'
resource "azurerm_resource_group" "moodle" {
name = "rg-moodle-${local.ws}-001"
location = "eastus"
tags = local.tags
lifecycle {
ignore_changes = [tags]
precondition {
condition = terraform.workspace != "default"
error_message = "No se despliega en el workspace 'default'. Usa: terraform workspace select <nombre>"
}
}
}
resource "azurerm_virtual_network" "moodle" {
name = "vnet-moodle-${local.ws}"
resource_group_name = azurerm_resource_group.moodle.name
location = azurerm_resource_group.moodle.location
address_space = [local.cfg.cidr]
tags = local.tags
}
resource "azurerm_subnet" "moodle" {
for_each = { for i, s in local.cfg.subredes : s => i + 1 }
name = "snet-${each.key}"
resource_group_name = azurerm_resource_group.moodle.name
virtual_network_name = azurerm_virtual_network.moodle.name
address_prefixes = [cidrsubnet(local.cfg.cidr, 8, each.value)]
}
resource "random_string" "sufijo" {
length = 6
upper = false
special = false
}
resource "azurerm_storage_account" "moodledata" {
name = "stmoodle${replace(local.ws, "-", "")}${random_string.sufijo.result}"
resource_group_name = azurerm_resource_group.moodle.name
location = azurerm_resource_group.moodle.location
account_tier = "Standard"
account_replication_type = local.cfg.replicacion
min_tls_version = "TLS1_2"
allow_nested_items_to_be_public = false
shared_access_key_enabled = false
blob_properties { delete_retention_policy { days = local.cfg.retencion } }
tags = local.tags
}
output "workspace" { value = terraform.workspace }
output "resumen" { value = { grupo = azurerm_resource_group.moodle.name, subredes = keys(azurerm_subnet.moodle), replicacion = local.cfg.replicacion } }
EOF
terraform init
# ─── 1. default está protegido ──────────────────────────────────────────────────
terraform workspace list # * default
terraform plan # Error: Invalid index … key "default" (el mapa) — y si lo quitaras, el precondition
# ─── 2. Primer workspace ────────────────────────────────────────────────────────
terraform workspace new ana # "Created and switched to workspace "ana"!" (no hace falta select)
terraform workspace show # ana
terraform apply -auto-approve # 5 to add: grupo, vnet, 1 subred, sufijo, cuenta LRS
ls terraform.tfstate.d/ # ana/
terraform output resumen
# ─── 3. Segundo workspace: estado vacío, recursos nuevos ────────────────────────
terraform workspace new pre
terraform state list # (vacío)
terraform apply -auto-approve # 6 to add: 2 subredes, cuenta ZRS
az group list --query "[?starts_with(name,'rg-moodle')].{grupo:name, entorno:tags.entorno}" -o table # ambos conviven
ls terraform.tfstate.d/ # ana/ pre/
# ─── 4. Sin cambiar el activo: TF_WORKSPACE ─────────────────────────────────────
terraform workspace show # pre
TF_WORKSPACE=ana terraform output workspace # "ana"
TF_WORKSPACE=pr-142 terraform plan # Error: workspace "pr-142" does not exist → hay que crearlo:
terraform workspace select -or-create pr-142 && terraform workspace select pre
# ─── 5. El código es común: un cambio afecta a todos ────────────────────────────
sed -i 's/gestion = "terraform"/gestion = "terraform", curso = "moodle-azure"/' locals.tf
terraform plan | grep -E "to add|update" # pre: update in-place en vnet y cuenta (el grupo ignora tags)
TF_WORKSPACE=ana terraform plan | grep -E "to add|update" # ana: lo mismo. Ambos deben aplicarse; hasta entonces, drift de código
terraform apply -auto-approve && TF_WORKSPACE=ana terraform apply -auto-approve
# ─── 6. Sin sufijo de workspace en el nombre: la colisión ───────────────────────
# Prueba mental (no lo ejecutes): cambia name a "rg-moodle-001" y aplica en ana y pre.
# Grupo: ambos estados "poseen" el mismo. Cuenta: el segundo falla con StorageAccountAlreadyTaken.
# ─── 7. Borrar un workspace ─────────────────────────────────────────────────────
terraform workspace delete pre # Error: Workspace "pre" is not empty (y además está activo)
terraform destroy -auto-approve # 6 destroyed (en pre)
terraform workspace select ana && terraform workspace delete pre # ahora sí
terraform workspace delete pr-142 # vacío: se borra directamente
terraform destroy -auto-approve # ana
terraform workspace select default && terraform workspace delete ana
terraform workspace list # * default
ls terraform.tfstate.d/ 2>/dev/null # vacío o inexistente
6. Azure real: backend remoto, permisos y CI¶
# Con el backend de la página 7 (key = "moodle/dev.tfstate") los workspaces se guardan como blobs hermanos:
az storage blob list --account-name $ST -c tfstate --prefix moodle/ --auth-mode login --query "[].name" -o tsv
# moodle/dev.tfstate ← default
# moodle/dev.tfstateenv:ana
# moodle/dev.tfstateenv:pr-142
terraform workspace list # lee esa lista: default, ana, pr-142
# Permiso por workspace (ABAC): que el pipeline de PR solo toque blobs "…env:pr-*"
az role assignment create --assignee-object-id <sp-ci-pr> --assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Contributor" --scope "<id del contenedor tfstate>" \
--condition "@Resource[Microsoft.Storage/storageAccounts/blobServices/containers/blobs:path] StringLike 'moodle/dev.tfstateenv:pr-*'" \
--condition-version 2.0
# Es el límite práctico del aislamiento por workspace: útil para PRs, insuficiente para separar pro de dev.
# .github/workflows/pr-env.yml (entorno efímero por PR; OIDC y backend como en las páginas 6 y 7)
on:
pull_request: { types: [opened, synchronize, closed] }
concurrency:
group: moodle-dev-pr-${{ github.event.number }} # un lock de cola por workspace ([página 8](index.md#pagina-8))
cancel-in-progress: false
env:
TF_WORKSPACE: pr-${{ github.event.number }} # todos los pasos usan este workspace; nada de "select" con estado en el runner
jobs:
desplegar:
if: github.event.action != 'closed'
steps:
- run: terraform init -input=false
- run: terraform workspace select -or-create "$TF_WORKSPACE" # TF_WORKSPACE exige que exista: créalo aquí
- run: terraform apply -input=false -auto-approve -lock-timeout=10m
destruir:
if: github.event.action == 'closed'
steps:
- run: terraform init -input=false
- run: terraform destroy -input=false -auto-approve -lock-timeout=10m
- run: unset TF_WORKSPACE && terraform workspace select default && terraform workspace delete "pr-${{ github.event.number }}"
7. Errores comunes¶
⚠️ Solución de problemas
Mensaje o síntoma Causa y solución Invalid index … The given key does not identify an element sobre local.entornosWorkspace activo fuera del mapa: defaulto un typo.workspace showyselect. Es la protección funcionandoResource precondition failed: No se despliega en 'default' Igual que arriba, con mensaje propio. Si el mapa ya protege, el preconditiones redundante pero más legibleAplicaste en el workspace equivocado Si los nombres llevan el workspace, has creado una copia extra: destroyen ese workspace. Si no lo llevan, dos estados comparten recursos:state rmen el equivocado y revisa a mano. Y la lección:workspace showen el promptStorageAccountAlreadyTaken / already exists en el segundo workspace Nombre sin ${terraform.workspace}. Añádelo (o un derivado conreplacesi el recurso no admite guiones)Workspace "pr-142" does not exist con TF_WORKSPACELa variable no crea: terraform workspace select -or-createantes (oworkspace new)Workspace "pre" is not empty al borrar destroyprimero.-forceborra el estado y deja los recursos vivos y sin dueño: solo si ya los borraste por otro caminoCannot delete the currently active workspace / cannot delete "default" Cambia a otro antes. defaultsiempre existe y no se puede borrar; si no lo usas, déjalo vacío y protegido con el mapa o elpreconditionEl plandepremuestra cambios que no has hechoAlguien cambió el código (común) y aplicó solo en su workspace. Cada cambio de código debe aplicarse en todos los workspaces vivos; hasta entonces hay drift de código. En CI: un job por workspace tras el merge Variables not allowed al usar terraform.workspaceen el bloquebackendEl backend no admite expresiones. Los workspaces ya separan el estado por sí solos (sufijo env:); no hace falta cambiar lakeyEl environments/dev/terraform.tfvarsdel original no tiene efectoTerraform solo carga solo terraform.tfvarsy*.auto.tfvarsdel directorio actual. Pásalo con-var-fileo, mejor, usa el mapa de 10.4workspace listcon backendazurermmuestra workspaces que nadie creóCualquier blob <key>env:<x>del contenedor cuenta como workspace: restos de PRs cuyo jobdestruirfalló.state listen cada uno; si están vacíos,workspace deleteUn random_passworddistinto por workspace "se pierde" al borrar el workspaceComportamiento esperado: vive en ese estado. Si la contraseña debe sobrevivir, guárdala en Key Vault (página 6) antes de destruir En Topaz, los grupos de dos workspaces aparecen con la misma tag entornoEl grupo lleva ignore_changes = [tags]por la lectura de tags del emulador (página 2): la tag se pone al crear y no se corrige después. Compruébalo envneto en la cuenta, que sí las actualizan
8. Autoevaluación¶
- ¿Qué cambia entre dos workspaces y qué no?
Cambia solo el archivo de estado (y su lock) y el valor de
terraform.workspace. Comparten código, backend, credenciales, suscripción delprovidery versiones de providers. - ¿Por qué HashiCorp desaconseja un workspace por entorno (dev/pre/pro)?
Porque esos entornos deberían tener distinta suscripción, distintos permisos y distinto radio de explosión, y el workspace obliga a compartir backend y credenciales. Un
selectolvidado aplica en producción. - ¿Para qué sí son la herramienta adecuada? Copias equivalentes del mismo nivel y del mismo equipo: sandbox por desarrollador, entorno efímero por PR o rama, una copia por cliente o región.
- ¿Dónde guarda el estado cada workspace con backend
localy conazurerm? Local:terraform.tfstate.d/<nombre>/terraform.tfstate. Azurerm: un blob hermano con sufijo<key>env:<nombre>en el mismo contenedor.defaultusa la ruta sin sufijo. - ¿Qué protege el mapa
local.entornos[terraform.workspace]? Si el workspace activo no está en el mapa (defaulto un typo), elplanfalla por clave inexistente antes de tocar nada. - ¿Por qué todos los nombres deben incluir
${terraform.workspace}? Los workspaces comparten suscripción. Sin sufijo, el segundo workspace colisiona: adopta el mismo grupo de recursos (dos estados dueños de un objeto) o falla con already taken en cuentas de almacenamiento. - ¿Qué está mal en
environments/dev/terraform.tfvarsjunto aworkspace new dev? Mezcla dos patrones y además ese archivo no se carga: Terraform solo lee automáticamenteterraform.tfvarsy*.auto.tfvarsdel directorio actual. Y contenía una contraseña en claro. - ¿Qué diferencia hay entre
workspace selectyTF_WORKSPACE?selectcambia el activo y lo guarda en.terraform/environmentdel directorio.TF_WORKSPACElo fija por comando sin estado en disco: es lo que usa un pipeline, junto aselect -or-createpara crearlo. - ¿Qué pasa con
workspace delete -force? Borra el estado aunque no esté vacío: los recursos siguen en Azure sin ningún estado que los gobierne. Solo tras undestroyo si ya se borraron por otro camino. - ¿Qué es el "drift de código" entre workspaces y cómo se evita?
El código es común, pero cada workspace se aplica por separado: un cambio aplicado solo en
anadeja aprecon unplanpendiente. Se evita aplicando en todos los workspaces vivos tras cada merge (un job por workspace en CI). - ¿Cómo se combinan bien workspaces y directorios?
Directorio por entorno real (
dev,pre,pro) con su propio backend y permisos; dentro dedev, workspaces efímeros por persona o PR.prosolo condefault.
9. Referencias¶
- Workspaces (incluye la sección When to use multiple workspaces, con la recomendación de no usarlos para separar entornos) y workspaces en la CLI
- Comandos
terraform workspace(new,select -or-create,list,show,delete) y variableTF_WORKSPACE - Backend
azurerm(nomenclaturaenv:de los blobs por workspace) y configuración parcial por directorio - Archivos
.tfvarsy cuáles se cargan automáticamente,preconditiony variablesephemeral - Guía de estilo: múltiples entornos y
cidrsubnet - Condiciones ABAC en RBAC de Azure y ejemplos ABAC para blobs (permisos por prefijo de blob)
- Concurrencia en GitHub Actions y evento
pull_request(entornos efímeros por PR) - Documentación de Moodle para administradores (contexto de la aplicación del laboratorio final)
- Azure Local Emulator (Topaz)