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.
cd taskflow-p01-inframake helpPaso 6.1 · Configurar el bootstrap
Sección titulada «Paso 6.1 · Configurar el bootstrap»Objetivo: poner tu nombre y tus repositorios en las variables.
cd bootstrapcp terraform.tfvars.example terraform.tfvarsEdita terraform.tfvars y cambia tres valores:
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.
cd ..make whoamiQué deberías ver: un JSON con Account y Arn. Confirma que la cuenta es la del
taller.
Paso 6.3 · Aplicar el bootstrap
Sección titulada «Paso 6.3 · Aplicar el bootstrap»Objetivo: crear el bucket donde vivirá el estado de Terraform, y tus tres roles.
make bootstrap-initmake bootstrap-planmake bootstrap-applyY guarda las salidas, que vas a necesitar dos veces:
make bootstrap-outputQué 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
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.
cd envs/devcp terraform.tfvars.example terraform.tfvarsCambia dos valores. participante tiene que ser idéntico al del bootstrap, y
deploy_role_name sale de la salida deploy_role_dev_name:
participante = "p01"deploy_role_name = "taskflow-p01-deploy-dev"Inicializa con tu bucket de estado y aplica:
cd ../..make init ENV=dev STATE_BUCKET=taskflow-p01-tfstate-123456789012make plan ENV=devmake apply ENV=devmake output ENV=devQué 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
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
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.
Paso 6.5 · Subir el sitio a mano, una vez
Sección titulada «Paso 6.5 · Subir el sitio a mano, una vez»Objetivo: tener algo arriba para poder romperlo.
Compila la aplicación:
cd ../taskflow-p01-appmake buildY sube el resultado desde el contenedor:
cd ../taskflow-p01-inframake shellaws s3 sync ../taskflow-p01-app/dist/ \ "s3://taskflow-p01-dev-123456789012/"exitQué deberías ver: abre la site_url del paso 6.4. La aplicación carga. Crea un par
de tareas.
Paso 6.6 · Provocar el 403
Sección titulada «Paso 6.6 · Provocar el 403»Objetivo: ver con tus propios ojos el error que rompe todas las SPA en S3.
-
En el sitio, entra al detalle de una tarea tocando su título.
-
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. -
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.
Arreglarlo
Sección titulada «Arreglarlo»Abre el módulo y busca el marcador PASO 6.6:
cd taskflow-p01-infra # 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:
make apply ENV=devQué 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.
curl -s -o /dev/null -w '%{http_code}\n' \ https://taskflow-p01-dev-123456789012.s3.amazonaws.com/index.htmlQué 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.
Resumen de comandos
Sección titulada «Resumen de comandos»# bootstrapcd bootstrap && cp terraform.tfvars.example terraform.tfvars # editarcd .. && make whoamimake bootstrap-init && make bootstrap-apply && make bootstrap-output
# entorno devcd envs/dev && cp terraform.tfvars.example terraform.tfvars # editarcd ../.. && make init ENV=dev STATE_BUCKET=<bucket-de-estado>make plan ENV=dev && make apply ENV=dev && make output ENV=dev
# subir el sitiocd ../taskflow-p01-app && make buildcd ../taskflow-p01-infra && make shellaws s3 sync ../taskflow-p01-app/dist/ "s3://<bucket-del-sitio>/"
# tras descomentar el PASO 6.6make apply ENV=dev