Ingesta privada de objetos con expiración (TTL)
Un workload sube archivos a un bucket privado, sin claves, por red privada, y los archivos se borran solos a los N días.
Problema
Necesitás recibir archivos temporales (exports, uploads de usuarios, staging de ETL) que no deben quedar expuestos ni acumularse. Los requisitos son: cero acceso público, cero claves estáticas, tráfico por red privada, permisos acotados al prefijo de ingesta y borrado automático tras N días.
La topología es la misma en todas las nubes; lo que cambia es con qué se implementa cada rol.
Identidad sin secretos
Azure
User-assigned Managed Identity
Storage Blob Data Contributor sobre el contenedor
Venís de AWS: allá este rol es IAM Role (ECS task role). El scope mínimo de un role assignment es el contenedor, no un prefijo como en una policy de S3: para acotar por ruta hace falta una condición ABAC.
Venís de GCP: allá este rol es Service Account adjunta al workload.
2 gotchas en este rol
GCP
Service Account adjunta al workload
roles/storage.objectCreator con IAM Condition por prefijo
Venís de Azure: allá este rol es User-assigned Managed Identity.
Venís de AWS: allá este rol es IAM Role (ECS task role).
1 gotcha en este rol
Acceso privado
Azure
Private Endpoint (subresource blob)
+ zona privatelink.blob.core.windows.net
Venís de AWS: allá este rol es Gateway VPC Endpoint para S3. Además del endpoint necesitás la zona privatelink.blob.core.windows.net vinculada a la VNet; sin ella se resuelve la IP pública y la request da 403.
Venís de GCP: allá este rol es Private Google Access.
1 gotcha en este rol
GCP
Private Google Access≈ Equivalente parcial
flag en la subnet
Da salida a las APIs de Google sin IP pública, pero no es un endpoint privado del bucket ni restringe quién accede.
Venís de Azure: allá este rol es Private Endpoint (subresource blob). No es un Private Endpoint: no hay IP privada del bucket. Para restringir el origen hace falta bucket IP filtering o VPC Service Controls.
Venís de AWS: allá este rol es Gateway VPC Endpoint para S3.
1 gotcha en este rol
Almacenamiento de objetos
Azure
Storage Account (StorageV2) + container
Shared Key deshabilitado, acceso público de red deshabilitado
Venís de AWS: allá este rol es S3 bucket.
Venís de GCP: allá este rol es Cloud Storage bucket.
2 gotchas en este rol
GCP
Cloud Storage bucket
Uniform bucket-level access + Public access prevention enforced
Venís de Azure: allá este rol es Storage Account (StorageV2) + container. Soft delete viene activo por default (7 días): un objeto efímero se cobra una semana más salvo que lo pongas en 0.
Venís de AWS: allá este rol es S3 bucket. Soft delete viene activo por default (7 días): un objeto efímero se cobra una semana más salvo que lo pongas en 0.
2 gotchas en este rol
Política de expiración
Azure
Lifecycle management policy
delete_after_days_since_creation_greater_than
Venís de AWS: allá este rol es S3 Lifecycle rule. El prefijo del filtro incluye el nombre del contenedor: un "incoming/" copiado de S3 no matchea nada y no da error.
Venís de GCP: allá este rol es Object Lifecycle Management. El prefijo del filtro incluye el nombre del contenedor, y con soft delete activo el borrado no libera capacidad hasta que vence la retención.
3 gotchas en este rol
GCP
Object Lifecycle Management
Delete por age + AbortIncompleteMultipartUpload
Venís de Azure: allá este rol es Lifecycle management policy.
Venís de AWS: allá este rol es S3 Lifecycle rule.
2 gotchas en este rol
Perímetro de datos
Azure
Storage firewall≈ Equivalente parcial
public_network_access = Disabled
Para un perímetro a nivel organización entre varios PaaS, ver Network Security Perimeter.
Venís de AWS: allá este rol es Bucket policy.
Venís de GCP: allá este rol es Bucket IP filtering (opcional).
Sin letra chica en este rol.
GCP
Bucket IP filtering (opcional)≈ Equivalente parcial
El perímetro completo es VPC Service Controls, que se configura a nivel organización.
Venís de Azure: allá este rol es Storage firewall.
Venís de AWS: allá este rol es Bucket policy.
1 gotcha en este rol
Microsoft Azure
Implementación
- Identidad sin secretos
- User-assigned Managed IdentityStorage Blob Data Contributor sobre el contenedorVenís de AWS: allá este rol es IAM Role (ECS task role). El scope mínimo de un role assignment es el contenedor, no un prefijo como en una policy de S3: para acotar por ruta hace falta una condición ABAC.Venís de GCP: allá este rol es Service Account adjunta al workload.
- Acceso privado
- Private Endpoint (subresource blob)+ zona privatelink.blob.core.windows.netVenís de AWS: allá este rol es Gateway VPC Endpoint para S3. Además del endpoint necesitás la zona privatelink.blob.core.windows.net vinculada a la VNet; sin ella se resuelve la IP pública y la request da 403.Venís de GCP: allá este rol es Private Google Access.
- Almacenamiento de objetos
- Storage Account (StorageV2) + containerShared Key deshabilitado, acceso público de red deshabilitadoVenís de AWS: allá este rol es S3 bucket.Venís de GCP: allá este rol es Cloud Storage bucket.
- Política de expiración
- Lifecycle management policydelete_after_days_since_creation_greater_thanVenís de AWS: allá este rol es S3 Lifecycle rule. El prefijo del filtro incluye el nombre del contenedor: un "incoming/" copiado de S3 no matchea nada y no da error.Venís de GCP: allá este rol es Object Lifecycle Management. El prefijo del filtro incluye el nombre del contenedor, y con soft delete activo el borrado no libera capacidad hasta que vence la retención.
- Perímetro de datos
- Storage firewall≈public_network_access = DisabledPara un perímetro a nivel organización entre varios PaaS, ver Network Security Perimeter.Venís de AWS: allá este rol es Bucket policy.Venís de GCP: allá este rol es Bucket IP filtering (opcional).
Modelo de costo
Escala a cero parcial
Costo fijo aunque no haya tráfico:
- Private Endpoint (cargo por hora, más GB procesados)
- Zona DNS privada (cargo mensual por zona)
El storage escala a cero (pagás GB y operaciones). El costo fijo lo pone la red privada. Es la diferencia principal con AWS, donde el Gateway Endpoint de S3 no tiene costo.
Piso mensual sin tráfico
USD 7,80/ mes
Con la configuración por default del módulo Terraform. Sirve para comparar órdenes de magnitud, no para presupuestar.
- Private Endpoint × 1 · 0.01 USD/hUSD 7,30
- Zona DNS privada × 1 · 0.5 USD/mesUSD 0,50
Precio de lista en eastus al 2026-10-24, en USD, sin descuentos ni impuestos.
Letra chica · 6 gotchas
Deshabilitar Shared Key obliga a que Terraform use Entra ID
Con shared_access_key_enabled = false, toda llamada de data plane tiene que autenticarse con Entra ID, incluida la del provider. Configurá storage_use_azuread = true en el provider azurerm y dale a la identidad del pipeline un rol de datos (no alcanza con Contributor, que es solo control plane).
Los role assignments son por cuenta o contenedor; por prefijo hacen falta condiciones ABAC
Azure Storage admite role assignments a nivel de cuenta y de contenedor. Para acotar por prefijo o ruta hacen falta condiciones ABAC sobre el role assignment (atributos Blob prefix y Blob path). Alternativa simple: un contenedor dedicado a la ingesta (lo que hace este módulo).
Private Endpoint sin zona DNS privada: el firewall del servicio puede responder 403
Crear el Private Endpoint no alcanza: el FQDN <cuenta>.blob.core.windows.net tiene que resolver a la IP privada vía la zona privatelink.blob.core.windows.net vinculada a la VNet (o a tu DNS híbrido). Si resuelve a la IP pública y la cuenta tiene firewall, este rechaza la request con 403 y parece un problema de permisos.
Con soft delete activo, "borrar" no libera capacidad
Si la cuenta tiene soft delete de blobs, la acción delete de lifecycle deja el blob en estado soft-deleted durante el período de retención, y lo seguís pagando. Tu TTL efectivo de costo es ttl_days + días de soft delete. El módulo lo expone como variable para que sea una decisión explícita.
La policy tarda hasta 24 h en aplicarse y corre en background
Al crear o editar una lifecycle policy puede tardar hasta 24 h en entrar en vigor, y cada corrida puede llevar más de un día en cuentas grandes. No uses lifecycle como control de seguridad ("el archivo ya no existe a las 48 h"): si el requisito es estricto, borrá desde la aplicación o usá un job propio.
En Azure el prefijo del filtro incluye el nombre del contenedor
prefix_match es "contenedor/ruta", no solo la ruta. Un filtro "incoming/" que en S3 o Cloud Storage funciona, en Azure matchea un contenedor llamado "incoming" y nunca borra nada. El error no es visible: la policy se crea sin fallar.
Terraform
main.tf
# Ingesta privada de objetos con expiración (TTL) — Azure
#
# Storage Account sin claves (solo Entra ID), sin acceso público de red,
# alcanzable por Private Endpoint, con un contenedor de ingesta cuyo
# contenido se borra a los N días.
resource "azurerm_resource_group" "this" {
name = "rg-${var.name}"
location = var.location
tags = var.tags
}
# --- Red ----------------------------------------------------------------------
resource "azurerm_virtual_network" "this" {
name = "vnet-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
address_space = [var.vnet_address_space]
tags = var.tags
}
resource "azurerm_subnet" "endpoints" {
name = "snet-private-endpoints"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.endpoints_subnet_prefix]
}
# Sin reglas propias: las default permiten tráfico intra-VNet y bloquean internet.
# Para que el NSG filtre tráfico hacia Private Endpoints hay que habilitar
# network policies en la subnet (private_endpoint_network_policies).
resource "azurerm_network_security_group" "endpoints" {
name = "nsg-${var.name}-endpoints"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_subnet_network_security_group_association" "endpoints" {
subnet_id = azurerm_subnet.endpoints.id
network_security_group_id = azurerm_network_security_group.endpoints.id
}
# --- Storage ------------------------------------------------------------------
resource "azurerm_storage_account" "this" {
#checkov:skip=CKV2_AZURE_1:CMK fuera de alcance del patrón; cifrado con claves de Microsoft + infraestructura.
#checkov:skip=CKV_AZURE_206:ZRS es suficiente para datos efímeros con TTL; GRS duplica costo sin beneficio.
#checkov:skip=CKV_AZURE_33:No se usa Queue Storage en este patrón.
#checkov:skip=CKV_AZURE_59:checkov evalúa public_network_access_enabled (deprecado en azurerm 5); acá se usa public_network_access = "Disabled".
#checkov:skip=CKV_AZURE_36:Sin bypass de servicios de confianza a propósito: el único camino es el Private Endpoint.
name = var.storage_account_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_kind = "StorageV2"
account_tier = "Standard"
account_replication_type = "ZRS"
# Identidad: solo Entra ID. Sin Shared Key, sin SAS de cuenta, sin usuarios locales.
shared_access_key_enabled = false
default_to_oauth_authentication = true
local_user_enabled = false
# Red: sin endpoint público. El único camino es el Private Endpoint.
public_network_access = "Disabled"
allow_nested_items_to_be_public = false
cross_tenant_replication_enabled = false
https_traffic_only_enabled = true
min_tls_version = "TLS1_2"
infrastructure_encryption_enabled = true
network_rules {
default_action = "Deny"
bypass = ["None"]
}
blob_properties {
# Sin versioning: con versiones previas, lifecycle no borra la versión actual
# hasta borrar las anteriores, y el TTL deja de ser predecible.
versioning_enabled = false
delete_retention_policy {
days = var.soft_delete_retention_days
}
container_delete_retention_policy {
days = 7
}
}
tags = var.tags
}
resource "azurerm_storage_container" "ingest" {
#checkov:skip=CKV2_AZURE_21:Logging de lectura vía diagnostic settings, fuera de alcance del patrón.
# storage_account_id → se gestiona por Resource Manager (control plane),
# así que funciona con public_network_access = "Disabled".
name = "ingest"
storage_account_id = azurerm_storage_account.this.id
container_access_type = "private"
}
# --- Expiración (TTL) ---------------------------------------------------------
resource "azurerm_storage_management_policy" "ttl" {
storage_account_id = azurerm_storage_account.this.id
rule {
name = "expire-ingest"
enabled = true
filters {
blob_types = ["blockBlob"]
# El prefijo DEBE empezar con el nombre del contenedor
# (gotcha: prefix-includes-container).
prefix_match = ["${azurerm_storage_container.ingest.name}/${var.object_prefix}"]
}
actions {
base_blob {
delete_after_days_since_creation_greater_than = var.ttl_days
}
}
}
}
# --- Identidad del productor --------------------------------------------------
resource "azurerm_user_assigned_identity" "producer" {
name = "id-${var.name}-producer"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = var.tags
}
resource "azurerm_role_assignment" "producer_blob_writer" {
# Scope mínimo posible sin ABAC: el contenedor (gotcha: rbac-scope-is-container).
scope = azurerm_storage_container.ingest.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.producer.principal_id
principal_type = "ServicePrincipal"
}
# --- Private Endpoint + DNS ---------------------------------------------------
resource "azurerm_private_dns_zone" "blob" {
name = "privatelink.blob.core.windows.net"
resource_group_name = azurerm_resource_group.this.name
tags = var.tags
}
resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
name = "link-${var.name}"
private_dns_zone_id = azurerm_private_dns_zone.blob.id
virtual_network_id = azurerm_virtual_network.this.id
registration_enabled = false
tags = var.tags
}
resource "azurerm_private_endpoint" "blob" {
name = "pe-${var.name}-blob"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
subnet_id = azurerm_subnet.endpoints.id
private_service_connection {
name = "psc-${var.name}-blob"
private_connection_resource_id = azurerm_storage_account.this.id
subresource_names = ["blob"]
is_manual_connection = false
}
# Sin esto el FQDN resuelve a la IP pública y la request falla con 403
# (gotcha: private-endpoint-needs-dns).
private_dns_zone_group {
name = "default"
private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
}
tags = var.tags
}variables.tf
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"ingest-demo\")."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,20}$", var.name))
error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
}
}
variable "storage_account_name" {
description = "Nombre global único de la Storage Account (3-24, solo minúsculas y números)."
type = string
validation {
condition = can(regex("^[a-z0-9]{3,24}$", var.storage_account_name))
error_message = "storage_account_name: 3-24 caracteres, solo minúsculas y números."
}
}
variable "location" {
description = "Región de Azure."
type = string
default = "eastus2"
}
variable "ttl_days" {
description = "Días desde la creación tras los cuales se borra el blob."
type = number
default = 7
validation {
condition = var.ttl_days >= 1 && floor(var.ttl_days) == var.ttl_days
error_message = "ttl_days debe ser un entero >= 1."
}
}
variable "object_prefix" {
description = "Ruta dentro del contenedor a la que aplica el TTL (sin el nombre del contenedor)."
type = string
default = "incoming/"
}
variable "soft_delete_retention_days" {
description = "Retención de soft delete. El costo efectivo del TTL es ttl_days + este valor (gotcha: soft-delete-keeps-capacity)."
type = number
default = 7
validation {
condition = var.soft_delete_retention_days >= 1 && var.soft_delete_retention_days <= 365
error_message = "soft_delete_retention_days debe estar entre 1 y 365."
}
}
variable "vnet_address_space" {
description = "Espacio de direcciones de la VNet del workload."
type = string
default = "10.10.0.0/16"
}
variable "endpoints_subnet_prefix" {
description = "Prefijo de la subnet para Private Endpoints."
type = string
default = "10.10.1.0/24"
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "storage_account_id" {
description = "ID de la Storage Account."
value = azurerm_storage_account.this.id
}
output "ingest_container_url" {
description = "URL del contenedor de ingesta (resuelve a IP privada desde la VNet)."
value = "${azurerm_storage_account.this.primary_blob_endpoint}${azurerm_storage_container.ingest.name}"
}
output "producer_identity_client_id" {
description = "Client ID de la Managed Identity que tiene que usar el workload productor."
value = azurerm_user_assigned_identity.producer.client_id
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.8"
}
}
}
provider "azurerm" {
features {}
# Con shared_access_key_enabled = false el provider tiene que usar Entra ID
# para las llamadas de data plane (gotcha: shared-key-needs-azuread-provider).
storage_use_azuread = true
# subscription_id se toma de ARM_SUBSCRIPTION_ID.
}Google Cloud
Implementación
- Identidad sin secretos
- Service Account adjunta al workloadroles/storage.objectCreator con IAM Condition por prefijoVenís de Azure: allá este rol es User-assigned Managed Identity.Venís de AWS: allá este rol es IAM Role (ECS task role).
- Acceso privado
- Private Google Access≈flag en la subnetDa salida a las APIs de Google sin IP pública, pero no es un endpoint privado del bucket ni restringe quién accede.Venís de Azure: allá este rol es Private Endpoint (subresource blob). No es un Private Endpoint: no hay IP privada del bucket. Para restringir el origen hace falta bucket IP filtering o VPC Service Controls.Venís de AWS: allá este rol es Gateway VPC Endpoint para S3.
- Almacenamiento de objetos
- Cloud Storage bucketUniform bucket-level access + Public access prevention enforcedVenís de Azure: allá este rol es Storage Account (StorageV2) + container. Soft delete viene activo por default (7 días): un objeto efímero se cobra una semana más salvo que lo pongas en 0.Venís de AWS: allá este rol es S3 bucket. Soft delete viene activo por default (7 días): un objeto efímero se cobra una semana más salvo que lo pongas en 0.
- Política de expiración
- Object Lifecycle ManagementDelete por age + AbortIncompleteMultipartUploadVenís de Azure: allá este rol es Lifecycle management policy.Venís de AWS: allá este rol es S3 Lifecycle rule.
- Perímetro de datos
- Bucket IP filtering (opcional)≈El perímetro completo es VPC Service Controls, que se configura a nivel organización.Venís de Azure: allá este rol es Storage firewall.Venís de AWS: allá este rol es Bucket policy.
Modelo de costo
Escala a cero
Sin costo fijo
Sin costo fijo: Private Google Access no tiene cargo. Ojo con soft delete (ver gotcha): es el costo oculto de este patrón en GCP.
Piso mensual sin tráfico
USD 0/ mes
USD 0: no hay componentes con costo fijo.
Letra chica · 5 gotchas
Uniform bucket-level access + public access prevention son el par a declarar
UBLA deshabilita las ACLs por objeto (equivalente a BucketOwnerEnforced en S3) y es requisito para usar IAM Conditions. Public access prevention "enforced" bloquea allUsers/allAuthenticatedUsers (equivalente a Block Public Access).
roles/storage.objectCreator no alcanza para sobrescribir
Sobrescribir un objeto existente requiere storage.objects.delete, que objectCreator no tiene. Para ingesta es una ventaja (un productor comprometido no puede pisar archivos), pero rompe clientes que reintentan con el mismo nombre. En S3, s3:PutObject sí sobrescribe.
Private Google Access da alcance a las APIs de Google; filtrar el origen del bucket es otro control
Las VMs que solo tienen IP interna pueden usar Private Google Access para llegar a las APIs y servicios de Google. Eso resuelve el alcance, no el filtrado de origen. Bucket IP filtering permite restringir el acceso al bucket según rangos de IP del cliente y redes VPC, y funciona junto con IAM.
Soft delete viene activo por default (7 días) y encarece los datos efímeros
Todos los buckets tienen soft delete con 7 días de retención salvo que lo cambies. Un objeto que vive una hora se cobra esa hora más 7 días de soft delete. En un bucket de ingesta con TTL eso puede multiplicar el costo. El módulo lo pone en 0 por default (deshabilitado) y lo deja como variable explícita.
Los cambios de lifecycle pueden tardar hasta 24 h y la acción es asincrónica
Igual que en Azure y S3, no es un TTL exacto: un objeto que cumple la condición puede seguir existiendo un tiempo. Si necesitás garantía de borrado, hacelo desde la aplicación.
Terraform
main.tf
# Ingesta privada de objetos con expiración (TTL) — GCP
#
# Bucket con UBLA + public access prevention, soft delete explícito,
# acceso desde una subnet con Private Google Access, escritura acotada
# a un prefijo con IAM Conditions y borrado a los N días.
# --- Red ----------------------------------------------------------------------
resource "google_compute_network" "this" {
name = "vpc-${var.name}"
auto_create_subnetworks = false
}
# Denegar todo ingress explícitamente (las VPCs custom no traen reglas,
# pero declararlo deja la intención en el código).
resource "google_compute_firewall" "deny_all_ingress" {
name = "fw-${var.name}-deny-all-ingress"
network = google_compute_network.this.id
direction = "INGRESS"
priority = 65534
deny {
protocol = "all"
}
source_ranges = ["0.0.0.0/0"]
}
resource "google_compute_subnetwork" "workloads" {
name = "snet-${var.name}-workloads"
region = var.region
network = google_compute_network.this.id
ip_cidr_range = var.subnet_cidr
# VMs/servicios sin IP pública llegan a storage.googleapis.com sin NAT.
# No restringe quién accede al bucket (gotcha: pga-is-not-a-perimeter).
private_ip_google_access = true
log_config {
aggregation_interval = "INTERVAL_10_MIN"
flow_sampling = 0.5
metadata = "INCLUDE_ALL_METADATA"
}
}
# --- Bucket -------------------------------------------------------------------
resource "google_storage_bucket" "ingest" {
#checkov:skip=CKV_GCP_62:Access logs fuera de alcance; usar Cloud Audit Logs (Data Access) si se requiere auditoría.
#checkov:skip=CKV_GCP_78:Sin versioning a propósito: el TTL es el comportamiento buscado.
name = var.bucket_name
location = upper(var.region)
storage_class = "STANDARD"
force_destroy = false
labels = var.labels
# Equivalente a BucketOwnerEnforced + Block Public Access (gotcha: ubla-and-pap).
uniform_bucket_level_access = true
public_access_prevention = "enforced"
versioning {
enabled = false
}
# Default de GCS: 7 días. Acá es una decisión explícita (gotcha: soft-delete-default-7-days).
soft_delete_policy {
retention_duration_seconds = var.soft_delete_retention_seconds
}
lifecycle_rule {
condition {
age = var.ttl_days
matches_prefix = [var.object_prefix]
}
action {
type = "Delete"
}
}
lifecycle_rule {
condition {
age = 1
}
action {
type = "AbortIncompleteMultipartUpload"
}
}
dynamic "ip_filter" {
for_each = var.restrict_to_vpc ? [1] : []
content {
mode = "Enabled"
vpc_network_sources {
network = google_compute_network.this.id
allowed_ip_cidr_ranges = [var.subnet_cidr]
}
}
}
}
# --- Identidad del productor --------------------------------------------------
resource "google_service_account" "producer" {
account_id = "${var.name}-producer"
display_name = "Productor de ingesta (${var.name})"
}
resource "google_storage_bucket_iam_member" "producer_create" {
bucket = google_storage_bucket.ingest.name
# objectCreator no puede sobrescribir (gotcha: objectcreator-cannot-overwrite).
role = "roles/storage.objectCreator"
member = google_service_account.producer.member
# Acotado al prefijo, como en S3. Requiere UBLA.
condition {
title = "only-${trimsuffix(var.object_prefix, "/")}"
description = "Solo objetos bajo ${var.object_prefix}"
expression = "resource.name.startsWith(\"projects/_/buckets/${var.bucket_name}/objects/${var.object_prefix}\")"
}
}variables.tf
variable "project_id" {
description = "ID del proyecto de GCP."
type = string
}
variable "name" {
description = "Prefijo para nombrar los recursos (ej. \"ingest-demo\")."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,19}$", var.name))
error_message = "name: 3-20 caracteres, empieza con letra, minúsculas, números o guiones."
}
}
variable "bucket_name" {
description = "Nombre global único del bucket."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9_.-]{1,61}[a-z0-9]$", var.bucket_name))
error_message = "bucket_name: 3-63 caracteres, minúsculas, números, puntos, guiones o guiones bajos."
}
}
variable "region" {
description = "Región de GCP (el bucket se crea regional en la misma región)."
type = string
default = "us-central1"
}
variable "ttl_days" {
description = "Edad en días tras la cual se borra el objeto."
type = number
default = 7
validation {
condition = var.ttl_days >= 1 && floor(var.ttl_days) == var.ttl_days
error_message = "ttl_days debe ser un entero >= 1."
}
}
variable "object_prefix" {
description = "Prefijo de los objetos a los que aplica el TTL (en GCS el prefijo NO incluye el bucket)."
type = string
default = "incoming/"
}
variable "soft_delete_retention_seconds" {
description = "Retención de soft delete. 0 lo deshabilita; el default de GCS es 7 días (gotcha: soft-delete-default-7-days)."
type = number
default = 0
validation {
condition = var.soft_delete_retention_seconds == 0 || (var.soft_delete_retention_seconds >= 604800 && var.soft_delete_retention_seconds <= 7776000)
error_message = "soft_delete_retention_seconds: 0 (deshabilitado) o entre 7 y 90 días (604800-7776000)."
}
}
variable "subnet_cidr" {
description = "CIDR de la subnet del workload."
type = string
default = "10.30.0.0/24"
}
variable "restrict_to_vpc" {
description = "Activa bucket IP filtering para aceptar requests solo desde la VPC. Puede dejar afuera al pipeline si corre fuera de la VPC."
type = bool
default = false
}
variable "labels" {
description = "Labels aplicados a los recursos que los soportan."
type = map(string)
default = {}
}outputs.tf
output "bucket_url" {
description = "URL gs:// del bucket."
value = google_storage_bucket.ingest.url
}
output "producer_service_account_email" {
description = "Service Account que tiene que usar el workload productor."
value = google_service_account.producer.email
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 8.6"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}