Tienes el plano. Ahora toca construir.

Y aquí es donde la mayoría de proyectos personales se tuercen, por una razón muy concreta: la construcción no falla por dificultad técnica, falla por orden. Quien empieza por la aplicación acaba con una app bonita colgada de una infraestructura creada a mano que no se puede reproducir. Quien empieza por la seguridad al revés —abriendo todo "para que funcione" y prometiéndose cerrarlo luego— nunca lo cierra. Quien no controla el gasto descubre en la semana tres que se ha comido el crédito entero.

Esta lección es un manual de trabajo. No explica qué es Cloud Run ni qué es Terraform: eso ya lo viste en 07-02 y en 06-07. Explica en qué orden se ponen las piezas, por qué ese orden y no otro, y cómo compruebas después de cada paso que lo que has hecho está bien.

Está organizada en nueve fases, de la 0 a la 8. Cada fase tiene su objetivo, sus pasos, sus comandos y —lo más importante para trabajar solo— su criterio de "hecho" verificable: un comando que ejecutas y una salida que esperas. Si el comando no da lo esperado, la fase no está terminada, por mucho que "parezca" que funciona.

Al final de la lección tendrás tu sistema en marcha, reproducible desde cero, con entrega automatizada, seguro y observado. Y sabrás exactamente en qué punto estás en cada momento, que cuando trabajas solo es la mitad de la batalla.

Contenido

  1. El orden de construcción y por qué es ese
  2. Fase 0 — Preparación: proyectos, APIs, presupuesto y repositorio
  3. Fase 1 — La base con Terraform: estado, proveedor, módulos y red
  4. Fase 2 — Identidad: cuentas de servicio, roles mínimos y federación sin claves
  5. Fase 3 — Datos: base de datos privada, buckets y migraciones como código
  6. Fase 4 — La aplicación: contenedor, configuración, secretos, salud y logs
  7. Fase 5 — La entrega automatizada: pruebas, construcción, despliegue y promoción
  8. Fase 6 — La capa de datos y de IA
  9. Fase 7 — Exposición: dominio, TLS y protección
  10. Fase 8 — Observabilidad: logs, panel, alertas y SLO
  11. Prácticas transversales durante toda la implementación
  12. Qué hacer cuando te atascas: el método de cinco pasos
  13. Registro de progreso y criterios de "hecho"
  14. El aviso realista: la primera vez todo tarda el doble

  1. El orden de construcción y por qué es ese

flowchart TD
    F0["Fase 0 — Preparación<br/>proyectos · APIs · presupuesto · repo"]
    F1["Fase 1 — Base<br/>estado Terraform · red · firewall"]
    F2["Fase 2 — Identidad<br/>cuentas de servicio · roles · WIF"]
    F3["Fase 3 — Datos<br/>BD privada · buckets · migraciones"]
    F4["Fase 4 — Aplicación<br/>contenedor · secretos · salud · logs"]
    F5["Fase 5 — Entrega<br/>CI/CD · pruebas · promoción"]
    F6["Fase 6 — Datos e IA<br/>eventos · BigQuery · panel · modelo"]
    F7["Fase 7 — Exposición<br/>dominio · TLS · protección"]
    F8["Fase 8 — Observabilidad<br/>panel · alertas · SLO"]

    F0 --> F1 --> F2 --> F3 --> F4 --> F5
    F4 --> F6
    F5 --> F7
    F6 --> F8
    F7 --> F8

    style F0 fill:#e8f0fe
    style F4 fill:#fef7e0
    style F8 fill:#e6f4ea

La justificación de cada dependencia, que es lo que hace del orden algo racional y no una costumbre:

Fase Depende de Por qué exactamente
0. Preparación Nada El projectId es irreversible y las APIs tardan minutos en propagarse. Y el presupuesto tiene que existir antes de que puedas gastar
1. Base 0 Terraform necesita un proyecto con la API de Resource Manager activa y un bucket para el estado. La red no se puede reconfigurar sin destruir lo que hay dentro
2. Identidad 1 Las cuentas de servicio son de proyecto, pero los permisos sobre recursos concretos (bucket, secreto, topic) requieren que esos recursos existan… o que los crees en el mismo apply. Va antes que los datos porque la BD se crea ya con su usuario y su secreto
3. Datos 1, 2 Cloud SQL con IP privada exige la red y el peering de acceso privado a servicios ya creados. Y su contraseña va a Secret Manager, que necesita la identidad que la leerá
4. Aplicación 3 La app no arranca sin BD ni sin secretos. Desplegarla antes obliga a desplegarla dos veces
5. Entrega 4 No puedes automatizar un despliegue que nunca has hecho a mano. Primero se hace una vez y se entiende; después se automatiza
6. Datos e IA 4 Los eventos los emite la aplicación. Sin aplicación no hay eventos que procesar
7. Exposición 5 El dominio apunta a un servicio estable. Si la app aún cambia de forma cada día, la propagación de DNS y el certificado son ruido
8. Observabilidad 6, 7 Se observa el sistema completo. Un panel montado sobre medio sistema hay que rehacerlo

La regla general detrás de todo esto: se construye de lo irreversible a lo reversible, y de lo que no depende de nada a lo que depende de todo. El projectId no se cambia; un panel de Monitoring se rehace en diez minutos. Por eso el primero va en la fase 0 y el segundo en la 8.

La excepción legítima: si en algún momento te bloqueas más de una hora en una fase, salta a la siguiente que no dependa de ella y vuelve después. La fase 6 (datos e IA) y la 7 (exposición) son independientes entre sí; la 8 depende de ambas. Documenta el salto en el diario.

  1. Fase 0 — Preparación

Objetivo: dejar el terreno listo para que Terraform pueda trabajar, con el gasto controlado desde el primer minuto.

Tiempo estimado: 1-2 horas.

2.1 Crear los proyectos con los nombres decididos en 08-02

# Variables del proyecto — ajústalas a lo que decidiste en el diseño
export PROY_BASE="refugio"
export PROY_DEV="${PROY_BASE}-dev"
export PROY_PROD="${PROY_BASE}-prod"
export PROY_DATOS="${PROY_BASE}-datos"
export REGION="europe-west1"
export BILLING_ACCOUNT="0X0X0X-0X0X0X-0X0X0X"   # gcloud billing accounts list

# Crear los proyectos
for P in "${PROY_DEV}" "${PROY_PROD}" "${PROY_DATOS}"; do
  gcloud projects create "${P}" --name="${P}"
  gcloud billing projects link "${P}" --billing-account="${BILLING_ACCOUNT}"
done

# Comprobar
gcloud projects list --filter="projectId:${PROY_BASE}-*" \
  --format="table(projectId, name, lifecycleState)"

⚠️ Antes de pulsar Enter, lee los nombres en voz alta. Es la última oportunidad. Un projectId no se cambia, no se reutiliza ni después de borrarlo, y aparecerá en cada captura de pantalla de tu presentación.

Si gcloud projects create falla con already exists, no es que tú lo tengas: es que alguien en el mundo lo tiene, porque el espacio de nombres es global. Añade un sufijo corto y distintivo, no un -2.

2.2 Activar las APIs

Las APIs tardan de segundos a minutos en propagarse. Actívalas todas de golpe ahora y te ahorras diez interrupciones después:

APIS=(
  cloudresourcemanager.googleapis.com   # Terraform necesita esta la primera
  serviceusage.googleapis.com
  iam.googleapis.com
  iamcredentials.googleapis.com
  compute.googleapis.com                # red, direcciones, firewall
  servicenetworking.googleapis.com      # peering para Cloud SQL privada
  vpcaccess.googleapis.com              # conector serverless
  run.googleapis.com
  artifactregistry.googleapis.com
  cloudbuild.googleapis.com
  sqladmin.googleapis.com
  secretmanager.googleapis.com
  storage.googleapis.com
  pubsub.googleapis.com
  bigquery.googleapis.com
  cloudfunctions.googleapis.com
  eventarc.googleapis.com
  cloudscheduler.googleapis.com
  monitoring.googleapis.com
  logging.googleapis.com
  cloudtrace.googleapis.com
  language.googleapis.com               # sustituye por tu API de IA
)

for P in "${PROY_DEV}" "${PROY_PROD}"; do
  gcloud services enable "${APIS[@]}" --project="${P}"
done

gcloud services enable bigquery.googleapis.com storage.googleapis.com \
  --project="${PROY_DATOS}"

Criterio de "hecho":

gcloud services list --enabled --project="${PROY_PROD}" | wc -l   # debe rondar 25+

2.3 El presupuesto, antes que nada

Ya lo creaste en 08-01. Si no, hazlo ahora, antes de crear el primer recurso de pago:

gcloud billing budgets create \
  --billing-account="${BILLING_ACCOUNT}" \
  --display-name="Proyecto final ${PROY_BASE}" \
  --budget-amount=12EUR \
  --threshold-rule=percent=0.5 \
  --threshold-rule=percent=0.9 \
  --threshold-rule=percent=1.0 \
  --filter-projects="projects/${PROY_DEV}","projects/${PROY_PROD}","projects/${PROY_DATOS}"

gcloud billing budgets list --billing-account="${BILLING_ACCOUNT}"

Y activa la exportación de facturación a BigQuery desde la consola (Facturación → Exportación de facturación). Tarda hasta 24 horas en empezar a poblar datos, así que cuanto antes se active, antes tendrás histórico para la sección de coste de tu presentación.

2.4 El repositorio y su estructura

Si seguiste el ejercicio 3 de 08-01, ya lo tienes. Ampliado para la implementación:

mi-proyecto/
├── README.md
├── .gitignore
├── cloudbuild.yaml                 # pipeline de dev
├── cloudbuild-prod.yaml            # promoción a prod
├── app/
│   ├── Dockerfile
│   ├── requirements.txt
│   ├── src/
│   │   ├── main.py
│   │   ├── db.py
│   │   ├── eventos.py
│   │   └── observabilidad.py
│   └── tests/
│       ├── test_unitarios.py
│       └── test_integracion.py
├── infra/
│   ├── modules/
│   │   ├── red/
│   │   ├── identidad/
│   │   ├── datos/
│   │   ├── servicio/
│   │   └── observabilidad/
│   ├── envs/
│   │   ├── dev/{main.tf,variables.tf,terraform.tfvars,backend.tf}
│   │   └── prod/{main.tf,variables.tf,terraform.tfvars,backend.tf}
│   └── bootstrap/                  # crea el bucket de estado. Se aplica una vez
├── data/
│   ├── seed/generar.py
│   ├── migraciones/
│   │   ├── 001_esquema_inicial.sql
│   │   └── 002_indices.sql
│   └── sql/analitica.sql
├── ml/
│   └── analizar_opiniones.py
└── docs/
    ├── arquitectura.md
    ├── runbook.md
    ├── diario.md
    └── adr/

El README que sí se lee. Escríbelo ahora, no al final, y con esta estructura:

# RefugioReserva

Plataforma de reservas de refugios de montaña. Proyecto final del curso de GCP.
**Todos los datos son ficticios.**

🔗 **Demo:** https://refugioreserva.example
📊 **Cuadro de mando:** [Looker Studio](...)
📐 **Arquitectura:** [docs/arquitectura.md](docs/arquitectura.md)

## Qué hace
Un excursionista consulta disponibilidad de plazas en 12 refugios y reserva.
El guarda ve las reservas del día. La federación consulta indicadores.

## Arquitectura en una línea
Cloud Run (Python/FastAPI) → Cloud SQL PostgreSQL privada · fotos en Cloud
Storage · eventos por Pub/Sub → BigQuery → Looker Studio · sentimiento de
opiniones con la API de Natural Language. Todo en Terraform, desplegado por
Cloud Build sin claves (WIF).

## Cómo levantarlo desde cero

cd infra/bootstrap && terraform init && terraform apply cd ../envs/dev && terraform init && terraform apply make seed

## Coste
**~10 €/mes.** Ver [docs/adr/ADR-006-sin-balanceador.md](docs/adr/) para
la decisión de coste más relevante.

## Estado y deuda técnica conocida
Ver la sección "Deuda técnica" más abajo. Sí, la hay, y está priorizada.

Criterio de "hecho" de la fase 0:

Comprobación Comando Resultado esperado
Proyectos creados y facturables gcloud billing projects describe ${PROY_PROD} billingEnabled: true
APIs activas gcloud services list --enabled --project=${PROY_PROD} ≥25 líneas
Presupuesto creado gcloud billing budgets list --billing-account=${BILLING_ACCOUNT} 1 presupuesto
Repositorio con estructura y README git log --oneline ≥2 commits

  1. Fase 1 — La base con Terraform

Objetivo: que a partir de aquí todo se cree con código.

Tiempo estimado: 4-8 horas la primera vez.

3.1 El bucket de estado (bootstrap)

Hay un problema del huevo y la gallina: Terraform guarda su estado en un bucket, pero el bucket también hay que crearlo. La solución estándar es un pequeño módulo bootstrap con estado local que se aplica una sola vez:

# infra/bootstrap/main.tf
terraform {
  required_version = ">= 1.9"
  required_providers {
    google = { source = "hashicorp/google", version = "~> 6.0" }
  }
  # Sin backend: estado local, se aplica una vez y se sube el .tfstate cifrado
  # o simplemente se acepta que este módulo se re-crea a mano si se pierde.
}

provider "google" {
  project = var.proyecto_estado
  region  = var.region
}

resource "google_storage_bucket" "estado" {
  name          = "${var.prefijo}-terraform-estado"
  location      = var.region
  force_destroy = false

  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = true }        # ← imprescindible: permite recuperar un estado corrupto

  lifecycle_rule {
    condition { num_newer_versions = 20 }
    action    { type = "Delete" }
  }

  labels = {
    proyecto       = var.prefijo
    componente     = "iac"
    gestionado-por = "terraform"
  }
}

Las tres opciones que no son negociables:

  • versioning.enabled = true: si el estado se corrompe (y pasa), la versión anterior te salva el proyecto.
  • public_access_prevention = "enforced": el estado de Terraform contiene identificadores de recursos y, a veces, valores sensibles. Nunca público.
  • force_destroy = false: evita que un destroy accidental se lleve el estado de todo lo demás.
cd infra/bootstrap
terraform init && terraform apply
gcloud storage buckets describe gs://refugio-terraform-estado \
  --format="value(versioning.enabled,iamConfiguration.publicAccessPrevention)"
# Esperado: True  enforced

3.2 El backend remoto y el proveedor fijado

# infra/envs/prod/backend.tf
terraform {
  required_version = ">= 1.9"

  backend "gcs" {
    bucket = "refugio-terraform-estado"
    prefix = "envs/prod"                  # dev usa "envs/dev": estados separados
  }

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"                  # fijado. NUNCA sin versión
    }
    random = { source = "hashicorp/random", version = "~> 3.6" }
  }
}

Por qué prefix distinto por entorno y no dos buckets: un bucket, dos prefijos, dos estados completamente independientes. Es más simple de gestionar y no hay riesgo de que un apply de dev toque prod, porque son ficheros de estado distintos.

Por qué fijar la versión del proveedor: sin version, Terraform coge la última cada vez que haces init. Un martes cualquiera sale la versión 7.0 con cambios incompatibles y tu plan propone destruir media infraestructura. Con ~> 6.0 te quedas en la rama 6 hasta que decidas subir tú, conscientemente y con un commit que lo diga.

3.3 Módulos propios

La regla práctica: crea un módulo cuando vayas a usar la misma cosa en dos entornos. Con dev y prod, eso es casi todo.

# infra/modules/red/main.tf
variable "proyecto"    { type = string }
variable "prefijo"     { type = string }
variable "region"      { type = string }
variable "cidr_app"    { type = string }
variable "cidr_conector" { type = string }
variable "cidr_privado"  { type = string }
variable "etiquetas"   { type = map(string) }

resource "google_compute_network" "vpc" {
  project                 = var.proyecto
  name                    = "${var.prefijo}-vpc"
  auto_create_subnetworks = false        # ← NUNCA la red default
  routing_mode            = "REGIONAL"
}

resource "google_compute_subnetwork" "app" {
  project                  = var.proyecto
  name                     = "${var.prefijo}-app-${substr(var.region, 0, 8)}"
  network                  = google_compute_network.vpc.id
  region                   = var.region
  ip_cidr_range            = var.cidr_app
  private_ip_google_access = true        # salida a APIs de Google sin IP pública
}

# Conector de acceso serverless: exige un /28 exacto
resource "google_vpc_access_connector" "conector" {
  project       = var.proyecto
  name          = "${var.prefijo}-conn"
  region        = var.region
  ip_cidr_range = var.cidr_conector
  network       = google_compute_network.vpc.name
  min_instances = 2
  max_instances = 3                      # tope de coste
}

# --- Acceso privado a servicios (necesario para Cloud SQL con IP privada) ---
resource "google_compute_global_address" "rango_privado" {
  project       = var.proyecto
  name          = "${var.prefijo}-rango-privado"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = 20
  address       = split("/", var.cidr_privado)[0]
  network       = google_compute_network.vpc.id
}

resource "google_service_networking_connection" "peering" {
  network                 = google_compute_network.vpc.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.rango_privado.name]
}

# --- Firewall: denegación por defecto explícita ---
resource "google_compute_firewall" "denegar_entrada" {
  project   = var.proyecto
  name      = "${var.prefijo}-denegar-entrada"
  network   = google_compute_network.vpc.name
  direction = "INGRESS"
  priority  = 65534
  deny { protocol = "all" }
  source_ranges = ["0.0.0.0/0"]
  log_config { metadata = "INCLUDE_ALL_METADATA" }
}

output "red_id"        { value = google_compute_network.vpc.id }
output "red_nombre"    { value = google_compute_network.vpc.name }
output "conector_id"   { value = google_vpc_access_connector.conector.id }
output "peering_listo" { value = google_service_networking_connection.peering.id }

Los dos recursos que la gente olvida están ahí: google_compute_global_address con purpose = "VPC_PEERING" y google_service_networking_connection. Sin ellos, Cloud SQL con IP privada falla con un error que no menciona el peering por ningún lado.

Y el output "peering_listo" no es decorativo: sirve para que el módulo de datos pueda declarar depends_on sobre él y Terraform no intente crear la base de datos antes de que el peering exista.

3.4 Variables por entorno

# infra/envs/prod/terraform.tfvars
proyecto      = "refugio-prod"
entorno       = "prod"
prefijo       = "refugio"
region        = "europe-west1"
cidr_app      = "10.20.0.0/24"
cidr_conector = "10.20.8.0/28"
cidr_privado  = "10.20.16.0/20"

bd_tier            = "db-f1-micro"
bd_alta_disp       = false
bd_backup_retencion = 7

run_min_instancias = 0
run_max_instancias = 5
# infra/envs/dev/terraform.tfvars — mismas claves, valores más pequeños
proyecto      = "refugio-dev"
entorno       = "dev"
prefijo       = "refugio"
region        = "europe-west1"
cidr_app      = "10.10.0.0/24"
cidr_conector = "10.10.8.0/28"
cidr_privado  = "10.10.16.0/20"

bd_tier            = "db-f1-micro"
bd_alta_disp       = false
bd_backup_retencion = 1
run_min_instancias = 0
run_max_instancias = 2

La regla: los dos ficheros tienen exactamente las mismas claves. Si dev tiene una variable que prod no tiene, los entornos han divergido y la promoción deja de ser fiable.

3.5 El primer apply

cd infra/envs/dev
terraform init
terraform fmt -recursive ../..     # formato consistente, gratis
terraform validate                 # sintaxis y referencias
terraform plan -out=plan.tfplan    # LEER LA SALIDA ENTERA
terraform apply plan.tfplan

Lee el plan entero. Siempre. Es la práctica transversal número uno de la sección 11, y la primera vez es cuando más se aprende: el plan te enseña exactamente qué recursos implica cada bloque que has escrito.

Criterio de "hecho" de la fase 1:

Comprobación Comando Esperado
Estado remoto gcloud storage ls gs://refugio-terraform-estado/envs/dev/ default.tfstate
Red creada, no la default gcloud compute networks list --project=${PROY_DEV} refugio-vpc, sin default
Conector activo gcloud compute networks vpc-access connectors list --region=${REGION} --project=${PROY_DEV} estado READY
Peering establecido gcloud services vpc-peerings list --network=refugio-vpc --project=${PROY_DEV} 1 peering
Plan limpio terraform plan No changes.

Esa última fila es la que importa: un plan que dice No changes significa que el código y la realidad coinciden. Es el criterio que se comprueba en 08-04 y que vale 3 puntos de la rúbrica.

  1. Fase 2 — Identidad

Objetivo: cada carga de trabajo con su identidad y sus permisos mínimos, y el CI/CD funcionando sin una sola clave descargada.

Tiempo estimado: 3-5 horas (2 de ellas, la primera vez que configuras WIF).

4.1 Cuentas de servicio y roles mínimos

# infra/modules/identidad/main.tf

resource "google_service_account" "web" {
  project      = var.proyecto
  account_id   = "sa-${var.prefijo}-web"
  display_name = "Cuenta de la aplicación web"
  description  = "Cloud Run: lee BD, lee 2 secretos, escribe fotos, publica eventos"
}

# --- Roles a nivel de PROYECTO: sólo los que no se pueden acotar más ---
resource "google_project_iam_member" "web_proyecto" {
  for_each = toset([
    "roles/cloudsql.client",
    "roles/logging.logWriter",
    "roles/cloudtrace.agent",
    "roles/monitoring.metricWriter",
  ])
  project = var.proyecto
  role    = each.value
  member  = "serviceAccount:${google_service_account.web.email}"
}

# --- Roles acotados AL RECURSO: así es como se hace bien ---
resource "google_secret_manager_secret_iam_member" "web_secretos" {
  for_each  = toset(var.secretos_de_la_web)   # ["refugio-db-password", "refugio-session-key"]
  project   = var.proyecto
  secret_id = each.value
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.web.email}"
}

resource "google_storage_bucket_iam_member" "web_fotos" {
  bucket = var.bucket_fotos
  role   = "roles/storage.objectAdmin"
  member = "serviceAccount:${google_service_account.web.email}"
}

resource "google_pubsub_topic_iam_member" "web_publica" {
  project = var.proyecto
  topic   = var.topic_eventos
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:${google_service_account.web.email}"
}

La diferencia que separa un proyecto bueno de uno mediocre está en los nombres de esos recursos. google_project_iam_member con secretAccessor da acceso a todos los secretos del proyecto, presentes y futuros. google_secret_manager_secret_iam_member lo da a ese secreto. Es la misma cantidad de código y una diferencia enorme de superficie de ataque.

4.2 Workload Identity Federation: CI/CD sin claves

Este es el punto que más te va a diferenciar. La idea, en dos frases: en vez de descargar una clave JSON y guardarla en GitHub, se establece una relación de confianza entre GCP y el proveedor de identidad de tu CI. El CI presenta un token firmado que dice "soy la rama main del repo usuario/refugioreserva", y GCP lo canjea por credenciales temporales.

Cero claves. Cero rotación. Cero riesgo de fuga.

# infra/modules/identidad/wif.tf

resource "google_iam_workload_identity_pool" "github" {
  project                   = var.proyecto
  workload_identity_pool_id = "gh-pool"
  display_name              = "GitHub Actions"
}

resource "google_iam_workload_identity_pool_provider" "github" {
  project                            = var.proyecto
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "gh-provider"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.repository" = "assertion.repository"
    "attribute.ref"        = "assertion.ref"
  }

  # CONDICIÓN OBLIGATORIA: sin esto, CUALQUIER repositorio de GitHub
  # del mundo podría autenticarse contra tu proyecto.
  attribute_condition = "assertion.repository == '${var.github_repo}'"

  oidc { issuer_uri = "https://token.actions.githubusercontent.com" }
}

resource "google_service_account" "deploy" {
  project      = var.proyecto
  account_id   = "sa-${var.prefijo}-deploy"
  display_name = "Despliegue desde CI"
}

# Sólo la rama main del repositorio puede impersonar la cuenta de despliegue
resource "google_service_account_iam_member" "deploy_wif" {
  service_account_id = google_service_account.deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repo}"
}

resource "google_project_iam_member" "deploy_roles" {
  for_each = toset([
    "roles/run.developer",
    "roles/artifactregistry.writer",
  ])
  project = var.proyecto
  role    = each.value
  member  = "serviceAccount:${google_service_account.deploy.email}"
}

# EL PERMISO QUE SE OLVIDA SIEMPRE:
# para desplegar un servicio que corre como sa-web, deploy debe poder "actuar como" ella.
resource "google_service_account_iam_member" "deploy_actua_como_web" {
  service_account_id = google_service_account.web.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:${google_service_account.deploy.email}"
}

⚠️ El attribute_condition no es opcional. Sin él, el proveedor acepta tokens de cualquier repositorio de GitHub del planeta, y cualquiera que sepa el nombre de tu pool puede desplegar en tu proyecto. Es un fallo de seguridad real y documentado que aparece en muchos tutoriales por omisión.

Y en el flujo de GitHub Actions:

# .github/workflows/deploy.yml
permissions:
  contents: read
  id-token: write            # imprescindible para que GitHub emita el token OIDC

jobs:
  desplegar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/gh-pool/providers/gh-provider
          service_account: [email protected]
      # A partir de aquí, gcloud está autenticado. Sin ningún secreto en GitHub.

Criterio de "hecho" de la fase 2:

# 1. Ninguna cuenta de servicio con rol primitivo
gcloud projects get-iam-policy "${PROY_PROD}" --format=json | \
  jq -r '.bindings[] | select(.role | test("roles/(owner|editor)")) |
         .members[] | select(startswith("serviceAccount:"))'
# Esperado: vacío

# 2. Cero claves de cuenta de servicio gestionadas por el usuario
for SA in $(gcloud iam service-accounts list --project="${PROY_PROD}" --format="value(email)"); do
  echo -n "$SA: "
  gcloud iam service-accounts keys list --iam-account="$SA" \
    --managed-by=user --format="value(name)" | wc -l
done
# Esperado: 0 en todas

Guarda la salida de esos dos comandos: son evidencia directa de 6 puntos del bloque D de la rúbrica.

  1. Fase 3 — Datos

Objetivo: base de datos gestionada, privada, con su contraseña en Secret Manager, y el esquema como código versionado.

Tiempo estimado: 3-5 horas.

5.1 La base de datos, sin IP pública

# infra/modules/datos/sql.tf

resource "random_password" "bd" {
  length  = 32
  special = true
}

resource "google_secret_manager_secret" "bd_password" {
  project   = var.proyecto
  secret_id = "${var.prefijo}-db-password"
  replication { auto {} }
}

resource "google_secret_manager_secret_version" "bd_password" {
  secret      = google_secret_manager_secret.bd_password.id
  secret_data = random_password.bd.result
}

resource "google_sql_database_instance" "principal" {
  project          = var.proyecto
  name             = "${var.prefijo}-db"
  region           = var.region
  database_version = "POSTGRES_16"

  # El peering debe existir antes. Caso legítimo de depends_on explícito.
  depends_on = [var.peering_listo]

  settings {
    tier              = var.bd_tier
    availability_type = var.bd_alta_disp ? "REGIONAL" : "ZONAL"
    disk_size         = 10
    disk_type         = "PD_HDD"      # más barato; suficiente a este volumen
    disk_autoresize   = true

    ip_configuration {
      ipv4_enabled    = false          # ← SIN IP PÚBLICA
      private_network = var.red_id
      ssl_mode        = "ENCRYPTED_ONLY"
    }

    backup_configuration {
      enabled                        = true
      start_time                     = "03:00"
      point_in_time_recovery_enabled = var.entorno == "prod"
      backup_retention_settings { retained_backups = var.bd_backup_retencion }
    }

    maintenance_window { day = 7, hour = 4 }   # domingo de madrugada

    database_flags {
      name  = "cloudsql.iam_authentication"
      value = "on"
    }

    user_labels = var.etiquetas
  }

  # Protección contra el destroy accidental en producción
  deletion_protection = var.entorno == "prod"
}

resource "google_sql_database" "app" {
  project  = var.proyecto
  instance = google_sql_database_instance.principal.name
  name     = "reservas"
}

resource "google_sql_user" "app" {
  project  = var.proyecto
  instance = google_sql_database_instance.principal.name
  name     = "app"
  password = random_password.bd.result
}

Cinco detalles que cuentan:

  • ipv4_enabled = false es la línea que vale 2 puntos del bloque D y, más importante, la que hace que tu base de datos no sea escaneable desde internet.
  • La contraseña se genera con random_password y nunca la ves. Va directa a Secret Manager. Nadie la teclea, nadie la copia, nadie la sube por error.
  • depends_on sobre el peering es uno de los pocos casos donde depends_on explícito es correcto: la dependencia es real pero Terraform no puede inferirla del grafo.
  • deletion_protection condicionada al entorno: dev se destruye los viernes, prod no se destruye por accidente.
  • disk_type = "PD_HDD": para un proyecto de portafolio, el SSD no aporta nada y cuesta más.

5.2 Buckets con ciclo de vida

resource "random_id" "sufijo" { byte_length = 2 }

resource "google_storage_bucket" "fotos" {
  project  = var.proyecto
  name     = "${var.prefijo}-fotos-${random_id.sufijo.hex}"   # nombre global
  location = var.region

  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = true }

  lifecycle_rule {
    condition { age = 90, matches_storage_class = ["STANDARD"] }
    action    { type = "SetStorageClass", storage_class = "NEARLINE" }
  }
  lifecycle_rule {
    condition { age = 365, matches_storage_class = ["NEARLINE"] }
    action    { type = "SetStorageClass", storage_class = "COLDLINE" }
  }
  lifecycle_rule {
    condition { num_newer_versions = 3 }     # no acumular versiones antiguas
    action    { type = "Delete" }
  }

  cors {
    origin          = ["https://${var.dominio}"]
    method          = ["GET", "HEAD"]
    response_header = ["Content-Type"]
    max_age_seconds = 3600
  }

  labels = merge(var.etiquetas, { componente = "web" })
}

public_access_prevention = "enforced" impide, a nivel de bucket, que nadie pueda hacerlo público ni por error ni a propósito. Es una línea y elimina de raíz el incidente más común de la nube.

5.3 Migraciones de esquema como código

El esquema no se crea a mano en una consola SQL. Se versiona:

data/migraciones/
├── 001_esquema_inicial.sql
├── 002_indice_disponibilidad.sql
└── 003_columnas_sentimiento.sql
-- data/migraciones/001_esquema_inicial.sql
-- Idempotente: se puede ejecutar dos veces sin romper nada.
BEGIN;

CREATE TABLE IF NOT EXISTS refugio (
    id        SERIAL PRIMARY KEY,
    nombre    TEXT NOT NULL UNIQUE,
    altitud_m INTEGER NOT NULL CHECK (altitud_m BETWEEN 500 AND 3500),
    capacidad INTEGER NOT NULL CHECK (capacidad > 0),
    activo    BOOLEAN NOT NULL DEFAULT TRUE,
    creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS reserva (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    refugio_id     INTEGER NOT NULL REFERENCES refugio(id),
    fecha          DATE NOT NULL,
    plazas         INTEGER NOT NULL CHECK (plazas BETWEEN 1 AND 12),
    nombre_titular TEXT NOT NULL,
    email_titular  TEXT NOT NULL,
    estado         TEXT NOT NULL DEFAULT 'confirmada'
                   CHECK (estado IN ('confirmada','cancelada')),
    creada_en      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Registro de migraciones aplicadas
CREATE TABLE IF NOT EXISTS _migraciones (
    version    TEXT PRIMARY KEY,
    aplicada_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO _migraciones (version) VALUES ('001')
  ON CONFLICT (version) DO NOTHING;

COMMIT;

Para ejecutarlas contra una BD sin IP pública, usa el proxy de autenticación:

# Descarga cloud-sql-proxy si no lo tienes
./cloud-sql-proxy --port 5432 "${PROY_DEV}:${REGION}:refugio-db" &

PGPASSWORD=$(gcloud secrets versions access latest --secret=refugio-db-password --project="${PROY_DEV}")
export PGPASSWORD

for f in data/migraciones/*.sql; do
  echo "→ $f"
  psql -h 127.0.0.1 -U app -d reservas -v ON_ERROR_STOP=1 -f "$f"
done

unset PGPASSWORD

Fíjate en unset PGPASSWORD y en que la contraseña se lee de Secret Manager en el momento: nunca queda escrita en un fichero ni en el historial del shell si usas HISTCONTROL=ignorespace y un espacio delante.

5.4 Sembrar datos ficticios

# data/seed/generar.py — genera datos 100% inventados
import random, uuid
from datetime import date, timedelta

random.seed(42)  # reproducible: mismos datos en cada ejecución

REFUGIOS = [
    ("Refugio de Cotiella", 2100, 40), ("Refugio Peña Blanca", 1850, 28),
    ("Refugio del Ibón Verde", 2340, 22), ("Refugio de Valdellosa", 1620, 55),
]
NOMBRES = ["Ana", "Luis", "Marta", "Jorge", "Carmen", "Diego", "Elena", "Pablo"]
APELLIDOS = ["Soler", "Ibáñez", "Marín", "Vidal", "Rey", "Castaño", "Lorca"]

def titular_ficticio(i):
    nombre = f"{random.choice(NOMBRES)} {random.choice(APELLIDOS)}"
    # example.com está reservado por el RFC 2606 justo para esto
    correo = f"usuario{i:04d}@example.com"
    return nombre, correo

def factor_estacional(d: date) -> float:
    """Julio-agosto x4, fines de semana x2,5, resto normal."""
    f = 4.0 if d.month in (7, 8) else (2.0 if d.month in (6, 9) else 1.0)
    if d.weekday() >= 5:
        f *= 2.5
    return f

def generar_reservas(n=2000):
    inicio = date.today() - timedelta(days=540)
    filas = []
    i = 0
    while len(filas) < n:
        d = inicio + timedelta(days=random.randint(0, 540))
        if random.random() > factor_estacional(d) / 10:
            continue
        i += 1
        nombre, correo = titular_ficticio(i)
        filas.append((
            str(uuid.uuid4()), random.randint(1, len(REFUGIOS)), d.isoformat(),
            random.randint(1, 6), nombre, correo,
            "cancelada" if random.random() < 0.08 else "confirmada",
        ))
    return filas

random.seed(42) hace el generador reproducible: si destruyes y recreas el entorno, obtienes exactamente los mismos datos. Eso hace que las capturas del cuadro de mando sigan siendo válidas y que las pruebas de 08-04 sean deterministas.

Criterio de "hecho" de la fase 3:

Comprobación Comando Esperado
BD sin IP pública gcloud sql instances describe refugio-db --format="value(settings.ipConfiguration.ipv4Enabled)" False
Secreto creado con versión gcloud secrets versions list refugio-db-password ≥1 versión ENABLED
Bucket no público gcloud storage buckets describe gs://... --format="value(iamConfiguration.publicAccessPrevention)" enforced
Migraciones aplicadas psql -c "SELECT version FROM _migraciones ORDER BY version" Todas
Datos sembrados psql -c "SELECT count(*) FROM reserva" ~2.000

  1. Fase 4 — La aplicación

Objetivo: un contenedor que cumple el contrato de Cloud Run, configurado por entorno, con secretos inyectados, sondas de salud y logs correlacionables. Y desplegado a mano una vez, como prueba de vida.

Tiempo estimado: 8-12 horas.

6.1 El contrato de Cloud Run

Cuatro reglas. Incumplir cualquiera hace que el despliegue falle con un error poco informativo:

Regla Qué significa Error si la incumples
Escucha en $PORT La variable de entorno PORT, no un puerto fijo El contenedor no pasa el chequeo de arranque
Escucha en 0.0.0.0 No en 127.0.0.1 Igual: parece arrancado pero no responde
Arranca en <4 min Migraciones o cargas lentas al inicio, no Timeout de despliegue
Sin estado en disco El sistema de ficheros es efímero y por instancia Datos que desaparecen sin explicación
# app/Dockerfile — multietapa, sin root, y con lo mínimo dentro
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

FROM python:3.12-slim
RUN useradd --create-home --uid 1001 app
WORKDIR /app
COPY --from=build /root/.local /home/app/.local
COPY --chown=app:app src/ ./src/
USER app
ENV PATH=/home/app/.local/bin:$PATH \
    PYTHONUNBUFFERED=1

# $PORT lo inyecta Cloud Run. El 8080 es sólo el valor por defecto local.
ENV PORT=8080
CMD exec uvicorn src.main:app --host 0.0.0.0 --port ${PORT}

USER app vale 1 punto de seguridad y cuesta dos líneas. PYTHONUNBUFFERED=1 hace que los logs salgan inmediatamente en vez de quedarse en el búfer; sin él, los logs de un contenedor que se cae se pierden.

6.2 Configuración y secretos

Configuración no sensible → variables de entorno del servicio. Secretos → Secret Manager, montados por referencia:

resource "google_cloud_run_v2_service" "web" {
  project  = var.proyecto
  name     = "${var.prefijo}-web"
  location = var.region
  ingress  = "INGRESS_TRAFFIC_ALL"

  template {
    service_account = var.sa_web_email

    scaling {
      min_instance_count = var.run_min_instancias   # 0: escala a cero
      max_instance_count = var.run_max_instancias   # tope de gasto
    }

    vpc_access {
      connector = var.conector_id
      egress    = "PRIVATE_RANGES_ONLY"   # sólo la BD va por la VPC
    }

    containers {
      image = var.imagen

      resources {
        limits = { cpu = "1", memory = "512Mi" }
        cpu_idle = true                    # no pagar CPU entre peticiones
      }

      # --- Configuración: en claro, no sensible ---
      env { name = "ENTORNO"   value = var.entorno }
      env { name = "REGION"    value = var.region }
      env { name = "BD_HOST"   value = var.bd_ip_privada }
      env { name = "BD_NOMBRE" value = "reservas" }
      env { name = "TOPIC_EVENTOS" value = var.topic_eventos }
      env { name = "BUCKET_FOTOS"  value = var.bucket_fotos }

      # --- Secretos: por referencia, nunca por valor ---
      env {
        name = "BD_PASSWORD"
        value_source {
          secret_key_ref {
            secret  = var.secreto_bd_password
            version = "latest"
          }
        }
      }

      startup_probe {
        http_get { path = "/salud/arranque" }
        initial_delay_seconds = 3
        period_seconds        = 3
        failure_threshold     = 10
      }
      liveness_probe {
        http_get { path = "/salud/vivo" }
        period_seconds = 30
      }
    }
  }

  traffic {
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
    percent = 100
  }
}

cpu_idle = true es la opción que hace que Cloud Run no te cobre CPU entre peticiones. En un proyecto de portafolio con tráfico esporádico, es la diferencia entre céntimos y euros.

6.3 Las dos sondas y por qué son distintas

# app/src/main.py (fragmento)
from fastapi import FastAPI, Response
from . import db

app = FastAPI()

@app.get("/salud/arranque")
async def arranque(response: Response):
    """Sonda de arranque: ¿estoy listo para recibir tráfico?
    Comprueba dependencias críticas. Si falla, Cloud Run no envía tráfico."""
    try:
        await db.ping()
        return {"estado": "listo"}
    except Exception as e:
        response.status_code = 503
        return {"estado": "no listo", "motivo": str(e)[:200]}

@app.get("/salud/vivo")
async def vivo():
    """Sonda de vida: ¿el proceso responde?
    NO comprueba dependencias: si la BD se cae, no queremos que
    Cloud Run reinicie la instancia en bucle — no arreglaría nada."""
    return {"estado": "vivo"}

La distinción es importante y se pregunta en entrevistas: arranque comprueba dependencias, vida no. Si la sonda de vida comprobara la base de datos, una caída de la BD provocaría reinicios continuos de todas las instancias, convirtiendo un problema en una tormenta.

6.4 Logs estructurados con trace_id

De 06-06, en su forma mínima y efectiva:

# app/src/observabilidad.py
import json, os, sys, contextvars

_trace = contextvars.ContextVar("trace", default=None)
PROYECTO = os.environ.get("GOOGLE_CLOUD_PROJECT", "")

def fijar_trace(cabecera: str | None):
    """Cloud Run envía X-Cloud-Trace-Context: TRACE_ID/SPAN_ID;o=1"""
    if cabecera:
        _trace.set(cabecera.split("/")[0])

def log(severidad: str, mensaje: str, **campos):
    entrada = {
        "severity": severidad,           # nombres que Cloud Logging entiende
        "message": mensaje,
        **campos,
    }
    t = _trace.get()
    if t and PROYECTO:
        # Esta clave exacta es la que enlaza el log con la traza en la consola
        entrada["logging.googleapis.com/trace"] = f"projects/{PROYECTO}/traces/{t}"
    print(json.dumps(entrada, ensure_ascii=False), file=sys.stdout, flush=True)
# En el middleware de la aplicación
@app.middleware("http")
async def correlacion(request, call_next):
    fijar_trace(request.headers.get("X-Cloud-Trace-Context"))
    respuesta = await call_next(request)
    log("INFO", "peticion",
        ruta=request.url.path, metodo=request.method,
        codigo=respuesta.status_code)
    return respuesta

La clave logging.googleapis.com/trace con ese nombre exacto es lo que hace que en la consola puedas pulsar sobre una traza lenta y ver todos los logs de esa petición concreta. Es una línea de código y transforma la depuración.

⚠️ Nunca registres datos personales. Ni correos, ni nombres, ni contenido de formularios, ni tokens. Registra identificadores (reserva_id), no personas. Los logs se replican, se exportan y se conservan; un dato personal en un log es un dato personal que has perdido de vista.

6.5 El primer despliegue, a mano

Se hace manualmente y una sola vez. El motivo es pedagógico y práctico: entender cada paso antes de automatizarlo, y tener una prueba de vida contra la que comparar cuando el pipeline falle.

export IMAGEN="${REGION}-docker.pkg.dev/${PROY_DEV}/refugio-imagenes/web:manual-1"

gcloud builds submit app/ --tag="${IMAGEN}" --project="${PROY_DEV}"

gcloud run deploy refugio-web \
  --image="${IMAGEN}" \
  --region="${REGION}" \
  --project="${PROY_DEV}" \
  --service-account="sa-refugio-web@${PROY_DEV}.iam.gserviceaccount.com" \
  --vpc-connector="refugio-conn" \
  --vpc-egress=private-ranges-only \
  --set-env-vars="ENTORNO=dev,BD_HOST=10.10.16.3,BD_NOMBRE=reservas" \
  --set-secrets="BD_PASSWORD=refugio-db-password:latest" \
  --min-instances=0 --max-instances=2 \
  --no-allow-unauthenticated

URL=$(gcloud run services describe refugio-web --region="${REGION}" \
      --project="${PROY_DEV}" --format="value(status.url)")
curl -s -H "Authorization: Bearer $(gcloud auth print-identity-token)" "${URL}/salud/arranque"

Después de que funcione, bórralo del historial mental y hazlo desde Terraform. El despliegue manual era la prueba de vida; el estado permanente lo gobierna el código.

Criterio de "hecho" de la fase 4:

Comprobación Comando Esperado
Servicio desplegado gcloud run services describe refugio-web --region=$REGION --format="value(status.conditions[0].status)" True
Sonda de arranque OK curl .../salud/arranque {"estado":"listo"}
Lee de la BD curl .../api/refugios Lista con datos
Logs estructurados gcloud logging read 'resource.type="cloud_run_revision"' --limit=1 --format=json JSON con jsonPayload y trace
No corre como root docker run --rm $IMAGEN id -u 1001

  1. Fase 5 — La entrega automatizada

Objetivo: que un git push a main pruebe, construya, publique y despliegue a desarrollo sin que toques nada; y que la promoción a producción sea la misma imagen con una aprobación.

Tiempo estimado: 4-6 horas.

7.1 El pipeline de desarrollo

# cloudbuild.yaml
substitutions:
  _REGION: europe-west1
  _SERVICIO: refugio-web
  _REPO: refugio-imagenes

steps:
  # 1. Pruebas ANTES de construir. Si fallan, no hay imagen.
  - id: pruebas
    name: python:3.12-slim
    entrypoint: bash
    args:
      - -c
      - |
        pip install --no-cache-dir -r app/requirements.txt -r app/requirements-dev.txt
        cd app && python -m pytest tests/ -v --tb=short

  # 2. Construir con caché de la imagen anterior
  - id: construir
    name: gcr.io/cloud-builders/docker
    args:
      - build
      - --cache-from=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:latest
      - -t=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:$SHORT_SHA
      - -t=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:latest
      - app/
    waitFor: [pruebas]

  # 3. Publicar
  - id: publicar
    name: gcr.io/cloud-builders/docker
    args: [push, --all-tags, "${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web"]

  # 4. Desplegar a DESARROLLO
  - id: desplegar
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: gcloud
    args:
      - run
      - deploy
      - ${_SERVICIO}
      - --image=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:$SHORT_SHA
      - --region=${_REGION}
      - --revision-suffix=$SHORT_SHA

  # 5. Humo: si la revisión recién desplegada no responde, el build falla
  - id: humo
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: bash
    args:
      - -c
      - |
        URL=$(gcloud run services describe ${_SERVICIO} --region=${_REGION} --format='value(status.url)')
        TOKEN=$(gcloud auth print-identity-token)
        CODIGO=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $$TOKEN" "$$URL/salud/arranque")
        echo "Código: $$CODIGO"
        test "$$CODIGO" = "200"

options:
  logging: CLOUD_LOGGING_ONLY
  machineType: E2_HIGHCPU_8
timeout: 900s

El orden es la parte importante: pruebas → construir → publicar → desplegar → humo. Las pruebas van antes de construir, porque construir una imagen de código que no pasa las pruebas es tiempo y dinero tirados. Y la prueba de humo va después de desplegar, porque es lo único que distingue "el despliegue terminó" de "el despliegue funcionó".

7.2 La promoción a producción

# cloudbuild-prod.yaml — NO construye. Promociona la imagen ya probada.
substitutions:
  _IMAGEN_SHA: ""     # se pasa explícitamente: la que ya funciona en dev

steps:
  - id: verificar-imagen-existe
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: bash
    args:
      - -c
      - |
        test -n "${_IMAGEN_SHA}" || { echo "Falta _IMAGEN_SHA"; exit 1; }
        gcloud artifacts docker images describe \
          europe-west1-docker.pkg.dev/refugio-dev/refugio-imagenes/web:${_IMAGEN_SHA}

  # Despliegue CANARIO: 10% del tráfico a la revisión nueva
  - id: canario
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: gcloud
    args:
      - run
      - deploy
      - refugio-web
      - --image=europe-west1-docker.pkg.dev/refugio-dev/refugio-imagenes/web:${_IMAGEN_SHA}
      - --region=europe-west1
      - --project=refugio-prod
      - --no-traffic                       # se despliega sin recibir tráfico
      - --revision-suffix=${_IMAGEN_SHA}

  - id: repartir-10
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: gcloud
    args:
      - run
      - services
      - update-traffic
      - refugio-web
      - --region=europe-west1
      - --project=refugio-prod
      - --to-revisions=refugio-web-${_IMAGEN_SHA}=10

El principio que hay que interiorizar: la misma imagen, sin reconstruir. Reconstruir para producción significa que estás desplegando algo que nunca has probado: el pip install puede resolver una versión distinta, la imagen base puede haber cambiado. La imagen que pasó las pruebas en dev es la que va a producción, byte a byte.

La aprobación manual se configura en el activador de Cloud Build (--require-approval) o, en GitHub Actions, con un environment protegido.

Criterio de "hecho" de la fase 5:

Comprobación Cómo Esperado
Push despliega solo git push y mirar gcloud builds list --limit=1 SUCCESS en <10 min
Una prueba rota impide el despliegue Rompe una prueba a propósito, haz push Build en FAILURE, revisión de Cloud Run sin cambiar
Sin claves en CI Revisar secretos del repositorio Ninguna credencial de GCP
Promoción sin reconstruir Comparar digest en dev y prod Idéntico

La segunda fila es la prueba de verdad. Un pipeline que nunca ha fallado no está probado: rompe una prueba a propósito, comprueba que el despliegue se detiene, y guarda la captura. Vale 3 puntos y demuestra que la red de seguridad existe.

  1. Fase 6 — La capa de datos y de IA

Objetivo: que una acción en la aplicación acabe visible en el cuadro de mando, y que exista un componente de IA que funcione.

Tiempo estimado: 6-9 horas.

8.1 Ingesta: la aplicación publica eventos

# app/src/eventos.py
import json, os
from google.cloud import pubsub_v1

_publisher = pubsub_v1.PublisherClient()
_TOPIC = _publisher.topic_path(os.environ["GOOGLE_CLOUD_PROJECT"],
                               os.environ["TOPIC_EVENTOS"])

def publicar_reserva(evento: dict) -> None:
    """Publica el evento de negocio. NUNCA incluye datos de contacto."""
    carga = {
        "evento_id":      evento["id"],
        "tipo_evento":    evento["tipo"],       # creada | cancelada
        "reserva_id":     evento["reserva_id"],
        "refugio_id":     evento["refugio_id"],
        "refugio_nombre": evento["refugio_nombre"],
        "fecha_estancia": evento["fecha"],
        "plazas":         evento["plazas"],
        "ocurrido_en":    evento["ts"],
    }
    futuro = _publisher.publish(_TOPIC, json.dumps(carga).encode("utf-8"))
    futuro.result(timeout=10)

El comentario no es decorativo: el evento no lleva nombre_titular ni email_titular. Es la minimización de datos de 08-02 aplicada en el código, y es la diferencia entre tener datos personales en un sitio o en cuatro.

8.2 Transformación: la función que escribe en BigQuery

# ml/../funcion/main.py
import base64, json, os
from google.cloud import bigquery

_bq = bigquery.Client()
_TABLA = os.environ["TABLA_EVENTOS"]

def procesar(evento, contexto):
    """Suscriptor de Pub/Sub. Inserta el evento en BigQuery."""
    datos = json.loads(base64.b64decode(evento["data"]).decode("utf-8"))

    errores = _bq.insert_rows_json(_TABLA, [datos], row_ids=[datos["evento_id"]])
    if errores:
        # Lanzar excepción hace que Pub/Sub reintente; tras N intentos,
        # el mensaje va a la cola de mensajes fallidos.
        raise RuntimeError(f"Error insertando en BigQuery: {errores}")

row_ids con el identificador del evento activa la deduplicación de BigQuery: si Pub/Sub entrega el mismo mensaje dos veces —y lo hará, porque su garantía es "al menos una vez"— la fila no se duplica. Es una línea que evita un cuadro de mando con cifras infladas.

Y configura la cola de mensajes fallidos en Terraform, con su tema y su suscripción, o los mensajes que fallan reintentan eternamente.

8.3 Las tablas analíticas

-- data/sql/analitica.sql
CREATE TABLE IF NOT EXISTS `refugio-datos.refugio_analitica.reservas_eventos` (
  evento_id      STRING NOT NULL,
  ocurrido_en    TIMESTAMP NOT NULL,
  tipo_evento    STRING NOT NULL,
  reserva_id     STRING NOT NULL,
  refugio_id     INT64 NOT NULL,
  refugio_nombre STRING,
  fecha_estancia DATE NOT NULL,
  plazas         INT64 NOT NULL
)
PARTITION BY DATE(ocurrido_en)
CLUSTER BY refugio_id
OPTIONS (partition_expiration_days = 1095, require_partition_filter = TRUE);

-- Vista agregada: es lo que consume el cuadro de mando.
-- Que el panel consulte una vista y no la tabla base reduce el escaneo.
CREATE OR REPLACE VIEW `refugio-datos.refugio_analitica.v_ocupacion_diaria` AS
SELECT
  fecha_estancia,
  refugio_id,
  ANY_VALUE(refugio_nombre) AS refugio,
  SUM(IF(tipo_evento = 'creada',    plazas, 0)) AS plazas_reservadas,
  SUM(IF(tipo_evento = 'cancelada', plazas, 0)) AS plazas_canceladas,
  SUM(IF(tipo_evento = 'creada', plazas, -plazas)) AS plazas_netas
FROM `refugio-datos.refugio_analitica.reservas_eventos`
WHERE DATE(ocurrido_en) >= DATE_SUB(CURRENT_DATE(), INTERVAL 730 DAY)
GROUP BY fecha_estancia, refugio_id;

El WHERE DATE(ocurrido_en) >= ... de la vista no es opcional: con require_partition_filter = TRUE, una vista sin filtro de partición fallaría.

8.4 El componente de IA

La recomendación, repetida porque importa: empieza por una API preentrenada.

# ml/analizar_opiniones.py
from google.cloud import language_v2

_cliente = language_v2.LanguageServiceClient()

def analizar(texto: str) -> dict:
    """Sentimiento de una opinión. Texto FICTICIO, sin datos personales."""
    documento = language_v2.Document(
        content=texto,
        type_=language_v2.Document.Type.PLAIN_TEXT,
        language_code="es",
    )
    r = _cliente.analyze_sentiment(request={"document": documento})
    return {
        "sentimiento": round(r.document_sentiment.score, 3),   # -1..1
        "magnitud":    round(r.document_sentiment.magnitude, 3),
    }

Y la decisión de arquitectura que ahorra dinero: el análisis se hace una vez, al crear la opinión, y el resultado se guarda en la base de datos. No se llama a la API cada vez que alguien abre el cuadro de mando. Es el mismo razonamiento que llevó a AlpinaShop a las recomendaciones por lotes en DA-002: si el resultado no cambia, no se recalcula.

Criterio de "hecho" de la fase 6:

# Prueba de extremo a extremo: crear una reserva y verla en BigQuery
curl -s -X POST "${URL}/api/reservas" -H 'Content-Type: application/json' \
  -d '{"refugio_id":1,"fecha":"2026-12-20","plazas":2,
       "nombre_titular":"Prueba Ficticia","email_titular":"[email protected]"}'

sleep 30

bq query --use_legacy_sql=false --project_id="${PROY_DATOS}" \
'SELECT evento_id, tipo_evento, refugio_id, plazas
 FROM `refugio-datos.refugio_analitica.reservas_eventos`
 WHERE DATE(ocurrido_en) = CURRENT_DATE()
 ORDER BY ocurrido_en DESC LIMIT 5'

Si esa fila aparece, has cerrado el hito M3 de 08-01: el flujo completo funciona de extremo a extremo. Haz una captura: la vas a necesitar en la presentación.

  1. Fase 7 — Exposición

Objetivo: que el sistema responda en un dominio propio, con HTTPS válido, y con la protección que hayas decidido en el diseño.

Tiempo estimado: 3-5 horas, más el tiempo de propagación de DNS y de emisión del certificado (de 15 minutos a varias horas: no lo dejes para el último día).

9.1 Las dos rutas posibles

Dominio personalizado de Cloud Run Balanceador global HTTPS
Coste 0 € ~18 €/mes por la regla de reenvío
TLS Gestionado y automático Gestionado y automático
Cloud CDN ❌ ✅
Cloud Armor (WAF) ❌ ✅
Varios backends (Run + bucket) ❌ ✅
Complejidad 2 recursos 7-8 recursos

Si tu límite de coste es ajustado, la primera opción cumple RNF-5 y no cuesta nada. Documenta el porqué en un ADR, como hizo RefugioReserva.

9.2 Ruta económica: dominio personalizado

resource "google_cloud_run_domain_mapping" "web" {
  project  = var.proyecto
  location = var.region
  name     = var.dominio                 # "refugioreserva.example"
  metadata { namespace = var.proyecto }
  spec     { route_name = google_cloud_run_v2_service.web.name }
}

output "registros_dns" {
  description = "Registros a crear en el registrador"
  value       = google_cloud_run_domain_mapping.web.status[0].resource_records
}

Creas los registros que devuelve ese output en tu registrador (o en Cloud DNS) y Google emite el certificado solo.

9.3 Ruta completa: balanceador, CDN y WAF

Aunque acabes destruyéndolo por coste, escribe el módulo y despliégalo al menos una vez: el conocimiento queda, tienes capturas y puedes contarlo en la presentación.

resource "google_compute_region_network_endpoint_group" "neg" {
  project               = var.proyecto
  name                  = "${var.prefijo}-neg"
  region                = var.region
  network_endpoint_type = "SERVERLESS"
  cloud_run { service = google_cloud_run_v2_service.web.name }
}

resource "google_compute_backend_service" "bs" {
  project                 = var.proyecto
  name                    = "${var.prefijo}-bs"
  protocol                = "HTTPS"
  load_balancing_scheme   = "EXTERNAL_MANAGED"
  enable_cdn              = true
  security_policy         = google_compute_security_policy.waf.id

  backend { group = google_compute_region_network_endpoint_group.neg.id }

  cdn_policy {
    cache_mode        = "CACHE_ALL_STATIC"
    default_ttl       = 3600
    client_ttl        = 3600
    negative_caching  = true
  }

  log_config { enable = true, sample_rate = 1.0 }
}

resource "google_compute_security_policy" "waf" {
  project = var.proyecto
  name    = "${var.prefijo}-waf"

  # Limitación de tasa: protege el bolsillo tanto como la aplicación
  rule {
    action   = "throttle"
    priority = 1000
    match {
      versioned_expr = "SRC_IPS_V1"
      config { src_ip_ranges = ["*"] }
    }
    rate_limit_options {
      conform_action = "allow"
      exceed_action  = "deny(429)"
      enforce_on_key = "IP"
      rate_limit_threshold { count = 100, interval_sec = 60 }
    }
  }

  rule {
    action   = "allow"
    priority = 2147483647
    match {
      versioned_expr = "SRC_IPS_V1"
      config { src_ip_ranges = ["*"] }
    }
    description = "Regla por defecto"
  }
}

La regla de limitación de tasa merece una nota: en un proyecto con presupuesto de 12 €, un bot que haga 100.000 peticiones puede costarte el presupuesto del mes. El límite de 100 peticiones por minuto y por IP protege tanto la aplicación como la factura. Si no usas balanceador, el equivalente es --max-instances, que es un tope duro de gasto.

Criterio de "hecho" de la fase 7:

curl -sI "https://${DOMINIO}" | head -1                       # HTTP/2 200
curl -sI "https://${DOMINIO}" | grep -i strict-transport       # HSTS presente
echo | openssl s_client -connect "${DOMINIO}:443" -servername "${DOMINIO}" 2>/dev/null \
  | openssl x509 -noout -dates -issuer                         # certificado válido
curl -sI "http://${DOMINIO}" | head -1                         # 301 a HTTPS

  1. Fase 8 — Observabilidad

Objetivo: enterarte de que algo va mal antes de que te lo diga alguien.

Tiempo estimado: 4-6 horas.

10.1 El panel de las cuatro señales

resource "google_monitoring_dashboard" "principal" {
  project = var.proyecto
  dashboard_json = jsonencode({
    displayName = "RefugioReserva — visión general"
    gridLayout  = { columns = 2, widgets = [
      {
        title = "Tráfico (peticiones/s)"
        xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
          filter = "metric.type=\"run.googleapis.com/request_count\" resource.type=\"cloud_run_revision\""
          aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_RATE" }
        }}}]}
      },
      {
        title = "Errores (5xx/s)"
        xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
          filter = "metric.type=\"run.googleapis.com/request_count\" metric.label.response_code_class=\"5xx\""
          aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_RATE" }
        }}}]}
      },
      {
        title = "Latencia p95 (ms)"
        xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
          filter = "metric.type=\"run.googleapis.com/request_latencies\""
          aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_DELTA",
                          crossSeriesReducer = "REDUCE_PERCENTILE_95" }
        }}}]}
      },
      {
        title = "Instancias activas (saturación)"
        xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
          filter = "metric.type=\"run.googleapis.com/container/instance_count\""
          aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_MEAN" }
        }}}]}
      }
    ]}
  })
}

10.2 La alerta que sí notifica

resource "google_monitoring_notification_channel" "correo" {
  project      = var.proyecto
  display_name = "Correo del responsable"
  type         = "email"
  labels       = { email_address = var.correo_alertas }
}

resource "google_monitoring_alert_policy" "errores_5xx" {
  project      = var.proyecto
  display_name = "Tasa de errores 5xx > 5%"
  combiner     = "OR"

  conditions {
    display_name = "5xx elevados durante 5 minutos"
    condition_threshold {
      filter = join(" ", [
        "metric.type=\"run.googleapis.com/request_count\"",
        "resource.type=\"cloud_run_revision\"",
        "metric.label.response_code_class=\"5xx\"",
      ])
      comparison      = "COMPARISON_GT"
      threshold_value = 0.05
      duration        = "300s"
      aggregations {
        alignment_period   = "60s"
        per_series_aligner = "ALIGN_RATE"
      }
    }
  }

  notification_channels = [google_monitoring_notification_channel.correo.id]

  documentation {
    content = <<-EOT
      ## Errores 5xx elevados

      **Primeros pasos** (ver `docs/runbook.md`):
      1. `gcloud logging read 'severity>=ERROR' --limit=20 --freshness=15m`
      2. ¿Coincide con un despliegue? `gcloud run revisions list --limit=5`
      3. Si coincide: revertir con
         `gcloud run services update-traffic refugio-web --to-revisions=<anterior>=100`
      4. ¿Base de datos accesible? `gcloud sql instances describe refugio-db`
    EOT
    mime_type = "text/markdown"
  }
}

El bloque documentation es lo que convierte una alerta en algo útil. Una alerta que sólo dice "algo va mal" te despierta; una que dice qué mirar y cómo revertir te permite arreglarlo. Escribe siempre los primeros pasos ahí.

10.3 El SLO como recurso

resource "google_monitoring_slo" "disponibilidad" {
  project = var.proyecto
  service = google_monitoring_service.web.service_id
  slo_id  = "disponibilidad-api"
  display_name = "99,5% de peticiones sin error 5xx (30 días)"

  goal                = 0.995
  rolling_period_days = 30

  request_based_sli {
    good_total_ratio {
      total_service_filter = "metric.type=\"run.googleapis.com/request_count\" resource.type=\"cloud_run_revision\""
      bad_service_filter   = "metric.type=\"run.googleapis.com/request_count\" resource.type=\"cloud_run_revision\" metric.label.response_code_class=\"5xx\""
    }
  }
}

10.4 El uptime check

resource "google_monitoring_uptime_check_config" "web" {
  project      = var.proyecto
  display_name = "RefugioReserva disponible"
  timeout      = "10s"
  period       = "300s"

  http_check {
    path         = "/salud/arranque"
    port         = 443
    use_ssl      = true
    validate_ssl = true
  }

  monitored_resource {
    type   = "uptime_url"
    labels = { host = var.dominio, project_id = var.proyecto }
  }

  selected_regions = ["EUROPE", "USA"]
}

Criterio de "hecho" de la fase 8 — y aquí hay uno que no se cumple mirando, sino provocando:

Comprobación Cómo Esperado
Panel existe gcloud monitoring dashboards list 1 panel
Alerta existe gcloud alpha monitoring policies list ≥1 política
La alerta notifica de verdad Provócala (ver abajo) Correo recibido
SLO calculando Consola → SLO Presupuesto de error con valor
Uptime check gcloud monitoring uptime list-configs 1, en estado correcto
# Provocar la alerta a propósito: despliega una revisión que devuelva 500
# en un endpoint de prueba, genera tráfico, y espera el correo.
for i in $(seq 1 200); do curl -s -o /dev/null "${URL}/api/error-de-prueba"; done
# Espera 5-10 minutos. Si no llega el correo, la alerta no sirve.

Una alerta que nunca se ha disparado no es una alerta: es una intención. Provocarla es el único modo de saber que el canal de notificación funciona, que el umbral es alcanzable y que el correo no acaba en spam. Guarda la captura del correo recibido: vale 2 puntos.

  1. Prácticas transversales durante toda la implementación

Estas seis cosas no son de ninguna fase: son de todas.

11.1 Commits pequeños y frecuentes

Un commit por unidad de trabajo comprensible. Añade módulo de red con conector serverless es un commit; Avances varios no lo es.

git add infra/modules/red/
git commit -m "Añade módulo de red: VPC, subred, conector /28 y peering de servicios"

Beneficios concretos: puedes revertir una cosa sin revertir cinco, el historial cuenta la historia del proyecto en la presentación, y git bisect sirve para algo cuando algo se rompe.

11.2 terraform plan revisado siempre

Nunca apply sin haber leído el plan. Y presta atención especial a tres palabras:

En el plan Significado Reacción
will be created Recurso nuevo Normal
will be updated in-place Cambio sin recrear Normal
must be replaced Se destruye y se crea de nuevo PARAR y entender por qué
will be destroyed Desaparece Comprobar que es intencionado

must be replaced sobre una base de datos significa perder los datos. Sobre un bucket, perder los objetos. Casi siempre lo provoca cambiar un atributo ForceNew (el nombre, la región, un CIDR). Si aparece y no lo esperabas, cancela.

terraform plan -out=plan.tfplan
terraform show -json plan.tfplan | \
  jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address'
# Si esto devuelve algo que no esperas, no apliques.

11.3 No tocar nada a mano fuera de Terraform

La regla ideal. Y la realidad: lo vas a hacer, con prisa, un martes por la noche.

Qué hacer entonces, en orden:

  1. Anótalo inmediatamente en docs/diario.md. El pecado no es el clic; es olvidarlo.
  2. Detecta la deriva: terraform plan te dirá que la realidad no coincide con el código.
  3. Decide: o llevas el cambio al código (lo normal) o revierte el cambio manual.
  4. Si creaste un recurso a mano, impórtalo en vez de recrearlo:
# Terraform 1.5+: bloque import, versionable en el código
cat >> infra/envs/dev/imports.tf <<'EOF'
import {
  to = google_storage_bucket.temporal
  id = "refugio-dev/refugio-temporal-a1b2"
}
EOF
terraform plan   # genera la configuración que falta

El detector gratuito: la etiqueta gestionado-por = terraform de 08-02. Cualquier recurso sin ella se creó a mano.

11.4 Documentar al vuelo

Tres líneas por sesión en docs/diario.md y un ADR cada vez que decides algo con alternativas. No cuesta y es la diferencia entre una presentación con contenido y una inventada la noche antes.

11.5 Controlar el gasto cada pocos días

# Alias que conviene tener a mano
gcloud billing accounts list
# Y, cuando la exportación a BigQuery lleve unos días poblando:
bq query --use_legacy_sql=false \
'SELECT service.description AS servicio, ROUND(SUM(cost),2) AS coste_eur
 FROM `mi-proyecto.facturacion.gcp_billing_export_v1_XXXX`
 WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
 GROUP BY servicio ORDER BY coste_eur DESC'

Dos veces por semana, treinta segundos. Y destruye dev cuando no lo uses:

terraform -chdir=infra/envs/dev destroy -auto-approve   # viernes
terraform -chdir=infra/envs/dev apply   -auto-approve   # sábado

Esto tiene un doble beneficio que conviene subrayar: ahorras dinero y estás ejecutando cada semana la prueba más dura de RNF-1. Si algún viernes el apply del sábado no reconstruye el entorno, acabas de descubrir un fallo en tu IaC en el momento más barato posible.

11.6 Mantener los entornos alineados

Cada vez que cambies algo en prod, comprueba que dev tiene el equivalente. La deriva entre entornos convierte la promoción en una lotería.

  1. Qué hacer cuando te atascas: el método de cinco pasos

Paso Pregunta Comando
1 ¿Qué dice el error, entero? gcloud logging read 'severity>=ERROR' --limit=20 --freshness=1h --format=json
2 ¿Está la API activada? gcloud services list --enabled | grep <api>
3 ¿Es un permiso? gcloud policy-troubleshoot iam <recurso> --principal-email=<sa> --permission=<permiso>
4 ¿Es la red? gcloud network-management connectivity-tests create ...
5 ¿El recurso es como creo? gcloud <servicio> describe <recurso> --format=yaml

Los diez errores que te vas a encontrar, con su causa

Síntoma Causa casi segura Solución
PERMISSION_DENIED al desplegar Falta iam.serviceAccountUser sobre la SA de ejecución Concédelo sobre esa SA concreta
API not enabled Justo eso, o propagación aún en curso gcloud services enable ... y espera 2 min
Cloud SQL privada no se crea Falta el peering de acceso privado a servicios Crea global_address + service_networking_connection
Cloud Run no conecta con la BD Falta el conector, o vpc-egress mal --vpc-connector + --vpc-egress=private-ranges-only
Contenedor no arranca No escucha en $PORT o en 0.0.0.0 Corrige el CMD
Despliegue con timeout Arranque > 4 min (migraciones al inicio) Saca las migraciones del arranque
Bucket "already exists" Nombre global, lo tiene otro Añade sufijo con random_id
Conector VPC no se crea El rango no es un /28 exacto Ajusta el CIDR
Alerta que no llega Canal sin verificar, o correo en spam Verifica el canal y prueba provocándola
must be replaced inesperado Cambiaste un atributo ForceNew Revisa el registro de Terraform antes de aplicar

Y la regla de las dos horas: si llevas dos horas en el mismo error, cambia de fase. Anota en el diario el error exacto y lo que has probado. Volver al día siguiente resuelve más bloqueos que insistir.

  1. Registro de progreso y criterios de "hecho"

Mantén esta tabla en docs/diario.md y actualízala al terminar cada fase. Cuando trabajas solo, es lo único que te dice objetivamente dónde estás:

Fase Entregable Criterio de "hecho" verificable Comando de verificación Estado
0 Proyectos y presupuesto 3 proyectos facturables, ≥25 APIs, 1 presupuesto gcloud billing projects describe ⬜
0 Repositorio Estructura + README + ≥2 commits git log --oneline ⬜
1 Estado remoto Bucket con versionado y sin acceso público gcloud storage buckets describe ⬜
1 Red VPC propia, conector READY, peering activo gcloud compute networks list ⬜
1 Plan limpio terraform plan → No changes terraform plan ⬜
2 Identidades 0 roles primitivos en SA, 0 claves de usuario Script de la fase 2 ⬜
2 WIF Build autenticado sin secretos en el CI gcloud builds list ⬜
3 Base de datos ipv4Enabled = False, backups activos gcloud sql instances describe ⬜
3 Secretos Contraseña en Secret Manager, no en el repo git log -p | grep -i password → vacío ⬜
3 Datos ficticios ~2.000 filas, migraciones registradas psql -c "SELECT count(*)..." ⬜
4 Aplicación Servicio Ready, /salud/arranque = 200 curl ⬜
4 Logs Entradas con jsonPayload y campo trace gcloud logging read ⬜
5 CI/CD Push despliega en <10 min gcloud builds list --limit=1 ⬜
5 Prueba que protege Prueba rota → despliegue detenido Romper a propósito ⬜
6 Flujo completo Reserva → evento → BigQuery en <2 min bq query ⬜
6 Cuadro de mando 4 visualizaciones con datos URL del informe ⬜
6 IA Opiniones con puntuación de sentimiento psql -c "SELECT ... WHERE sentimiento IS NOT NULL" ⬜
7 Dominio y TLS HTTP/2 200 y certificado válido curl -sI + openssl ⬜
8 Panel 4 señales visibles gcloud monitoring dashboards list ⬜
8 Alerta probada Correo recibido tras provocarla Captura del correo ⬜
8 SLO Presupuesto de error con valor numérico Consola de Monitoring ⬜

Las tres filas en negrita son las que la gente marca sin comprobar. No lo hagas: son precisamente las que demuestran que el sistema funciona de verdad.

  1. El aviso realista: la primera vez todo tarda el doble

Merece una sección propia porque es la principal causa de abandono, y porque no es un problema tuyo.

Tarea Primera vez Segunda vez
Configurar WIF 2-3 h 15 min
Cloud SQL con IP privada 2 h 20 min
Primer despliegue en Cloud Run que funciona 3-4 h 30 min
Pipeline de Cloud Build completo 4 h 45 min
Panel de Monitoring con 4 gráficas 2 h 30 min
Módulo de Terraform reutilizable 3 h 45 min

Lo que ocurre la primera vez y no la segunda: leer documentación, entender el modelo mental, equivocarte con el nombre de un campo, esperar propagaciones, descubrir que faltaba un permiso, deshacer y rehacer.

Tres consecuencias prácticas:

  1. Planifica con el doble. Si crees que la fase 5 son 4 horas, resérvate 8.
  2. No te midas contra un tutorial. El vídeo de veinte minutos está editado y grabado por alguien que lo ha hecho cincuenta veces.
  3. El tiempo "perdido" es el aprendizaje. Las dos horas peleándote con iam.serviceAccountUser son exactamente el motivo por el que la próxima vez tardas quince minutos, y por el que en una entrevista sabrás responder al instante.

Errores Comunes y Consejos

Empezar por la aplicación. Es el error 2 de 08-01 y se manifiesta aquí. Si tu commit número 20 no tiene nada en infra/, has empezado por donde no era.

Crear "solo una cosita" a mano. Nunca es una. A las tres semanas tienes quince recursos huérfanos y un terraform plan lleno de ruido que ya no lees. Anota, importa o revierte, siempre.

Aplicar sin leer el plan. El día que aparezca must be replaced sobre tu base de datos y no lo veas, perderás los datos y el fin de semana.

Poner roles/editor "temporalmente". Ese "temporalmente" dura hasta la presentación. Empieza restrictivo y abre con policy-troubleshoot.

Dejar el dominio para el último día. La propagación de DNS y la emisión del certificado tienen tiempos que no controlas. Hazlo en cuanto la fase 5 esté estable.

Cargar el modelo de IA en cada petición. Analiza una vez y guarda el resultado. Llamar a una API de IA en cada carga de página es caro y lento sin ninguna ventaja.

No probar la alerta. Es la comprobación más rápida de hacer y la más olvidada. Una alerta sin probar tiene un 50 % de probabilidad de no funcionar cuando la necesites.

Consejo: escribe primero el criterio de "hecho", después el código. Antes de empezar una fase, escribe el comando que usarás para verificarla. Te obliga a definir qué significa terminar y evita el "creo que ya está".

Consejo: usa terraform plan como herramienta de aprendizaje. Cada vez que escribas un recurso nuevo, haz plan y lee qué implica. Es la mejor documentación que existe sobre lo que hace cada bloque.

Consejo: guarda las salidas importantes en el repositorio. Un directorio docs/evidencias/ con la salida del script de auditoría de identidades, la captura del primer flujo de extremo a extremo, el correo de la alerta. Vale puntos en 08-05 y es imposible reconstruirlo después.

Consejo: haz git commit al final de cada sesión aunque no funcione. Un commit WIP: conector VPC, el peering aún falla es información útil. El historial es un entregable.

Ejercicios

Ejercicio 1 — Construye la base reproducible (fases 0 a 2)

Completa las fases 0, 1 y 2 en tu proyecto: proyectos creados con los nombres del diseño, APIs activadas, presupuesto con alertas, repositorio con estructura; bucket de estado con versionado y bloqueo de acceso público, backend remoto con prefijo por entorno, proveedor fijado por versión, al menos dos módulos propios (red e identidad) y variables por entorno con las mismas claves; cuentas de servicio por carga de trabajo con roles acotados al recurso, y Workload Identity Federation con condición de repositorio.

Entrega: la salida del script de verificación de identidades (0 roles primitivos, 0 claves) y un terraform plan que diga No changes.

Ejercicio 2 — Datos, aplicación y flujo de extremo a extremo (fases 3, 4 y 6)

Despliega tu base de datos gestionada sin IP pública, con su contraseña generada y guardada en Secret Manager, sus migraciones aplicadas como código y sus datos ficticios sembrados de forma reproducible. Contenedoriza tu aplicación cumpliendo el contrato de Cloud Run —$PORT, 0.0.0.0, usuario no root, arranque rápido—, con configuración por variables de entorno, secretos por referencia, dos sondas de salud distintas y logs estructurados con trace_id. Conecta el flujo de datos hasta el cuadro de mando y añade el componente de IA.

Entrega: la traza completa de una acción en tu aplicación que acaba visible en el cuadro de mando, con los comandos de verificación de cada salto.

Ejercicio 3 — Automatiza, expón y observa (fases 5, 7 y 8)

Monta el pipeline que prueba, construye, publica, despliega a desarrollo y ejecuta una prueba de humo, más la promoción a producción de la misma imagen con aprobación. Rompe una prueba a propósito y demuestra con una captura que el despliegue se detiene. Expón el sistema en tu dominio con HTTPS válido y redirección desde HTTP. Crea el panel de las cuatro señales, la alerta con su documentación de primeros pasos, el uptime check y el SLO.

Provoca la alerta a propósito y guarda el correo recibido. Cierra el ejercicio con la tabla de registro de progreso de la sección 13 completamente marcada y verificada.

Soluciones

Solución 1 — La base de RefugioReserva

Tras completar las tres primeras fases, la verificación da esto:

$ terraform -chdir=infra/envs/dev plan
No changes. Your infrastructure matches the configuration.

$ gcloud projects get-iam-policy refugio-dev --format=json | \
  jq -r '.bindings[] | select(.role|test("roles/(owner|editor)")) |
         "\(.role): \(.members[])"'
roles/owner: user:[email protected]
# Ninguna cuenta de servicio. Correcto.

$ for SA in $(gcloud iam service-accounts list --project=refugio-dev --format="value(email)"); do
    N=$(gcloud iam service-accounts keys list --iam-account="$SA" --managed-by=user \
        --format="value(name)" | wc -l)
    echo "$SA -> $N claves de usuario"
  done
[email protected] -> 0
[email protected] -> 0
[email protected] -> 0
[email protected] -> 0

Los tres problemas reales que aparecieron y cuánto costaron:

Problema Síntoma Causa Tiempo perdido
Conector VPC no se creaba Invalid IP CIDR range Había puesto /27; exige /28 25 min
WIF autenticaba pero no desplegaba PERMISSION_DENIED al desplegar Faltaba iam.serviceAccountUser de sa-deploy sobre sa-web 1 h 40 min
Bucket de fotos rechazado already exists Nombre global tomado 10 min → random_id

El segundo es el clásico del módulo. gcloud policy-troubleshoot lo señaló en dos minutos; el problema fue que tardé hora y media en acordarme de usarlo. Anotado en el diario para no repetirlo.

Solución 2 — El flujo de extremo a extremo de RefugioReserva

# 1. Verificar la base de datos
$ gcloud sql instances describe refugio-db --project=refugio-dev \
    --format="value(settings.ipConfiguration.ipv4Enabled, state)"
False   RUNNABLE

# 2. Verificar el secreto
$ gcloud secrets versions list refugio-db-password --project=refugio-dev \
    --format="value(name,state)"
1   ENABLED

# 3. Datos sembrados
$ psql -h 127.0.0.1 -U app -d reservas -c \
    "SELECT count(*) AS reservas, count(DISTINCT refugio_id) AS refugios FROM reserva"
 reservas | refugios
----------+----------
     2000 |       12

# 4. La aplicación responde y lee de la BD
$ curl -s "${URL}/api/refugios" | jq '.[0]'
{"id":1,"nombre":"Refugio de Cotiella","altitud_m":2100,"capacidad":40}

# 5. Crear una reserva (datos FICTICIOS)
$ curl -s -X POST "${URL}/api/reservas" -H 'Content-Type: application/json' \
    -d '{"refugio_id":3,"fecha":"2026-12-20","plazas":2,
         "nombre_titular":"Prueba Ficticia","email_titular":"[email protected]"}' | jq
{"id":"7c2e...","estado":"confirmada","plazas_restantes":20}

# 6. El log estructurado, con su traza
$ gcloud logging read 'resource.type="cloud_run_revision" jsonPayload.ruta="/api/reservas"' \
    --limit=1 --format="value(jsonPayload.message, jsonPayload.codigo, trace)"
peticion  201  projects/refugio-dev/traces/8a1f...

# 7. El evento llegó a BigQuery (26 segundos después)
$ bq query --use_legacy_sql=false --project_id=refugio-datos \
  'SELECT evento_id, tipo_evento, refugio_id, plazas, ocurrido_en
   FROM `refugio-datos.refugio_analitica.reservas_eventos`
   WHERE DATE(ocurrido_en) = CURRENT_DATE() ORDER BY ocurrido_en DESC LIMIT 1'
+-----------+-------------+------------+--------+---------------------+
| evento_id | tipo_evento | refugio_id | plazas |     ocurrido_en     |
+-----------+-------------+------------+--------+---------------------+
| 7c2e...   | creada      |          3 |      2 | 2026-10-14 18:42:11 |
+-----------+-------------+------------+--------+---------------------+

# 8. Y el sentimiento de las opiniones
$ psql -c "SELECT count(*) FILTER (WHERE sentimiento IS NOT NULL) AS analizadas,
           round(avg(sentimiento)::numeric,3) AS media FROM opinion"
 analizadas | media
------------+--------
        400 |  0.412

Hito M3 cerrado. Ocho comandos, ocho saltos verificados, 26 segundos de la acción al almacén analítico. Captura guardada en docs/evidencias/.

Un detalle que costó tiempo y merece la pena señalar: los eventos aparecían duplicados en BigQuery. Causa: Pub/Sub garantiza "al menos una vez" y la función se reintentaba. Solución: row_ids=[evento_id] en insert_rows_json, que activa la deduplicación por identificador. Sin eso, el cuadro de mando mostraba un 12 % más de reservas de las reales, y lo peor es que no lo habría notado si no hubiera cuadrado el total contra la base de datos operativa. Lección anotada: contrasta siempre el almacén analítico contra el operativo.

Solución 3 — Entrega, exposición y observabilidad de RefugioReserva

La prueba de que el pipeline protege. Se rompió a propósito la prueba del control de aforo:

# app/tests/test_unitarios.py — cambio temporal
def test_no_sobreventa():
    disponible = calcular_disponibilidad(refugio_id=1, fecha="2026-08-15")
    assert disponible == 999   # ← valor incorrecto a propósito
$ git commit -am "PRUEBA: rompo el test de aforo a propósito" && git push
$ gcloud builds list --limit=1 --format="table(id, status, createTime)"
ID                                    STATUS   CREATE_TIME
9f2a-...                              FAILURE  2026-10-21T19:14:22

$ gcloud run revisions list --service=refugio-web --region=europe-west1 --limit=2 \
    --format="table(name, active, createTime)"
NAME                    ACTIVE  CREATE_TIME
refugio-web-a3f81c      True    2026-10-21T18:02:11   ← la anterior sigue sirviendo

El despliegue se detuvo en el paso 1. La revisión anterior siguió atendiendo tráfico. La red de seguridad existe y está probada. Captura guardada.

Exposición:

$ curl -sI https://dev.refugioreserva.example | head -3
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
content-type: text/html; charset=utf-8

$ echo | openssl s_client -connect dev.refugioreserva.example:443 \
    -servername dev.refugioreserva.example 2>/dev/null | \
    openssl x509 -noout -dates -issuer
notBefore=Oct 20 09:14:00 2026 GMT
notAfter=Jan 18 09:13:59 2027 GMT
issuer=C = US, O = Google Trust Services, CN = WE1

$ curl -sI http://dev.refugioreserva.example | head -1
HTTP/1.1 301 Moved Permanently

Tras el ADR-006 se usa el dominio personalizado de Cloud Run: certificado gestionado, coste cero, RNF-5 cumplido. El módulo del balanceador quedó escrito y se desplegará una semana en 08-04 para las pruebas de carga.

La alerta, provocada de verdad:

$ for i in $(seq 1 300); do curl -s -o /dev/null "${URL}/api/error-de-prueba"; done
$ # 6 minutos después:
$ gcloud alpha monitoring policies list --format="value(displayName,enabled)"
Tasa de errores 5xx > 5%   True

Correo recibido a las 20:41, seis minutos después de empezar a generar errores. Contenía los cuatro primeros pasos del bloque documentation. Captura guardada en docs/evidencias/alerta-recibida.png.

Un detalle que no funcionó a la primera: el primer canal de notificación estaba creado pero sin verificar, y las alertas no llegaban. No hay ningún error visible: la política aparece activa y el incidente se abre, pero el correo no sale. Se descubrió sólo porque se provocó la alerta a propósito. Es exactamente el motivo por el que provocarla es obligatorio.

Registro de progreso final:

Fase Criterio Estado Evidencia
0 3 proyectos, 27 APIs, presupuesto 12 € ✅ docs/evidencias/fase0.txt
1 plan limpio, VPC propia, peering ✅ docs/evidencias/plan-limpio.txt
2 0 roles primitivos, 0 claves, WIF ✅ docs/evidencias/identidades.txt
3 BD privada, secreto, 2.000 filas ✅ docs/evidencias/datos.txt
4 Servicio Ready, logs con traza ✅ docs/evidencias/app.txt
5 Push despliega; prueba rota lo detiene ✅ docs/evidencias/build-fallido.png
6 Reserva → BigQuery en 26 s; 400 opiniones analizadas ✅ docs/evidencias/e2e.txt
7 HTTP/2 200, HSTS, certificado válido, 301 ✅ docs/evidencias/tls.txt
8 Panel, alerta recibida, SLO, uptime ✅ docs/evidencias/alerta-recibida.png

Tiempo real invertido: 63 horas frente a las 50 estimadas. El desvío se concentró en tres puntos: WIF (2,5 h frente a 1 estimada), el peering de Cloud SQL (2 h frente a 0,5) y la depuración de los eventos duplicados (3 h no previstas). Coincide con el aviso de la sección 14 casi exactamente.

Conclusión

Tu sistema existe, funciona y se reconstruye desde cero.

Sabes por qué el orden de construcción es el que es: de lo irreversible a lo reversible, de lo que no depende de nada a lo que depende de todo. El projectId en la fase 0 y el panel en la 8, porque uno no se cambia nunca y el otro se rehace en diez minutos. Y conoces cada dependencia concreta: por qué la identidad va antes que los datos, por qué la aplicación no se automatiza hasta haberla desplegado a mano una vez, por qué la observabilidad se monta sobre el sistema completo.

Tienes la fase 0 con los proyectos creados con nombres definitivos, las APIs activadas de golpe, el presupuesto existiendo antes de que pudieras gastar, y el repositorio con su estructura y su README escrito al principio y no al final.

Tienes la base con Terraform: el bootstrap que resuelve el problema del huevo y la gallina, el bucket de estado con versionado y sin acceso público, el backend con prefijo por entorno, el proveedor fijado por versión para que una actualización un martes cualquiera no te proponga destruir media infraestructura, módulos propios reutilizados en dev y prod, variables con exactamente las mismas claves en ambos, y los dos recursos del peering de acceso privado a servicios que todo el mundo olvida.

Tienes la identidad hecha bien: una cuenta de servicio por carga, roles acotados al recurso y no al proyecto —google_secret_manager_secret_iam_member, no google_project_iam_member—, Workload Identity Federation con su attribute_condition, que sin él acepta tokens de cualquier repositorio del planeta, y iam.serviceAccountUser previsto para que el primer despliegue automatizado no te robe una tarde.

Tienes los datos con la base sin IP pública, la contraseña generada por Terraform y guardada en Secret Manager sin que nadie la vea nunca, buckets con prevención de acceso público forzada y ciclo de vida, migraciones idempotentes versionadas en el repositorio y un generador de datos ficticios reproducible con semilla fija.

Tienes la aplicación cumpliendo el contrato de Cloud Run —$PORT, 0.0.0.0, arranque rápido, sin estado—, sin root, con configuración por variables y secretos por referencia, con dos sondas que hacen cosas distintas a propósito, con cpu_idle para no pagar entre peticiones, y con logs estructurados cuya clave logging.googleapis.com/trace enlaza cada línea con su traza.

Tienes la entrega automatizada con el orden correcto —pruebas antes de construir, humo después de desplegar— y el principio que la hace fiable: la misma imagen se promociona a producción, sin reconstruir, porque reconstruir es desplegar algo que nunca has probado. Y la has verificado del único modo que vale: rompiendo una prueba a propósito y comprobando que el despliegue se detiene.

Tienes la capa de datos e IA con eventos que no llevan datos personales, deduplicación por row_ids que evita cifras infladas, tablas particionadas con filtro obligatorio y un componente de IA basado en una API preentrenada que analiza una vez y guarda el resultado.

Tienes la exposición con dominio propio y TLS válido, sabiendo elegir entre la ruta de coste cero y la completa con CDN y WAF, y con la limitación de tasa entendida como lo que también es: protección del presupuesto.

Y tienes la observabilidad con el panel de las cuatro señales, la alerta con sus primeros pasos escritos en el propio bloque documentation, el uptime check, el SLO calculando su presupuesto de error, y la alerta probada provocándola, que es la única forma de descubrir un canal de notificación sin verificar antes de necesitarlo.

Sobre todo eso tienes las prácticas transversales: commits pequeños, plan leído siempre —con must be replaced como palabra de alarma—, nada creado a mano y qué hacer cuando lo haces, documentación al vuelo, control del gasto dos veces por semana con el destroy de los viernes que ahorra dinero y prueba tu IaC a la vez, y los entornos alineados.

Y tienes el método de cinco pasos para cuando te atasques, los diez errores más frecuentes con su causa, la regla de las dos horas, la tabla de registro de progreso con criterios verificables y la advertencia más honesta: la primera vez todo tarda el doble, y ese tiempo no es perdido, es exactamente el aprendizaje.

En la próxima lección, 08-04, la pregunta ya no es "¿funciona?" sino "¿cómo lo sé?". Vas a montar la pirámide de pruebas de tu proyecto, probar la infraestructura recreándola desde cero —la prueba definitiva de que tu IaC es real—, ejecutar una lista de comprobación de seguridad, hacer una prueba de carga honesta y barata, apagar una dependencia a propósito para ver qué pasa, cronometrar una restauración, ensayar una reversión, y pasar tu lista de verificación previa al lanzamiento antes de declarar el proyecto en producción.

Curso de Google Cloud Platform (GCP)

Módulo 1: Introducción a Google Cloud Platform

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados