← Todos los escritos

El huevo-gallina del bootstrap en la nube, y cómo romperlo

Una sola cuenta, un usuario admin — y el truco del backend local que deja a un módulo de Terraform gestionar su propio estado desde cero.

La primera vez que conectas GitHub Actions a una cuenta de AWS recién creada, te topas con un pequeño acertijo. Quieres que Terraform gestione todo, incluido el bucket de S3 donde vive el estado de Terraform. Pero ese bucket tiene que existir antes de que Terraform pueda leer su propio estado desde él. La mayoría de los tutoriales esquivan el problema creando el bucket a mano en la consola y haciéndose los desentendidos después. Hay una respuesta más limpia que cabe en un solo módulo de Terraform, y funciona igual de bien en un proyecto de una sola cuenta que en una flota.

El caso de una sola cuenta es un problema en sí mismo

La mayoría de las guías sobre "AWS y GitHub OIDC" te dejan en Organizations, Control Tower, IAM Identity Center y un esquema de cuatro cuentas con topología hub-and-spoke antes de haber desplegado un solo workflow. Ese público existe. No es todo el mundo. Si gestionas una cuenta de AWS para un proyecto, con un usuario admin de IAM que creaste desde el alta inicial con el usuario raíz, el andamiaje empresarial estándar es excesivo — e introducirlo hace que el proyecto parezca más complejo de lo que es.

Excesivo no es lo mismo que licencia para saltarse la disciplina. La forma del problema de bootstrap es la misma en una cuenta pequeña que en una grande: tres artefactos deben existir antes de la primera ejecución de CI, e idealmente los tres viven en control de versiones. Lo que cambia en el caso pequeño es el tamaño de la respuesta, no su rigor.

El problema del huevo-gallina, dicho claramente

Los tres artefactos son un bucket de S3 para el estado de Terraform, una tabla de DynamoDB para el lock de estado, y un proveedor OIDC más un rol de IAM que GitHub Actions pueda asumir. El truco es que el bucket contiene el propio estado de Terraform, lo que significa que Terraform necesita que el bucket exista antes de poder gestionar nada — incluido el bucket.

Las tres respuestas que veo con más frecuencia son: (1) montar el bucket a mano en la consola y olvidarse de que existió, (2) escribir un script de shell que crea el bucket con el AWS CLI y le pide a Terraform que no lo toque, o (3) partir la cuenta en dos y arrancar la segunda desde la primera. La primera es infraestructura sin documentar. La segunda funciona hasta que alguien necesita recrear la cuenta desde cero y el script de shell se ha podrido. La tercera nos devuelve a Organizations.

La respuesta que termino adoptando es escribir un único módulo bootstrap/ cuyo main.tf declara un bloque vacío backend "s3" {} pero se inicializa con terraform init -backend=false la primera vez. El primer apply corre contra un terraform.tfstate local en disco, crea el bucket de S3 y la tabla de locks, y después el mismo módulo migra su propio estado al bucket que acaba de crear.

Qué contiene el módulo

El módulo es deliberadamente pequeño. El backend de estado es un bucket de S3 con versionado activo, cifrado en servidor AES-256, acceso público totalmente bloqueado y prevent_destroy puesto en el recurso para que un terraform destroy descuidado no pueda borrar la historia. La tabla de locks es una sola tabla de DynamoDB con una clave de partición LockID y facturación por solicitud — no hay volumen de lectura o escritura que optimizar.

El lado OIDC es el proveedor de identidad de GitHub (token.actions.githubusercontent.com con sts.amazonaws.com como audiencia), un rol de IAM con una política de confianza AssumeRoleWithWebIdentity, y una política de IAM adjunta a ese rol. La política es amplia — concede acceso casi total a los servicios que este proyecto realmente toca: ECS, ECR, IAM (con un permissions boundary), lecturas de VPC y security groups, ELB, CloudWatch, Secrets Manager, S3 con prefijo, KMS, ElastiCache, SSM, RDS, Route 53 y los propios recursos del backend de estado.

No voy a pretender que esa política es estrecha. Es amplia a propósito porque la cuenta contiene exactamente un proyecto. En una cuenta multi-tenant sería un error; en un proyecto de una sola cuenta es honesto, más fácil de razonar que un laberinto de sentencias muy acotadas, y no es una concesión de seguridad que nadie esté haciendo realmente — el usuario de IAM que ejecutó el bootstrap ya tenía acceso de administrador. El rol simplemente hereda una versión ligeramente más pequeña de la misma confianza.

01 / BACKEND DE ESTADO02 / CONFIANZA DE CIS3bucket terraform-stateversionado · cifrado · prevent_destroyDDBtabla terraform-locksclave LockID · pay-per-requestOIDCtoken.actions.githubusercontent.coma nivel de cuenta · importado si ya existeROLgithub-actionstrust: sub StringLike repo:org/repo:*POLÍTICAamplia · single-tenant a propósitoECS · ECR · IAM · VPC · S3 · DDB · KMSconfía enadjuntapermite R/W03 / A NIVEL DE CUENTASLRService-linked roles · ECS · ELB · RDS · ElastiCache — importados si ya existen
Lo que crea el módulo bootstrap. La cadena de confianza a la derecha controla la asunción del rol; la política alcanza el backend de estado a la izquierda.

La política de confianza es donde vive la seguridad

La línea interesante no está en la política adjunta al rol. Está en el documento de confianza del rol, donde dos condiciones acotan quién puede asumirlo:

"Condition": {
  "StringLike": {
    "token.actions.githubusercontent.com:sub": [
      "repo:<tu-org>/<repo-infra>:*",
      "repo:<tu-org>/<repo-app>:*"
    ]
  },
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
  }
}

La comprobación de aud es la barata — los tokens OIDC de GitHub para AWS siempre llevan sts.amazonaws.com como audiencia, y verificarlo no cuesta nada. La comprobación de sub es la frontera real. `repo:<org>/<repo>:*` significa que cualquier workflow en ese repositorio, en cualquier rama, en cualquier entorno, puede asumir este rol. Para un proyecto de una sola cuenta que despliega desde main tras revisión, ese es el alcance mínimo aceptable y es también donde discutiría con la regla de "más estrecho es mejor".

Puedes acotar más: `repo:<org>/<repo>:ref:refs/heads/main` ata la asunción del rol a una rama, y `repo:<org>/<repo>:environment:prod` la ata a un Environment de GitHub con aprobaciones. Ambas son mejoras que vale la pena hacer, pero solo después de que el workflow sea lo bastante estable como para que los nombres de rama y entorno no sigan cambiando. Apretar la política de confianza cada vez que se renombra un workflow es una tarea que he visto a equipos saltarse después de la tercera vez, lo cual es peor que empezar más amplio y apretar una vez un viernes tranquilo.

El truco de la migración

La pieza que hace que todo el módulo se autoarranque son dos líneas de terraform init más un sed sobre el bloque de backend. En un clon limpio, la primera ejecución es terraform init -backend=false. Eso le dice a Terraform que ignore el bloque declarado backend "s3" {}, use un archivo de estado local y siga adelante. El primer terraform apply crea entonces el bucket y la tabla de locks. Después de eso, el módulo cambia in situ la declaración del backend — en la práctica, escribiendo un archivo por entorno envs/<ENV>/bootstrap-backend.hcl (en gitignore, para que no pueda apuntar accidentalmente al bucket de otra cuenta) — y ejecuta terraform init -migrate-state -force-copy. Terraform lee el estado local, lo sube al bucket que acaba de crear, borra el archivo local, y a partir de ahí el módulo se gestiona a sí mismo desde S3 con locking en DynamoDB, igual que todo lo demás.

Por qué importa: el bootstrap entero es reproducible. Una persona nueva puede clonar el repo, definir dos variables de entorno, ejecutar un target de make, y terminar con una cuenta totalmente preparada sin pasos manuales por el medio. Los buckets creados a mano no tienen esa propiedad.

Así se ven las piezas que sostienen todo en el Makefile que las orquesta — el init condicional, el import defensivo y la migración:

# bootstrap/Makefile (extracto — los targets que rompen el huevo-gallina)

# main.tf declara `backend "s3" {}` desde el principio.
# Hacemos init con -backend=false hasta que se haya escrito el HCL por entorno.
init:
	@if [ -f envs/$(ENV)/bootstrap-backend.hcl ]; then \
	  terraform init -backend-config=envs/$(ENV)/bootstrap-backend.hcl -reconfigure; \
	else \
	  terraform init -backend=false -reconfigure; \
	fi

# Paso 1 — apply dirigido, solo los recursos que SE CONVIERTEN en el backend
apply-backend: init
	terraform apply $(TF_VARS) \
	  -target=aws_s3_bucket.terraform_state \
	  -target=aws_dynamodb_table.terraform_locks \
	  -auto-approve

# Paso 2 — importar cualquier proveedor OIDC preexistente para evitar colisiones
import-oidc: init
	@ARN="arn:aws:iam::$$(aws sts get-caller-identity --query Account --output text):oidc-provider/token.actions.githubusercontent.com"; \
	if aws iam get-open-id-connect-provider --open-id-connect-provider-arn "$$ARN" >/dev/null 2>&1 \
	   && ! terraform state show aws_iam_openid_connect_provider.github >/dev/null 2>&1; then \
	  terraform import $(TF_VARS) aws_iam_openid_connect_provider.github "$$ARN"; \
	fi

apply-oidc: init import-oidc
	terraform apply $(TF_VARS) $(OIDC_TARGETS) -auto-approve

# Paso 5 — escribir el HCL por entorno y mover el estado local a S3
migrate-bootstrap-state:
	@mkdir -p envs/$(ENV)
	@{ echo "bucket         = \"$$(terraform output -raw state_bucket_name)\""; \
	   echo "key            = \"$(PROJECT)/$(ENV)/bootstrap.tfstate\""; \
	   echo "region         = \"$(AWS_REGION)\""; \
	   echo "dynamodb_table = \"$$(terraform output -raw dynamodb_table_name)\""; \
	   echo "encrypt        = true"; \
	 } > envs/$(ENV)/bootstrap-backend.hcl
	@sed -i.bak 's/backend "local" {}/backend "s3" {}/' main.tf && rm -f main.tf.bak
	terraform init -backend-config=envs/$(ENV)/bootstrap-backend.hcl \
	  -migrate-state -force-copy

Nada de eso es ingenioso — esa es la idea. Son cuarenta líneas de pegamento de shell alrededor de tres comandos de Terraform, y le da a una cuenta de AWS recién creada la misma historia de un solo disparo que un spoke de Control Tower.

TARGET DEL MAKEFILEEFECTO01 / apply-backendapply dirigido de S3 + DDBcrea el backend de estadoterraform.tfstate sigue en disco02 / apply-oidcimportar-luego-aplicarOIDC + rol + políticaimporta proveedor · SLRs preexistentes03 / generate-backendlee los outputs del bootstrapescribe terraform/backend.hclbucket · key · region · lock table · encrypt04 / init-mainterraform init en ../terraformmódulo principal conectado a S3el primer plan usa estado remoto05 / migrate-bootstrap-statesed + init -migrate-stateel estado bootstrap pasa a S3se gestiona a sí mismo desde aquí06 / DESDE CIGitHub Actions asume el rol vía OIDC y ejecuta terraform contra el mismo backend
Cinco targets de make, un apply por paso, una migración. Tras el paso cinco, el módulo bootstrap lee su propio estado desde S3; el paso seis es lo que toda ejecución de CI hace para siempre.

Modos de fallo que vale la pena diseñar

Hay dos cosas que muerden la primera vez que ejecutas el módulo contra una cuenta que no está perfectamente limpia.

El proveedor OIDC para token.actions.githubusercontent.com es un recurso a nivel de cuenta. Si cualquier otra cosa en la cuenta lo ha creado — otro stack, una solución de AWS, el experimento de un compañero — el segundo aws_iam_openid_connect_provider fallará con un error de "provider already exists". La solución es comprobarlo vía AWS CLI antes del apply, y terraform importar el ARN existente al estado si aparece. El Makefile del módulo encierra esa comprobación para que se ejecute automáticamente.

El mismo patrón se repite con los service-linked roles de AWS — los roles AWSServiceRoleForECS, ...ForElasticLoadBalancing y ...ForRDS. Son de cuenta, suelen estar precreados por la consola la primera vez que abres uno de esos servicios, y un recurso aws_iam_service_linked_role de Terraform se negará a crear uno que ya existe. Mismo truco de importar-antes-de-aplicar, mismo target del Makefile, cuatro recursos.

El contraargumento de gestionar los service-linked roles en Terraform es razonable: son propiedad de AWS, no se pueden personalizar, e importarlos es trabajo de relleno. Los mantengo en el módulo porque la alternativa es una lista de conocimiento tribal del estilo "cosas que también tienen que existir antes de que esta cuenta funcione". El import es barato; la documentación que reemplaza, no.

Y entonces qué

Un proyecto de AWS de una sola cuenta — del tipo que despliega desde un repo, tiene una persona ingeniera la mayoría de las semanas, y nunca ve Control Tower — puede tener la misma historia de CI reproducible y auditable que un setup multi-cuenta. No requiere una herramienta distinta. Requiere un módulo de Terraform con un backend de marcador, un Makefile que sepa migrar el estado e importar los recursos que AWS precrea, y una política de IAM que sea honesta sobre ser amplia porque solo hay un inquilino.

Todo junto son unas cuatrocientas líneas de HCL más un Makefile. Si estás empezando un proyecto pequeño, esa es la inversión completa en CI sin claves. Hazla una vez, y no vuelvas a poner una clave de AWS de larga duración en un secreto de GitHub.

Comentarios

Enviando…

100 / 100

Al publicar un comentario, aceptas los Términos de Uso.