La mayoría de las comparativas entre Terraform y Pulumi se quedan en "HCL es declarativo, Pulumi usa lenguajes reales, elige según las habilidades del equipo". Es cierto, y no ayuda a decidir. Las diferencias que importan aparecen más tarde: en lo que guarda el archivo de estado, en cómo pruebas, en lo que ve un revisor antes de que un cambio llegue a producción y en lo difícil que es irse.
Así que este artículo construye un stack pequeño y realista en las dos herramientas y lo ejecuta. Cada listado HCL de abajo pasó validate en Terraform y en OpenTofu (el único bloque exclusivo de OpenTofu está señalado), y las pruebas pasaron en verde en ambos; cada listado TypeScript pasó tsc, y sus pruebas unitarias pasaron en verde.
El stack: un equipo de plataforma da a cada equipo de producto su propio namespace de Kubernetes con una ResourceQuota y un LimitRange. En cuanto una cuota cubre CPU o memoria, Kubernetes puede rechazar cualquier pod nuevo que no declare requests o limits para ellas, así que el LimitRange aporta valores por defecto. Un mapa de equipos obliga a usar un bucle, y tres recursos por equipo piden reutilización.
El stack en HCL
El módulo raíz llama a un módulo hijo una vez por entrada del mapa con for_each. La regla de validación rechaza los nombres que Kubernetes no aceptaría como nombres de namespace antes de que nada llegue a la API.
variable "teams" {
description = "Team name => namespace quota"
type = map(object({
cpu = string
memory = string
pods = optional(number, 50)
}))
validation {
condition = alltrue([for name in keys(var.teams) : can(regex("^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$", name))])
error_message = "Team names must be valid namespace names (DNS-1123 labels)."
}
}
module "team" {
source = "./modules/team-namespace"
for_each = var.teams
name = each.key
cpu = each.value.cpu
memory = each.value.memory
pods = each.value.pods
}El módulo contiene los tres recursos y dos outputs para las pruebas:
variable "name" { type = string }
variable "cpu" { type = string }
variable "memory" { type = string }
variable "pods" { type = number }
resource "kubernetes_namespace_v1" "this" {
metadata {
name = var.name
labels = { team = var.name }
}
}
resource "kubernetes_resource_quota_v1" "this" {
metadata {
name = "team-quota"
namespace = kubernetes_namespace_v1.this.metadata[0].name
}
spec {
hard = {
"requests.cpu" = var.cpu
"requests.memory" = var.memory
pods = tostring(var.pods)
}
}
}
resource "kubernetes_limit_range_v1" "this" {
metadata {
name = "container-defaults"
namespace = kubernetes_namespace_v1.this.metadata[0].name
}
spec {
limit {
type = "Container"
default_request = { cpu = "100m", memory = "128Mi" }
default = { cpu = "500m", memory = "512Mi" }
}
}
}
output "namespace" { value = kubernetes_namespace_v1.this.metadata[0].name }
output "quota" { value = kubernetes_resource_quota_v1.this.spec[0].hard }La versión fijada del provider vive en versions.tf y la lista de equipos en un archivo .tfvars. Cada instancia recibe una dirección como module.team["payments"]: la clave del mapa es su identidad, algo que vuelve a importar en la sección de revisión.
El mismo stack en Pulumi con TypeScript
El programa lee el mismo mapa de la configuración del stack y lo recorre en un bucle:
import * as pulumi from "@pulumi/pulumi";
import { TeamNamespace, TeamArgs } from "./team";
const config = new pulumi.Config();
const teams = config.requireObject<Record<string, TeamArgs>>("teams");
export const namespaces = Object.entries(teams).map(
([name, args]) => new TeamNamespace(name, args).namespace,
);Las tres llamadas new podrían ir dentro de ese bucle. En cuanto un segundo stack las necesite, muévelas a un ComponentResource, la unidad de reutilización de Pulumi:
import * as pulumi from "@pulumi/pulumi";
import * as k8s from "@pulumi/kubernetes";
export interface TeamArgs {
cpu: string;
memory: string;
pods?: number;
}
export class TeamNamespace extends pulumi.ComponentResource {
public readonly namespace: pulumi.Output<string>;
public readonly quota: k8s.core.v1.ResourceQuota;
constructor(name: string, args: TeamArgs, opts?: pulumi.ComponentResourceOptions) {
if (!/^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/.test(name)) {
throw new Error(`team name "${name}" is not a DNS-1123 label`);
}
super("platform:k8s:TeamNamespace", name, {}, opts);
const child = { parent: this };
const ns = new k8s.core.v1.Namespace(name, {
metadata: { name, labels: { team: name } },
}, child);
this.quota = new k8s.core.v1.ResourceQuota(`${name}-quota`, {
metadata: { name: "team-quota", namespace: ns.metadata.name },
spec: {
hard: {
"requests.cpu": args.cpu,
"requests.memory": args.memory,
pods: String(args.pods ?? 50),
},
},
}, child);
new k8s.core.v1.LimitRange(`${name}-defaults`, {
metadata: { name: "container-defaults", namespace: ns.metadata.name },
spec: {
limits: [{
type: "Container",
defaultRequest: { cpu: "100m", memory: "128Mi" },
default: { cpu: "500m", memory: "512Mi" },
}],
},
}, child);
this.namespace = ns.metadata.name;
this.registerOutputs({ namespace: this.namespace });
}
}Tres detalles difieren de la versión HCL:
- La validación es código normal. La comprobación del nombre lanza una excepción en el constructor, así que un nombre incorrecto hace fallar
pulumi previewigual que la validación HCL hace fallarterraform plan. - La identidad es el nombre lógico.
payments-quotay su componente padre forman el URN del recurso, el equivalente en Pulumi de la dirección del módulo. Cambia el nombre lógico y Pulumi planifica un borrado y una creación. - Fija
metadata.nametú mismo. Sin él, Pulumi pone nombre automáticamente al objeto: un Namespace declarado comopaymentssin nombre salió comopayments-7dfa2b14en nuestra ejecución. Útil cuando dos stacks despliegan un mismo programa; una sorpresa para un namespace que los desarrolladores escriben a diario.
Estado, bloqueos y los secretos que contiene
Las dos herramientas guardan un estado que relaciona el código con los objetos reales, atributos de los recursos incluidos.
- Terraform y OpenTofu eligen un backend en un bloque
backend. Dos opciones neutrales respecto al fabricante:kubernetesguarda el estado en un Secret y bloquea con un Lease;pglo guarda en una tabla de PostgreSQL y bloquea con advisory locks. El almacenamiento de objetos y el servicio alojado del fabricante son las alternativas habituales. - Pulumi elige uno con
pulumi login: por defecto, el servicio gestionado del fabricante, o bien un backend autogestionado (un directorio local, almacenamiento de objetos o PostgreSQL). Los backends autogestionados bloquean por defecto con un archivo de bloqueo y guardan el historial de checkpoints en.pulumi/history/.
En los secretos es donde se diferencian. Hicimos el mismo experimento en ambas: pasar una contraseña marcada como sensible, aplicar y buscarla en el estado.
# Terraform: a variable with sensitive = true, passed to a resource
$ grep -c 's3cr3t-demo' terraform.tfstate
2
# Pulumi: the same value, set with pulumi config set --secret
$ grep -c 's3cr3t-demo' .pulumi/stacks/secrets-demo/dev.json
0En Terraform, sensitive = true solo oculta el valor en la salida de la CLI; el estado lo guarda en texto plano, aquí dos veces. Pulumi cifra todo valor marcado como secreto, y todo valor derivado de él, con una clave por stack: por defecto, la clave del servicio gestionado; en los backends autogestionados, por defecto, una frase de contraseña; o bien el servicio de gestión de claves de tu nube. Su provider de Kubernetes marca automáticamente como secretos los campos data y stringData de los Secret. Los valores no marcados como secretos quedan en JSON plano junto al texto cifrado.
Las herramientas HCL han reducido la distancia:
- Terraform 1.10 añadió los valores efímeros y la 1.11 los argumentos de solo escritura, que nunca se escriben en los archivos de estado ni de plan. El recurso Secret del provider de Kubernetes tiene uno,
data_wo; incrementasdata_wo_revisionpara enviar un valor nuevo. OpenTofu 1.11 añadió lo mismo. - OpenTofu 1.7 añadió el cifrado en el cliente de los archivos de estado y de plan. Terraform 1.16 rechaza el bloque de abajo como un tipo de bloque no soportado. Con OpenTofu, el archivo de estado en disco pasó a ser un blob
encrypted_data:
terraform {
encryption {
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
state {
method = method.aes_gcm.main
enforced = true
}
plan {
method = method.aes_gcm.main
enforced = true
}
}
}Si pierdes la frase de contraseña, el estado es irrecuperable; haz una copia antes.
La regla que se desprende: quien pueda leer un backend de Terraform sin cifrar puede leer todos los secretos que contiene, así que acceder al estado es acceder a las credenciales de producción. Con una frase de contraseña de Pulumi o tu propio servicio de gestión de claves, o con el estado de OpenTofu cifrado, puedes conceder la clave por separado del acceso al backend.
Pruebas sin clúster
Terraform 1.7 añadió mock_provider a terraform test: un provider falso rellena los atributos calculados con valores generados, así que las pruebas no necesitan clúster ni credenciales. Este archivo vive en tests/:
mock_provider "kubernetes" {}
variables {
teams = {
payments = { cpu = "8", memory = "16Gi" }
search = { cpu = "4", memory = "8Gi", pods = 20 }
}
}
run "one_namespace_per_team" {
command = plan
assert {
condition = toset(keys(module.team)) == toset(["payments", "search"])
error_message = "Expected one module instance per team."
}
assert {
condition = module.team["search"].quota["pods"] == "20"
error_message = "The pods override did not reach the quota."
}
assert {
condition = module.team["payments"].quota["pods"] == "50"
error_message = "Teams without an override should get 50 pods."
}
}
run "rejects_invalid_team_name" {
command = plan
variables {
teams = { "Payments_EU" = { cpu = "1", memory = "1Gi" } }
}
expect_failures = [var.teams]
}$ terraform test
tests/teams.tftest.hcl... in progress
run "one_namespace_per_team"... pass
run "rejects_invalid_team_name"... pass
tests/teams.tftest.hcl... tearing down
tests/teams.tftest.hcl... pass
Success! 2 passed, 0 failed.expect_failures convierte la regla de validación en una prueba: el run pasa solo si la regla rechaza la entrada. El mismo archivo pasó sin cambios con tofu test en OpenTofu 1.13. Para demostrar que la prueba puede fallar, cambiamos el valor por defecto a 40 pods, y la tercera aserción falló con "40" frente a "50".
Las pruebas unitarias de Pulumi se ejecutan con el test runner habitual de tu lenguaje, y pulumi.runtime.setMocks sustituye al motor. Ejecutamos esto con el runner integrado de Node (npx tsx --test team.test.ts) en una máquina sin la CLI de Pulumi, sin backend y sin clúster:
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import * as pulumi from "@pulumi/pulumi";
import { TeamNamespace } from "./team";
// Resolve an Output in a test. Mocks make every value known.
const valueOf = <T>(o: pulumi.Output<T>) =>
new Promise<T>((resolve) => o.apply(resolve));
describe("TeamNamespace", () => {
before(async () => {
await pulumi.runtime.setMocks({
newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
call: (args) => args.inputs,
});
});
it("defaults the pod quota to 50", async () => {
const team = new TeamNamespace("payments", { cpu: "8", memory: "16Gi" });
const hard = await valueOf(team.quota.spec.hard);
assert.equal(hard["pods"], "50");
assert.equal(hard["requests.cpu"], "8");
});
it("passes a pod override through", async () => {
const team = new TeamNamespace("search", { cpu: "4", memory: "8Gi", pods: 20 });
assert.equal((await valueOf(team.quota.spec.hard))["pods"], "20");
});
it("rejects names that are not valid namespaces", () => {
assert.throws(
() => new TeamNamespace("Payments_EU", { cpu: "1", memory: "1Gi" }),
/DNS-1123/,
);
});
});Las tres pasaron; el cambio del valor por defecto a 40 hizo fallar la primera. Si el archivo que pruebas crea recursos al importarse, como hace un index.ts típico, cárgalo con un import() dinámico después de setMocks.
En qué destaca cada enfoque:
terraform testcomprueba el plan o el estado que produce el motor real, incluido el cableado entre módulos y la validación. Se aplican las reglas de tipos de HCL: comparar un output de tipo lista con["Container"]falló hasta que lo envolvimos entolist(), porque un literal entre corchetes es una tupla.- Las pruebas de Pulumi son código normal, con fixtures y helpers. Pero el mock devuelve las entradas tal cual, así que pruebas tu lógica, no el comportamiento del provider.
- Ninguno sustituye a un apply real contra un clúster desechable antes de una release.
¿Quieres comparar las dos sobre tu propio código? Solicita una evaluación gratuita de Infraestructura como Código: leemos tus módulos o programas, revisamos qué hay en tu estado, ejecutamos tus pruebas y te entregamos una lista de riesgos ordenada por impacto.
Bucles, condicionales y el problema de lo "dinámico"
Los bucles simples son fáciles en las dos; los bloques anidados opcionales y los bucles anidados, no. Dos ampliaciones lo muestran: un límite de tamaño para PersistentVolumeClaim solo en los equipos que definen max_pvc_size, y un RoleBinding edit por cada grupo del equipo.
En HCL, un bloque opcional es un bloque dynamic sobre una lista de cero o un elementos, y un bucle anidado necesita flatten más una expresión de mapa para tener claves de for_each estables:
# modules/team-namespace/main.tf, inside the LimitRange spec
dynamic "limit" {
for_each = var.max_pvc_size == null ? [] : [var.max_pvc_size]
content {
type = "PersistentVolumeClaim"
max = { storage = limit.value }
}
}
# main.tf: one RoleBinding per team and group
locals {
team_groups = flatten([
for team, cfg in var.teams : [
for group in cfg.groups : { team = team, group = group }
]
])
}
resource "kubernetes_role_binding_v1" "edit" {
for_each = { for tg in local.team_groups : "${tg.team}/${tg.group}" => tg }
metadata {
name = "edit-${each.value.group}"
namespace = module.team[each.value.team].namespace
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "edit"
}
subject {
api_group = "rbac.authorization.k8s.io"
kind = "Group"
name = each.value.group
}
}El TypeScript es el código que escribirías de todos modos:
// team.ts, the LimitRange spec
limits: [
{
type: "Container",
defaultRequest: { cpu: "100m", memory: "128Mi" },
default: { cpu: "500m", memory: "512Mi" },
},
...(args.maxPvcSize
? [{ type: "PersistentVolumeClaim", max: { storage: args.maxPvcSize } }]
: []),
],
// index.ts: one RoleBinding per team and group
for (const [team, cfg] of Object.entries(teams)) {
const t = new TeamNamespace(team, cfg);
for (const group of cfg.groups ?? []) {
new k8s.rbac.v1.RoleBinding(`${team}-${group}`, {
metadata: { name: `edit-${group}`, namespace: t.namespace },
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "ClusterRole", name: "edit" },
subjects: [{ apiGroup: "rbac.authorization.k8s.io", kind: "Group", name: group }],
});
}
}Las dos versiones pasaron sus pruebas, con el límite de PVC solo en payments, y los bindings de HCL salieron con las claves payments/payments-devs, payments/payments-oncall y search/search-devs. El HCL funciona, pero cada uno de estos patrones es una cosa más que el revisor tiene que aprender.
El peligro va en sentido contrario. Un programa de Pulumi se ejecuta entero en cada preview:
- Llamadas de red al planificar. Si obtienes la lista de equipos de una API interna, cada preview depende de que esa API responda siempre lo mismo. Guarda los datos en el repositorio o usa la configuración del stack.
- No determinismo. Las marcas de tiempo o los valores aleatorios en nombres o entradas producen un diff en cada ejecución.
- Efectos secundarios. El código que escribe archivos o llama a webhooks también se ejecuta durante
pulumi preview, no solo conpulumi up.
HCL hace más difíciles estos errores, no imposibles: también tiene timestamp() y uuid(). Nuestra regla para el código de Pulumi: las entradas salen de la configuración, de archivos versionados o de los outputs de otros recursos, y la revisión rechaza todo lo demás.
Reutilización y revisión: qué ve el revisor
En reutilización, un módulo de Terraform se fija por tag de Git o versión del registry y sirve igual para Terraform que para OpenTofu; diseñar esas fronteras entre módulos es buena parte de nuestro trabajo de consultoría de Terraform. Un componente de Pulumi en TypeScript se distribuye como paquete npm a otros programas TypeScript.
En revisión, hicimos el mismo cambio en las dos: subir la CPU de payments de 8 a 12 y renombrar el equipo search a discovery. El plan de Terraform, obtenido con terraform test -verbose y el mock provider (extracto):
# module.team["payments"].kubernetes_resource_quota_v1.this will be updated in-place
~ resource "kubernetes_resource_quota_v1" "this" {
id = "74w79olp"
~ spec {
~ hard = {
~ "requests.cpu" = "8" -> "12"
# (2 unchanged elements hidden)
}
}
# (1 unchanged block hidden)
}
# module.team["search"].kubernetes_namespace_v1.this will be destroyed
# (because module.team["search"] is not in configuration)
Plan: 3 to add, 1 to change, 3 to destroy.La preview de Pulumi, ejecutada contra un provider que vuelca los manifiestos en un directorio en lugar de en un clúster (extracto):
~ kubernetes:core/v1:ResourceQuota: (update)
[id=payments/team-quota]
~ spec: {
~ hard: {
~ requests.cpu: "8" => "12"
}
}
- kubernetes:core/v1:Namespace: (delete)
[id=search]Su resumen indica 4 por crear, 1 por actualizar y 4 por borrar: Pulumi cuenta el propio componente como un recurso.
Las dos muestran la trampa: renombrar una clave del mapa borra un namespace y, con él, todo lo que se ejecuta dentro. Dos diferencias le importan al revisor:
- Un plan de Terraform guardado con
-outse aplica exactamente como se aprobó, yterraform show -jsonlo convierte en una evidencia de cambio que se puede archivar.--save-plande Pulumi sigue marcado como funcionalidad en preview, así quepulumi upnormalmente recalcula el diff en el momento de aplicar. - Revisa el plan, no solo el diff del código. En Pulumi importa más: un cambio en una función auxiliar puede alterar recursos lejos de las líneas que toca un pull request.
No dejes la trampa en manos de la vista humana. Este paso de CI falla cuando un cambio borraría un namespace:
# Terraform or OpenTofu
terraform plan -input=false -out=tfplan
terraform show -json tfplan | jq -r '.resource_changes[]
| select(.type == "kubernetes_namespace_v1")
| select(.change.actions | index("delete")) | .address' > deleted.txt
# Pulumi
pulumi preview --json | jq -r '.steps[]
| select(.op | test("delete|replace"))
| select(.oldState.type == "kubernetes:core/v1:Namespace") | .urn' > deleted.txt
# Either tool
if [ -s deleted.txt ]; then echo "This change deletes namespaces:"; cat deleted.txt; exit 1; fiEn el código, lifecycle { prevent_destroy = true } hace que Terraform rechace esos planes; quitar un equipo falla entonces con "Instance cannot be destroyed". La opción protect: true de Pulumi hace lo mismo. Para refactorizaciones que conservan el objeto, como mover un recurso a un módulo o a un componente, usa bloques moved en HCL o la opción aliases en Pulumi; renombrar el propio namespace siempre lo recrea. Integrar estos controles en los pipelines forma parte de nuestro trabajo de consultoría de CI/CD.
Drift, importación y migración entre herramientas
El drift es un cambio hecho fuera de la herramienta. Compruébalo de forma programada:
# Terraform or OpenTofu: show drift without proposing changes
terraform plan -refresh-only
# Exit code 0 = no changes, 1 = error, 2 = changes
terraform plan -detailed-exitcode
# Pulumi: show what a refresh would change, without writing state
pulumi refresh --preview-only
# Fail if code and reality differ
pulumi preview --refresh --expect-no-changesAceptar el drift (apply -refresh-only o pulumi refresh escriben la realidad en el estado) y revertirlo (aplicar el código) son decisiones distintas; toma una a propósito.
Para adoptar namespaces existentes, Terraform 1.5 añadió los bloques import y la 1.7 permitió usar for_each en ellos. Los formatos de ID son los que documenta el provider: el nombre para un namespace y NAMESPACE/NAME para una cuota.
import {
for_each = var.teams
to = module.team[each.key].kubernetes_namespace_v1.this
id = each.key
}
import {
for_each = var.teams
to = module.team[each.key].kubernetes_resource_quota_v1.this
id = "${each.key}/team-quota"
}terraform plan -generate-config-out=generated.tf escribe HCL para los recursos importados que no tienen configuración; las dos CLI siguen marcándolo como experimental. En Pulumi, pulumi import TYPE NAME ID adopta un recurso e imprime su código, --file acepta una lista en JSON y pulumi preview --import-file import.json genera un borrador de esa lista a partir de las creaciones que planifica tu programa. Lo importado queda protegido por defecto.
Migrar entre herramientas:
- De Terraform a OpenTofu: el mismo HCL; nuestro módulo y nuestras pruebas pasaron sin cambios. Las funcionalidades añadidas desde el fork difieren en ambos sentidos, así que revisa cuáles usa tu código.
- De Terraform a Pulumi:
pulumi convert --from terraform --language typescript --out DIRtraduce HCL. Sobre el stack de arriba (plugin del conversor 1.4.0) generó un componente y un bucle, perotsccon el propio tsconfig del conversor informó de 11 errores, entre ellos:tostring()se convirtió en una llamada anotImplemented(...)que lanza una excepción en tiempo de ejecución, las referencias al namespace se convirtieron en unthis.apply(...)no válido y el bloquelimitdel LimitRange no se renombró alimits. Trata el resultado como un primer borrador.pulumi import --from terraform ./terraform.tfstateadopta los recursos registrados en un archivo de estado de Terraform. - En paralelo: un programa de Pulumi puede leer los outputs raíz de un estado de Terraform, así que puedes migrar capa a capa. Esto leyó nuestro estado de prueba y exportó
["payments", "search"]:
import * as terraform from "@pulumi/terraform";
const legacy = terraform.state.getLocalReferenceOutput({
path: "../../tf-out/terraform.tfstate",
});
export const legacyNamespaces = legacy.outputs.apply((o) => o["namespaces"] as string[]);Para estado remoto, el paquete tiene getRemoteReference. Una vía de salida probada es lo que impide que la elección de herramienta se convierta en lock-in; nuestra guía para evitar el vendor lock-in aplica la misma prueba a los servicios cloud.
Licencias: Terraform, OpenTofu y Pulumi
- Terraform: el 10 de agosto de 2023, el fabricante anunció que las versiones futuras pasarían de la Mozilla Public License 2.0 a la Business Source License 1.1. El archivo de licencia nombra Terraform 1.6.0 y posteriores. Se permite el uso en producción, salvo ofrecer Terraform a terceros de forma alojada o embebida en competencia con las versiones de pago del fabricante. Cada versión pasa a MPL 2.0 cuatro años después de su publicación.
- OpenTofu: el fork comunitario del código de Terraform con licencia MPL, y sigue siendo MPL 2.0. Desde el 23 de abril de 2025 es un proyecto sandbox de la fundación que también aloja Kubernetes.
- Pulumi: la CLI, el motor y los SDK son Apache 2.0. El servicio gestionado del fabricante es opcional, con un nivel gratuito y niveles de pago; los backends autogestionados no tienen restricciones.
Si Terraform gestiona tu propia infraestructura, la BSL no cambia nada en el día a día. Importa si vendes un producto o un servicio alojado construido sobre él, o si tu política legal restringe sobre qué licencias puedes construir. En ese caso, haz que tus abogados lean la cláusula de uso, o pásate a OpenTofu, que no exige reescribir nada.
Una regla de decisión
Responde en orden y detente en la primera que decida:
- 1¿Ya usas Terraform con módulos que funcionan y revisión de planes? Quédate en HCL. Si el problema es la licencia, cámbiate a OpenTofu, no a una reescritura.
- 2¿Necesitas archivos de plan como evidencia de cambios, porque un auditor toma muestras de los cambios? Elige Terraform u OpenTofu: un plan guardado y aplicado tal como se aprobó es más fácil de defender que un diff recalculado al aplicar.
- 3¿Quién lo mantendrá dentro de dos años? Un equipo de plataforma u operaciones cuyos miembros no escriben todos TypeScript, Python o Go a diario: HCL. Ingenieros de producto que son dueños tanto de los servicios como de la infraestructura y revisan código en ese lenguaje a diario: Pulumi. Nuestra guía para crear un equipo de platform engineering trata quién debería ser dueño de esta capa.
- 4¿Cuánta lógica dinámica? Escribe primero el módulo más difícil. Si necesita más de un par de bloques
dynamico patrones conflatten, Pulumi se leerá mejor. Unfor_eachsobre un mapa no es motivo para cambiar. - 5¿Construyes una plataforma de autoservicio que aprovisiona desde tu propio servicio? La Automation API de Pulumi ejecuta operaciones de Pulumi desde tu código en lugar de invocar una CLI, una necesidad habitual en platform engineering.
- 6¿Los secretos del estado deben ser ilegibles para quien lee el backend? Los secretos de Pulumi o el cifrado de estado de OpenTofu lo resuelven hoy; con Terraform, usa argumentos de solo escritura donde los providers los ofrezcan y restringe el acceso al backend.
Si nada de lo anterior decide, nuestra opción por defecto es HCL con OpenTofu o Terraform: un revisor puede aprobar un cambio leyendo el plan, no un programa. Elijas lo que elijas, escribe las pruebas y el control de borrados en el primer mes; son ellos, y no el lenguaje, los que deciden cómo va el segundo año.
Solicita una evaluación gratuita de Infraestructura como Código
Si estás eligiendo entre Terraform, OpenTofu y Pulumi, o convives con una elección hecha hace años, nuestro equipo de consultoría de Terraform revisará tu configuración sin coste. La evaluación cubre:
- Estado y secretos: dónde vive tu estado, quién puede leerlo, qué secretos contiene en texto plano y cómo se comportan los bloqueos.
- Pruebas y revisión: si tus módulos o componentes tienen pruebas que se ejecutan sin clúster, y si la CI bloquea los cambios destructivos antes de que lleguen a producción.
- Encaje de la herramienta y migración: cómo puntúa tu código frente a la regla de decisión de arriba y qué implicaría pasar a OpenTofu o a Pulumi, si estuviera justificado.
Recibes por escrito una lista de hallazgos y próximos pasos, ordenada por riesgo, sin compromiso.
Solicita tu evaluación gratuita de Infraestructura como Código, o descubre cómo hacemos platform engineering sobre esa base.