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
AWS
IAM Role (ECS task role)
s3:PutObject acotado a bucket/incoming/*
Venís de Azure: allá este rol es User-assigned Managed Identity.
Venís de GCP: allá este rol es Service Account adjunta al workload.
Sin letra chica 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
AWS
Gateway VPC Endpoint para S3≈ Equivalente parcial
asociado a la route table privada
Es una ruta en la route table, no una IP privada: no llega desde on-premises ni desde otra región (para eso, Interface Endpoint).
Venís de Azure: allá este rol es Private Endpoint (subresource blob). No es un Private Endpoint: es una entrada gratis en la route table que no llega desde on-premises ni desde otra región. El equivalente real es un Interface Endpoint, que se cobra por hora.
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
AWS
S3 bucket
Block Public Access + BucketOwnerEnforced + SSE-KMS
Venís de Azure: allá este rol es Storage Account (StorageV2) + container.
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
AWS
S3 Lifecycle rule
Expiration.Days + AbortIncompleteMultipartUpload
Venís de Azure: allá este rol es Lifecycle management policy. El filtro es solo el prefijo dentro del bucket, y el cobro se corta en la fecha de expiración aunque el borrado físico tarde días.
Venís de GCP: allá este rol es Object Lifecycle Management.
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
AWS
Bucket policy
Deny si aws:SourceVpce ≠ endpoint
Venís de Azure: allá este rol es Storage firewall.
Venís de GCP: allá este rol es Bucket IP filtering (opcional).
1 gotcha 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
Amazon Web Services
Implementación
- Identidad sin secretos
- IAM Role (ECS task role)s3:PutObject acotado a bucket/incoming/*Venís de Azure: allá este rol es User-assigned Managed Identity.Venís de GCP: allá este rol es Service Account adjunta al workload.
- Acceso privado
- Gateway VPC Endpoint para S3≈asociado a la route table privadaEs una ruta en la route table, no una IP privada: no llega desde on-premises ni desde otra región (para eso, Interface Endpoint).Venís de Azure: allá este rol es Private Endpoint (subresource blob). No es un Private Endpoint: es una entrada gratis en la route table que no llega desde on-premises ni desde otra región. El equivalente real es un Interface Endpoint, que se cobra por hora.Venís de GCP: allá este rol es Private Google Access.
- Almacenamiento de objetos
- S3 bucketBlock Public Access + BucketOwnerEnforced + SSE-KMSVenís de Azure: allá este rol es Storage Account (StorageV2) + container.Venís de GCP: allá este rol es Cloud Storage bucket.
- Política de expiración
- S3 Lifecycle ruleExpiration.Days + AbortIncompleteMultipartUploadVenís de Azure: allá este rol es Lifecycle management policy. El filtro es solo el prefijo dentro del bucket, y el cobro se corta en la fecha de expiración aunque el borrado físico tarde días.Venís de GCP: allá este rol es Object Lifecycle Management.
- Perímetro de datos
- Bucket policyDeny si aws:SourceVpce ≠ endpointVenís de Azure: allá este rol es Storage firewall.Venís de GCP: allá este rol es Bucket IP filtering (opcional).
Modelo de costo
Escala a cero
Sin costo fijo
Sin costo fijo: el Gateway Endpoint es gratis y la clave KMS administrada por AWS (aws/s3) no tiene cargo mensual; Bucket Keys reduce las llamadas a KMS que sí se cobran por request.
Piso mensual sin tráfico
USD 0/ mes
USD 0: no hay componentes con costo fijo.
Letra chica · 6 gotchas
Block Public Access y ACLs deshabilitadas son default en buckets nuevos (pero declaralos igual)
Por default, Object Ownership es BucketOwnerEnforced y todas las ACLs están deshabilitadas. AWS anunció que, a partir de abril de 2023, S3 habilitaría Block Public Access y deshabilitaría las ACLs en todos los buckets nuevos, sin cambios para los existentes. Igual declaralos en IaC: declarar el estado evita drift si alguien los toca desde la consola.
Un Deny por aws:SourceVpce también te deja afuera a vos
Un Deny explícito gana sobre cualquier Allow, incluido el del admin. Si la policy niega todo s3:* fuera del endpoint, la consola y el pipeline de CI (que no están en la VPC) pierden acceso, y podés terminar sin poder editar la propia bucket policy. Este módulo limita el Deny a operaciones de objetos y permite excluir principals de break-glass con aws:PrincipalArn.
El Gateway Endpoint es gratis, pero solo sirve dentro de la VPC y la región
Es una entrada en la route table, sin costo por hora ni por GB, y por eso es la opción por defecto para S3. Pero no es alcanzable desde on-premises (VPN/Direct Connect) ni desde otra región. Para esos casos necesitás un Interface Endpoint, que sí se cobra por hora por AZ y es el equivalente real de Private Endpoint en Azure.
La expiración es asincrónica, pero el cobro se corta en la fecha de expiración
S3 encola las expiraciones y las procesa de forma asincrónica: puede pasar desde días hasta semanas entre la fecha de expiración y el borrado real. No se cobra la expiración ni el tiempo de storage de un objeto que ya venció.
En un bucket versionado, Expiration no borra nada
Con versioning habilitado, la acción Expiration solo agrega un delete marker y la versión pasa a noncurrent, que se sigue cobrando. Necesitás además NoncurrentVersionExpiration (y limpiar delete markers huérfanos). Este patrón deja el bucket sin versioning a propósito: el TTL es la protección que se busca.
Los multipart uploads incompletos se siguen cobrando
S3 retiene las partes de un multipart upload hasta que lo completás o lo abortás, y se cobra su storage durante todo ese tiempo. Agregá siempre una regla AbortIncompleteMultipartUpload: abortar el upload borra las partes.
Terraform
main.tf
# Ingesta privada de objetos con expiración (TTL) — AWS
#
# Bucket S3 privado, cifrado con SSE-KMS, accesible por Gateway Endpoint,
# con escritura acotada a un prefijo y expiración a los N días.
# --- Red ----------------------------------------------------------------------
resource "aws_vpc" "this" {
#checkov:skip=CKV2_AWS_11:Flow logs fuera de alcance del patrón (se cubren en el patrón de networking base).
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "vpc-${var.name}" }
}
# Sin reglas: nada usa el SG default (CIS 5.4).
resource "aws_default_security_group" "this" {
vpc_id = aws_vpc.this.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
tags = { Name = "rt-${var.name}-private" }
}
# Gateway endpoint: entrada en la route table, sin costo por hora ni por GB
# (gotcha: gateway-endpoint-scope).
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
tags = { Name = "vpce-${var.name}-s3" }
}
# --- Bucket -------------------------------------------------------------------
resource "aws_s3_bucket" "ingest" {
#checkov:skip=CKV_AWS_21:Sin versioning a propósito: con versioning, Expiration solo agrega un delete marker (gotcha: versioning-delete-marker).
#checkov:skip=CKV_AWS_144:Replicación cross-region no aplica a datos efímeros con TTL.
#checkov:skip=CKV_AWS_18:Access logging fuera de alcance; usar CloudTrail data events si se requiere auditoría.
#checkov:skip=CKV2_AWS_62:Notificaciones de eventos fuera de alcance del patrón.
bucket = var.bucket_name
force_destroy = false
}
# Default desde abril 2023 en buckets nuevos; se declara para evitar drift
# (gotcha: secure-defaults-since-2023).
resource "aws_s3_bucket_public_access_block" "ingest" {
bucket = aws_s3_bucket.ingest.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "ingest" {
bucket = aws_s3_bucket.ingest.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "ingest" {
#checkov:skip=CKV2_AWS_67:Clave administrada por AWS (aws/s3): sin rotación manual que gestionar ni costo mensual.
bucket = aws_s3_bucket.ingest.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
# Bucket Keys: menos llamadas a KMS (que se cobran por request).
bucket_key_enabled = true
}
}
# --- Expiración (TTL) ---------------------------------------------------------
resource "aws_s3_bucket_lifecycle_configuration" "ingest" {
bucket = aws_s3_bucket.ingest.id
rule {
id = "expire-incoming"
status = "Enabled"
filter {
prefix = var.object_prefix
}
expiration {
days = var.ttl_days
}
}
# Las partes huérfanas se cobran y no se listan (gotcha: abort-incomplete-multipart).
rule {
id = "abort-incomplete-multipart"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
}
# --- Perímetro: solo TLS y solo desde el VPC endpoint -------------------------
locals {
object_arns = "${aws_s3_bucket.ingest.arn}/*"
# Excluye principals de break-glass del Deny por endpoint. Si la lista está vacía
# no se agrega la condición (un ArnNotLike vacío no excluye a nadie).
vpce_deny_condition = merge(
{ StringNotEquals = { "aws:SourceVpce" = aws_vpc_endpoint.s3.id } },
length(var.break_glass_principal_arns) > 0 ? { ArnNotLike = { "aws:PrincipalArn" = var.break_glass_principal_arns } } : {},
)
}
resource "aws_s3_bucket_policy" "ingest" {
bucket = aws_s3_bucket.ingest.id
# La public access block tiene que existir antes que la policy.
depends_on = [aws_s3_bucket_public_access_block.ingest]
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [aws_s3_bucket.ingest.arn, local.object_arns]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
},
{
# Solo operaciones de objetos: la configuración del bucket sigue siendo
# gestionable desde fuera de la VPC (gotcha: sourcevpce-lockout).
Sid = "DenyObjectAccessOutsideVpce"
Effect = "Deny"
Principal = "*"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
Resource = local.object_arns
Condition = local.vpce_deny_condition
},
]
})
}
# --- Identidad del productor --------------------------------------------------
resource "aws_iam_role" "producer" {
name = "${var.name}-producer"
# Ejemplo con ECS: cambiá el principal según dónde corra el workload
# (ec2.amazonaws.com, lambda.amazonaws.com, o un OIDC provider para EKS).
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "producer_put" {
name = "put-incoming-objects"
role = aws_iam_role.producer.id
# Acotado al prefijo: el equivalente de Azure necesita ABAC (gotcha: rbac-scope-is-container).
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = "${aws_s3_bucket.ingest.arn}/${var.object_prefix}*"
}]
})
}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 "bucket_name" {
description = "Nombre global único del bucket S3."
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 o guiones."
}
}
variable "region" {
description = "Región de AWS."
type = string
default = "us-east-1"
}
variable "ttl_days" {
description = "Días desde la creación tras los cuales expira 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 las keys a las que aplica el TTL (en S3 el prefijo NO incluye el bucket)."
type = string
default = "incoming/"
}
variable "vpc_cidr" {
description = "CIDR de la VPC del workload."
type = string
default = "10.20.0.0/16"
}
variable "break_glass_principal_arns" {
description = "ARNs (admite comodines) excluidos del Deny por VPC endpoint: consola, CI. Gotcha: sourcevpce-lockout."
type = list(string)
default = []
}
variable "tags" {
description = "Tags aplicados a todos los recursos."
type = map(string)
default = {}
}outputs.tf
output "bucket_arn" {
description = "ARN del bucket."
value = aws_s3_bucket.ingest.arn
}
output "vpc_endpoint_id" {
description = "ID del Gateway Endpoint (usado en la condición aws:SourceVpce)."
value = aws_vpc_endpoint.s3.id
}
output "producer_role_arn" {
description = "ARN del rol que tiene que asumir el workload productor."
value = aws_iam_role.producer.arn
}versions.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.67"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = var.tags
}
}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
}