Ir al contenido

La infraestructura

Al final de este módulo TaskFlow está servido por CloudFront desde un bucket privado, y tú habrás provocado y arreglado el error que rompe todas las SPA en S3.

Todo pasa en el repositorio de infraestructura.

Ventana de terminal
cd taskflow-p01-infra
make help

Objetivo: poner tu nombre y tus repositorios en las variables.

Ventana de terminal
cd bootstrap
cp terraform.tfvars.example terraform.tfvars

Edita terraform.tfvars y cambia tres valores:

bootstrap/terraform.tfvars
project = "taskflow"
participante = "p01"
github_repository_app = "Protecso-SAC/taskflow-p01-app"
github_repository_infra = "Protecso-SAC/taskflow-p01-infra"

Qué deberías ver: nada todavía. Solo asegúrate de que participante no lleve mayúsculas ni espacios, y que los repositorios sean los tuyos.

Paso 6.2 · Comprobar con qué identidad trabajas

Sección titulada «Paso 6.2 · Comprobar con qué identidad trabajas»

Objetivo: no descubrir después de crear recursos que estabas en la cuenta equivocada.

Ventana de terminal
cd ..
make whoami

Qué deberías ver: un JSON con Account y Arn. Confirma que la cuenta es la del taller.

Objetivo: crear el bucket donde vivirá el estado de Terraform, y tus tres roles.

Ventana de terminal
make bootstrap-init
make bootstrap-plan
make bootstrap-apply

Y guarda las salidas, que vas a necesitar dos veces:

Ventana de terminal
make bootstrap-output

Qué deberías ver: entre nueve y once recursos creados, y unas salidas con el nombre del bucket de estado y tres ARN de roles.

Ver el bootstrap completo y qué hace cada parte
bootstrap/main.tf
data "aws_caller_identity" "current" {}
locals {
cuenta = data.aws_caller_identity.current.account_id
nombre = "${var.project}-${var.participante}"
# El bucket de estado es por participante: cada uno gestiona su propia
# infraestructura sin poder pisar la de otro, incluso compartiendo cuenta.
state_bucket = "${local.nombre}-tfstate-${local.cuenta}"
oidc_host = "token.actions.githubusercontent.com"
oidc_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github[0].arn
}
# ---------------------------------------------------------------------------
# Backend del estado de Terraform
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "state" {
bucket = local.state_bucket
# El estado describe toda tu infraestructura. Borrarlo es peor que borrar los
# recursos, porque pierdes la capacidad de gestionarlos. En producción esto
# siempre va en false.
force_destroy = false
}
# Imprescindible: es lo que permite recuperar un estado corrupto o borrado.
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Desde Terraform 1.10 el bloqueo de estado se hace con un archivo de lock en el
# propio bucket (use_lockfile = true en el backend), así que ya no hace falta la
# tabla de DynamoDB que se usaba antes.
# ---------------------------------------------------------------------------
# Confianza con GitHub Actions vía OIDC
#
# Ningún rol tiene llaves de acceso. GitHub emite un token firmado de vida corta,
# AWS lo verifica contra este proveedor, y las condiciones de abajo deciden qué
# workflow puede asumir qué rol.
# ---------------------------------------------------------------------------
resource "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 1 : 0
url = "https://${local.oidc_host}"
client_id_list = ["sts.amazonaws.com"]
# AWS valida el certificado de GitHub contra su propio almacén de confianza,
# así que esta huella se mantiene por compatibilidad del recurso.
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
data "aws_iam_openid_connect_provider" "github" {
count = var.create_oidc_provider ? 0 : 1
url = "https://${local.oidc_host}"
}
# ---------------------------------------------------------------------------
# Rol de despliegue a dev, para el repositorio de la aplicación
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "confianza_dev" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.oidc_arn]
}
# La audiencia evita que un token emitido para otro servicio sirva aquí.
condition {
test = "StringEquals"
variable = "${local.oidc_host}:aud"
values = ["sts.amazonaws.com"]
}
# El claim `sub` amarra el rol al repositorio de la APLICACIÓN y al entorno
# dev. El repositorio de infraestructura no puede asumir este rol.
condition {
test = "StringEquals"
variable = "${local.oidc_host}:sub"
values = ["repo:${var.github_repository_app}:environment:dev"]
}
}
}
resource "aws_iam_role" "deploy_dev" {
name = "${local.nombre}-deploy-dev"
description = "Despliegue de ${local.nombre} a dev desde el repo de la app"
assume_role_policy = data.aws_iam_policy_document.confianza_dev.json
# Una sesión corta reduce la ventana de uso si un token se filtrara.
max_session_duration = 3600
}
# ---------------------------------------------------------------------------
# Rol de despliegue a producción, para el repositorio de la aplicación
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "confianza_prod" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.oidc_arn]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:sub"
values = ["repo:${var.github_repository_app}:environment:prod"]
}
# ═════════════════════════════════════════════════════════════════════
# PASO 9.5 — La cerradura de producción
#
# Descomenta las dos condiciones de abajo cuando la guía te lo indique.
#
# Faltan a propósito. Primero vas a comprobar que, sin ellas, cambiar el
# disparador del workflow a una rama despliega a producción. Después las
# agregas y ves que STS rechaza exactamente el mismo intento.
#
# Esa diferencia es la lección más importante del workshop: la política de
# seguridad no está en un archivo YAML que cualquiera puede editar.
#
# https://workshop.protecso.io/01-spa-en-aws/09-release-por-tag/
# ═════════════════════════════════════════════════════════════════════
# condition {
# test = "StringLike"
# variable = "${local.oidc_host}:ref"
# values = [var.tag_pattern]
# }
#
# condition {
# test = "StringEquals"
# variable = "${local.oidc_host}:ref_type"
# values = ["tag"]
# }
}
}
resource "aws_iam_role" "deploy_prod" {
name = "${local.nombre}-deploy-prod"
description = "Despliegue de ${local.nombre} a prod, solo desde un tag de versión"
assume_role_policy = data.aws_iam_policy_document.confianza_prod.json
max_session_duration = 3600
}
# Los dos roles de despliegue nacen aquí SIN ningún permiso adjunto. Cada stack de
# entorno les agrega una política acotada a su bucket y su distribución exactos,
# que todavía no existen cuando corre este bootstrap. Así el permiso nunca es más
# amplio de lo necesario y no hay que usar comodines en los ARN.
# ---------------------------------------------------------------------------
# Rol de solo lectura para el repositorio de infraestructura
#
# El `terraform apply` lo ejecuta una persona desde su terminal, no el pipeline.
# El CI del repo de infraestructura solo necesita leer para poder mostrar un plan
# en los pull requests.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "confianza_plan" {
count = var.create_plan_role ? 1 : 0
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.oidc_arn]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:aud"
values = ["sts.amazonaws.com"]
}
# Aquí el sujeto es el repositorio de INFRAESTRUCTURA, no el de la app.
condition {
test = "StringEquals"
variable = "${local.oidc_host}:sub"
values = ["repo:${var.github_repository_infra}:environment:plan"]
}
}
}
resource "aws_iam_role" "plan" {
count = var.create_plan_role ? 1 : 0
name = "${local.nombre}-plan"
description = "Solo lectura, para terraform plan en el CI de infraestructura"
assume_role_policy = data.aws_iam_policy_document.confianza_plan[0].json
max_session_duration = 3600
}
# ReadOnlyAccess es una política administrada por AWS: permite describir y listar,
# nunca crear ni modificar. Es exactamente lo que necesita un plan.
resource "aws_iam_role_policy_attachment" "plan_readonly" {
count = var.create_plan_role ? 1 : 0
role = aws_iam_role.plan[0].name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
# El plan también necesita leer el estado. Solo leer: el workflow corre con
# -lock=false justamente para no tener que escribir el archivo de bloqueo.
data "aws_iam_policy_document" "plan_estado" {
count = var.create_plan_role ? 1 : 0
statement {
sid = "LeerEstado"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.state.arn}/*"]
}
statement {
sid = "ListarBucketDeEstado"
effect = "Allow"
actions = ["s3:ListBucket"]
resources = [aws_s3_bucket.state.arn]
}
}
resource "aws_iam_role_policy" "plan_estado" {
count = var.create_plan_role ? 1 : 0
name = "${local.nombre}-plan-estado"
role = aws_iam_role.plan[0].name
policy = data.aws_iam_policy_document.plan_estado[0].json
}

Tres cosas que merecen atención.

Los roles nacen sin permisos. deploy_dev y deploy_prod se crean con una política de confianza pero sin ninguna política de permisos adjunta. Los permisos los agrega cada stack de entorno, apuntando a su bucket y su distribución exactos, que todavía no existen aquí. Así no hay que escribir comodines como arn:aws:s3:::taskflow-* y cruzar los dedos.

El proveedor OIDC se reutiliza. Solo puede existir uno por cuenta de AWS, así que en la cuenta del taller ya está creado y Terraform lo busca con un data source. De ahí create_oidc_provider = false.

El bloqueo de estado ya no necesita DynamoDB. Desde Terraform 1.10 el backend de S3 soporta use_lockfile = true, que escribe el bloqueo en el propio bucket. Si ves guías que crean una tabla de DynamoDB para esto, describen la forma antigua.

Paso 6.4 · Aplicar el entorno de desarrollo

Sección titulada «Paso 6.4 · Aplicar el entorno de desarrollo»

Objetivo: crear el bucket del sitio y la distribución de CloudFront.

Ventana de terminal
cd envs/dev
cp terraform.tfvars.example terraform.tfvars

Cambia dos valores. participante tiene que ser idéntico al del bootstrap, y deploy_role_name sale de la salida deploy_role_dev_name:

envs/dev/terraform.tfvars
participante = "p01"
deploy_role_name = "taskflow-p01-deploy-dev"

Inicializa con tu bucket de estado y aplica:

Ventana de terminal
cd ../..
make init ENV=dev STATE_BUCKET=taskflow-p01-tfstate-123456789012
make plan ENV=dev
make apply ENV=dev
make output ENV=dev

Qué deberías ver: el apply tarda varios minutos, porque CloudFront tiene que propagar la distribución a sus puntos de presencia. Es normal. Al terminar, la salida site_url con un dominio del tipo https://d111111abcdef8.cloudfront.net.

Ver el módulo de hosting completo y las decisiones que contiene
modules/spa-hosting/main.tf
data "aws_caller_identity" "current" {}
locals {
nombre = "${var.project}-${var.participante}-${var.environment}"
# El nombre de un bucket es único en TODO S3, no solo en tu cuenta. Tu
# identificador lo separa del de los demás participantes, y el id de cuenta
# evita choques con el resto del mundo.
bucket_name = "${local.nombre}-${data.aws_caller_identity.current.account_id}"
usar_dominio = var.domain_name != ""
dns_route53 = var.dns_mode == "route53" && local.usar_dominio
origin_id = "s3-${local.bucket_name}"
}
# ---------------------------------------------------------------------------
# Origen: bucket privado
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "site" {
bucket = local.bucket_name
tags = var.tags
}
# Sin esto, un `aws s3 cp --acl public-read` hecho por descuido dejaría el sitio
# expuesto. Es la causa de buena parte de las filtraciones de datos en S3.
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "site" {
bucket = aws_s3_bucket.site.id
rule {
# Desactiva las ACL por completo: el acceso se define solo con políticas.
object_ownership = "BucketOwnerEnforced"
}
}
# El versionado es lo que permite revertir un despliegue roto sin reconstruir.
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
bucket_key_enabled = true
}
}
# Con versionado activo, cada despliegue acumula versiones. Sin ciclo de vida el
# bucket crece para siempre.
resource "aws_s3_bucket_lifecycle_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
id = "expirar-versiones-antiguas"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = var.noncurrent_version_expiration_days
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
depends_on = [aws_s3_bucket_versioning.site]
}
# ---------------------------------------------------------------------------
# Certificado TLS. Siempre en us-east-1: es un requisito de CloudFront.
# ---------------------------------------------------------------------------
resource "aws_acm_certificate" "site" {
count = local.usar_dominio ? 1 : 0
provider = aws.us_east_1
domain_name = var.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = var.tags
}
# Solo en modo route53: los registros de validación se crean automáticamente.
# En modo external hay que crearlos a mano con la salida acm_validation_records.
resource "aws_route53_record" "validacion" {
for_each = local.dns_route53 ? {
for opcion in aws_acm_certificate.site[0].domain_validation_options :
opcion.domain_name => {
name = opcion.resource_record_name
type = opcion.resource_record_type
value = opcion.resource_record_value
}
} : {}
zone_id = var.route53_zone_id
name = each.value.name
type = each.value.type
records = [each.value.value]
ttl = 60
allow_overwrite = true
}
# Este recurso no crea nada: espera a que ACM emita el certificado.
# En modo external, el apply se queda aquí hasta que agregues el CNAME en tu
# proveedor de DNS. Es el momento del taller en el que se ve para qué sirve.
resource "aws_acm_certificate_validation" "site" {
count = local.usar_dominio ? 1 : 0
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.site[0].arn
validation_record_fqdns = local.dns_route53 ? [
for registro in aws_route53_record.validacion : registro.fqdn
] : null
timeouts {
create = "30m"
}
}
# ---------------------------------------------------------------------------
# CloudFront
# ---------------------------------------------------------------------------
# OAC hace que CloudFront firme cada petición al bucket con SigV4. El bucket
# nunca se vuelve público: solo confía en esta distribución.
resource "aws_cloudfront_origin_access_control" "site" {
name = local.nombre
description = "Acceso de CloudFront al bucket de ${local.nombre}"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# Política administrada por AWS. Respeta las cabeceras Cache-Control que trae
# cada objeto desde el origen, que es justo lo que necesitamos: el pipeline sube
# los assets con hash como inmutables y el index.html sin caché.
data "aws_cloudfront_cache_policy" "optimizada" {
name = "Managed-CachingOptimized"
}
resource "aws_cloudfront_response_headers_policy" "site" {
name = "${local.nombre}-seguridad"
comment = "Cabeceras de seguridad para ${local.nombre}"
security_headers_config {
content_type_options {
override = true
}
frame_options {
frame_option = "DENY"
override = true
}
referrer_policy {
referrer_policy = "strict-origin-when-cross-origin"
override = true
}
xss_protection {
protection = true
mode_block = true
override = true
}
# HSTS solo tiene sentido con dominio propio: el dominio por defecto de
# CloudFront es compartido y no queremos fijarle política a nadie más.
dynamic "strict_transport_security" {
for_each = local.usar_dominio ? [1] : []
content {
access_control_max_age_sec = 31536000
include_subdomains = false
preload = false
override = true
}
}
}
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
comment = local.nombre
default_root_object = "index.html"
price_class = var.price_class
http_version = "http2and3"
aliases = local.usar_dominio ? [var.domain_name] : []
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = local.origin_id
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = local.origin_id
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.optimizada.id
response_headers_policy_id = aws_cloudfront_response_headers_policy.site.id
}
# ═══════════════════════════════════════════════════════════════════════
# PASO 6.6 — El fallback de rutas de la SPA
#
# Descomenta los dos bloques de abajo cuando la guía te lo indique.
#
# Ahora mismo faltan a propósito. Despliega primero, recarga la URL de una
# tarea, y mira qué responde CloudFront. Después vuelve aquí.
#
# https://workshop.protecso.io/01-spa-en-aws/06-infraestructura/
# ═══════════════════════════════════════════════════════════════════════
# custom_error_response {
# error_code = 403
# response_code = 200
# response_page_path = "/index.html"
# error_caching_min_ttl = 0
# }
#
# custom_error_response {
# error_code = 404
# response_code = 200
# response_page_path = "/index.html"
# error_caching_min_ttl = 0
# }
viewer_certificate {
cloudfront_default_certificate = !local.usar_dominio
acm_certificate_arn = local.usar_dominio ? aws_acm_certificate_validation.site[0].certificate_arn : null
ssl_support_method = local.usar_dominio ? "sni-only" : null
minimum_protocol_version = local.usar_dominio ? "TLSv1.2_2021" : null
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
tags = var.tags
}
# ---------------------------------------------------------------------------
# Política del bucket: solo esta distribución puede leerlo
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "bucket" {
statement {
sid = "LecturaSoloDesdeEstaDistribucion"
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
# Sin esta condición, cualquier distribución de CloudFront de cualquier
# cuenta de AWS podría usar tu bucket como origen.
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.bucket.json
depends_on = [aws_s3_bucket_public_access_block.site]
}
# ---------------------------------------------------------------------------
# DNS del sitio (solo modo route53)
# ---------------------------------------------------------------------------
resource "aws_route53_record" "alias" {
for_each = local.dns_route53 ? toset(["A", "AAAA"]) : toset([])
zone_id = var.route53_zone_id
name = var.domain_name
type = each.value
# Un alias apunta a CloudFront sin costo de consulta y resuelve al punto de
# presencia más cercano. Un CNAME normal no puede usarse en el ápex del dominio.
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
}

El bucket es privado, y de verdad. Cuatro recursos trabajan juntos: aws_s3_bucket_public_access_block con las cuatro banderas en true, para que un aws s3 cp --acl public-read hecho por descuido no exponga el sitio; BucketOwnerEnforced, que desactiva las ACL por completo; y una política que permite leer solo a CloudFront, con una condición AWS:SourceArn que amarra el permiso a tu distribución.

Sin esa condición la política diría «cualquier distribución de CloudFront puede leer este bucket», y CloudFront es un servicio compartido: cualquiera con una cuenta de AWS podría apuntar una distribución a tu bucket y hacer que tú pagues el tráfico.

OAC en lugar de OAI. aws_cloudfront_origin_access_control hace que CloudFront firme cada petición al bucket con SigV4. Si encuentras documentación que habla de Origin Access Identity, es el mecanismo anterior.

El certificado va en us-east-1, siempre. De ahí el configuration_aliases = [aws.us_east_1]. CloudFront solo acepta certificados de ACM de esa región, sin importar dónde viva el resto. No es una preferencia: es un requisito del servicio, y es lo que más tiempo hace perder la primera vez.

La caché la deciden los objetos. La política Managed-CachingOptimized respeta las cabeceras Cache-Control que trae cada objeto desde el origen. Es decir, la estrategia de caché no vive en Terraform sino en el momento de subir los archivos, y la vas a configurar en el módulo 8.

Ver el stack de dev y la política del rol de despliegue
envs/dev/main.tf
module "hosting" {
source = "../../modules/spa-hosting"
providers = {
aws = aws
aws.us_east_1 = aws.us_east_1
}
project = var.project
participante = var.participante
environment = "dev"
# Desarrollo NO usa dominio propio, y es una decisión deliberada.
#
# Sin dominio no hay certificado que validar ni DNS que esperar, así que este
# stack se aplica y funciona en pocos minutos. El sitio queda en el dominio que
# asigna CloudFront, del tipo d111111abcdef8.cloudfront.net.
#
# El dominio propio se ve en producción, donde además importa. Así el módulo se
# ejercita en sus dos modos y el taller no gasta media hora esperando DNS.
domain_name = ""
price_class = var.price_class
tags = var.tags
}
# ---------------------------------------------------------------------------
# Permisos del rol de despliegue
#
# El rol lo creó el bootstrap sin ningún permiso. Aquí recibe exactamente los que
# necesita para este entorno, referenciando el bucket y la distribución reales en
# lugar de comodines.
#
# Observa qué NO puede hacer este rol: no puede crear infraestructura, no puede
# leer otros buckets, no puede tocar producción, y no puede ser asumido desde el
# repositorio de infraestructura. Si el token de un workflow se filtrara, el daño
# posible queda acotado a sobrescribir el sitio de desarrollo.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "deploy" {
statement {
sid = "SincronizarSitio"
effect = "Allow"
actions = [
"s3:PutObject",
"s3:DeleteObject",
"s3:GetObject",
]
resources = ["${module.hosting.bucket_arn}/*"]
}
statement {
sid = "ListarBucket"
effect = "Allow"
# `aws s3 sync` necesita listar para saber qué cambió.
actions = ["s3:ListBucket"]
resources = [module.hosting.bucket_arn]
}
statement {
sid = "InvalidarCache"
effect = "Allow"
actions = [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
]
resources = [module.hosting.distribution_arn]
}
}
resource "aws_iam_role_policy" "deploy" {
name = "${var.project}-${var.participante}-dev-deploy"
role = var.deploy_role_name
policy = data.aws_iam_policy_document.deploy.json
}

Mira qué no puede hacer ese rol: no puede crear infraestructura, no puede leer otros buckets, no puede tocar producción. Si el token de un workflow se filtrara, el daño posible queda acotado a sobrescribir el sitio de desarrollo.

Objetivo: tener algo arriba para poder romperlo.

Compila la aplicación:

Ventana de terminal
cd ../taskflow-p01-app
make build

Y sube el resultado desde el contenedor:

Ventana de terminal
cd ../taskflow-p01-infra
make shell
Ventana de terminal
aws s3 sync ../taskflow-p01-app/dist/ \
"s3://taskflow-p01-dev-123456789012/"
exit

Qué deberías ver: abre la site_url del paso 6.4. La aplicación carga. Crea un par de tareas.

Objetivo: ver con tus propios ojos el error que rompe todas las SPA en S3.

  1. En el sitio, entra al detalle de una tarea tocando su título.

  2. Funciona. Fíjate en la URL: cambió a /tareas/3f2b1a4c-... sin recargar la página. Esa navegación la hizo el router en el navegador, no hubo ninguna petición al servidor.

  3. Ahora recarga esa página con F5.

Qué deberías ver: un XML con AccessDenied. Ese es el 403.

Lo que pasó: el navegador pidió /tareas/3f2b1a4c a CloudFront, CloudFront lo pidió a S3, y S3 respondió que no existe ningún objeto con ese nombre. Porque no existe: tu dist/ solo tiene index.html y los assets.

Abre el módulo y busca el marcador PASO 6.6:

Ventana de terminal
cd taskflow-p01-infra
modules/spa-hosting/main.tf
# custom_error_response {
# error_code = 403
# response_code = 200
# response_page_path = "/index.html"
# error_caching_min_ttl = 0
# }
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 0
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
error_caching_min_ttl = 0
}

Descomenta los dos bloques y aplica:

Ventana de terminal
make apply ENV=dev

Qué deberías ver: Terraform modifica la distribución. Espera a que termine, recarga la página del detalle, y ahora funciona.

Lo que hace: cuando S3 responde 403, CloudFront lo convierte en un 200 con el contenido de index.html. El router del navegador recibe la página y resuelve la ruta él mismo. El error_caching_min_ttl = 0 evita que CloudFront cachee el error.

Paso 6.7 · Comprobar que el bucket es privado

Sección titulada «Paso 6.7 · Comprobar que el bucket es privado»

Objetivo: verificar que el contenido solo se sirve por CloudFront.

Ventana de terminal
curl -s -o /dev/null -w '%{http_code}\n' \
https://taskflow-p01-dev-123456789012.s3.amazonaws.com/index.html

Qué deberías ver: 403.

Si responde 200, tu bucket está público y hay que revisar la configuración antes de seguir. No es un detalle menor: sería servir el sitio sin CDN, sin las cabeceras de seguridad y sin el fallback de rutas, además de pagar la transferencia de S3.

Ventana de terminal
# bootstrap
cd bootstrap && cp terraform.tfvars.example terraform.tfvars # editar
cd .. && make whoami
make bootstrap-init && make bootstrap-apply && make bootstrap-output
# entorno dev
cd envs/dev && cp terraform.tfvars.example terraform.tfvars # editar
cd ../.. && make init ENV=dev STATE_BUCKET=<bucket-de-estado>
make plan ENV=dev && make apply ENV=dev && make output ENV=dev
# subir el sitio
cd ../taskflow-p01-app && make build
cd ../taskflow-p01-infra && make shell
aws s3 sync ../taskflow-p01-app/dist/ "s3://<bucket-del-sitio>/"
# tras descomentar el PASO 6.6
make apply ENV=dev

Ahora le ponemos dominio a producción.