Módulo 3: Infrastructure As Code For An Ai Endpoint

3. Manos a la obra: el módulo `modules/bedrock-guardrail/`

Descripción

La lección 2 extrajo el schema real de aws_bedrock_guardrail. Esta lección lo convierte en el primer molde reusable de esta guía: modules/bedrock-guardrail/, mismo patrón exacto que modules/s3-bucket/ y modules/iam-role/ de terraform-and-iac-guide — tres archivos (main.tf, variables.tf, outputs.tf), inputs con valores por defecto seguros, validado de forma aislada antes de instalarse en el proyecto real. Todo lo que corre en esta lección corrió de verdad, en este entorno, mientras se escribía.

Conexión con el módulo

Este molde es la base literal del Módulo 4: implementa dos de las cinco políticas del schema (contenido, información sensible) como inputs de lista, dejando las otras tres (temas, grounding contextual, palabras) para que el Módulo 4, lección 3 las agregue sin tocar lo que ya funciona aquí — exactamente el mismo principio de crecimiento sin romper que terraform-and-iac-guide ya demostró con modules/s3-bucket/.


Paso 1 — modules/bedrock-guardrail/, archivo por archivo

Dentro de andes-cargo-infra/, crea la carpeta modules/bedrock-guardrail/ con los tres archivos de la estructura estándar.

modules/bedrock-guardrail/variables.tf — los inputs del molde:

variable "name" {
  description = "Name of the Bedrock guardrail."
  type        = string
}

variable "blocked_input_messaging" {
  description = "Message returned to the caller when an input is blocked by the guardrail."
  type        = string
}

variable "blocked_outputs_messaging" {
  description = "Message returned to the caller when a model output is blocked by the guardrail."
  type        = string
}

variable "content_filters" {
  description = "Content policy filters (e.g. PROMPT_ATTACK, HATE, SEXUAL). Each entry sets input/output detection strength."
  type = list(object({
    type            = string
    input_strength  = string
    output_strength = string
  }))
  default = []
}

variable "pii_entities" {
  description = "PII entity types to detect in the sensitive information policy, with the action to take on each (BLOCK or ANONYMIZE)."
  type = list(object({
    type   = string
    action = string
  }))
  default = []
}

variable "tags" {
  description = "Tags applied to the guardrail."
  type        = map(string)
  default     = {}
}

Tres cosas para entender, todas a propósito: 1) name, blocked_input_messaging y blocked_outputs_messaging son obligatorias —sin default—, exactamente porque el schema de la lección 2 las marca required a nivel del recurso mismo; ningún guardrail existe sin esos tres valores. 2) content_filters y pii_entities son listas de objetos tipados, con default = [] — el caso más simple ("sin ninguna política activa todavía") es una lista vacía, no null, porque el patrón dynamic del main.tf (Paso siguiente) necesita iterar sobre algo, aunque sea nada. 3) Cada objeto en esas listas usa exactamente los nombres de campo que el schema real exige dentro de filters_config y pii_entities_configtype, input_strength, output_strength para uno; type, action para el otro—, la misma disciplina de "el schema real, no inventado" que la lección 2 estableció.

modules/bedrock-guardrail/main.tf — el molde en sí:

resource "aws_bedrock_guardrail" "this" {
  name                      = var.name
  blocked_input_messaging   = var.blocked_input_messaging
  blocked_outputs_messaging = var.blocked_outputs_messaging

  dynamic "content_policy_config" {
    for_each = length(var.content_filters) > 0 ? [1] : []

    content {
      dynamic "filters_config" {
        for_each = var.content_filters

        content {
          type            = filters_config.value.type
          input_strength  = filters_config.value.input_strength
          output_strength = filters_config.value.output_strength
        }
      }
    }
  }

  dynamic "sensitive_information_policy_config" {
    for_each = length(var.pii_entities) > 0 ? [1] : []

    content {
      dynamic "pii_entities_config" {
        for_each = var.pii_entities

        content {
          type   = pii_entities_config.value.type
          action = pii_entities_config.value.action
        }
      }
    }
  }

  tags = var.tags
}

El patrón nuevo aquí, que no viste todavía en modules/s3-bucket/ ni modules/iam-role/, es el bloque dynamic anidado dos veces: un dynamic "content_policy_config" que existe solo si var.content_filters tiene al menos un elemento ([1] : [], el mismo truco de "crear o no crear" que ya conoces de count, aplicado a un bloque en vez de a un recurso completo), y dentro de ese bloque, un segundo dynamic "filters_config" que itera sobre cada filtro de la lista. Esto es exactamente lo que hace falta para traducir una lista de objetos de Terraform (var.content_filters) en múltiples bloques anidados repetidos dentro de un mismo recurso — algo que count o for_each a nivel de recurso completo no pueden hacer, porque aquí no se están creando varios recursos, sino varios bloques dentro del mismo recurso.

modules/bedrock-guardrail/outputs.tf — lo que el módulo expone:

output "guardrail_arn" {
  description = "ARN of the created guardrail."
  value       = aws_bedrock_guardrail.this.guardrail_arn
}

output "guardrail_id" {
  description = "ID of the created guardrail."
  value       = aws_bedrock_guardrail.this.guardrail_id
}

output "name" {
  description = "Name of the created guardrail."
  value       = aws_bedrock_guardrail.this.name
}

Paso 2 — Validando el molde de forma aislada

Exactamente como terraform-and-iac-guide Módulo 5, lección 6 hizo con modules/s3-bucket/: antes de instalar este módulo en andes-cargo-infra/, confírmalo en una carpeta de validación aparte, con su propio bloque de provider mínimo:

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region                      = "us-east-1"
  access_key                  = "test"
  secret_key                  = "test"
  s3_use_path_style           = true
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3 = "http://localhost:4566"
  }
}

module "smoke_test_guardrail" {
  source = "./modules/bedrock-guardrail"

  name                      = "andes-cargo-module-smoke-test-guardrail"
  blocked_input_messaging   = "This input is not allowed due to content policy violations."
  blocked_outputs_messaging = "This output is not allowed due to content policy violations."

  content_filters = [
    {
      type            = "PROMPT_ATTACK"
      input_strength  = "HIGH"
      output_strength = "NONE"
    }
  ]

  pii_entities = [
    {
      type   = "EMAIL"
      action = "ANONYMIZE"
    }
  ]

  tags = {
    Project     = "andes-cargo"
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}
terraform init -input=false

Qué esperar (literal — ejecutado en este entorno mientras se escribía esta lección):

Initializing the backend...

Initializing modules...
- smoke_test_guardrail in modules/bedrock-guardrail

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.0"...
- Installing hashicorp/aws v6.60.0...
- Installed hashicorp/aws v6.60.0 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

Paso 3 — terraform fmt, encontrando un desalineamiento real

terraform fmt -check -recursive -diff

Qué esperar (literal — la primera corrida de esta lección, con el archivo tal como se escribió arriba):

main.tf
--- old/main.tf
+++ new/main.tf
@@ -26,9 +26,9 @@
 module "smoke_test_guardrail" {
   source = "./modules/bedrock-guardrail"

-  name                       = "andes-cargo-module-smoke-test-guardrail"
-  blocked_input_messaging    = "This input is not allowed due to content policy violations."
-  blocked_outputs_messaging  = "This output is not allowed due to content policy violations."
+  name                      = "andes-cargo-module-smoke-test-guardrail"
+  blocked_input_messaging   = "This input is not allowed due to content policy violations."
+  blocked_outputs_messaging = "This output is not allowed due to content policy violations."

   content_filters = [
     {

Este no es un archivo inventado a propósito para mostrar el comando — es el desalineamiento real que produjo escribir el main.tf de la lección con espacios contados a mano en vez de dejar que fmt los calcule. terraform fmt -check devuelve código de salida 3 cuando encuentra diferencias (confírmalo con echo $? después del comando) — el mismo patrón de código de salida distinto de cero para "hay trabajo pendiente" que ya viste en otras herramientas de este ecosistema.

terraform fmt -recursive
terraform fmt -check -recursive
echo "exit: $?"

Qué esperar (literal):

main.tf
exit: 0

La primera línea confirma qué archivo fmt reescribió; la segunda corrida —ahora sin -diff, solo -check— no imprime nada porque ya no hay diferencias, y el código de salida 0 lo confirma.


Paso 4 — terraform validate, y el plan completo del módulo aislado

terraform validate

Qué esperar (literal):

Success! The configuration is valid.
terraform plan -input=false -no-color

Qué esperar (literal — corrido de verdad, sin LocalStack corriendo, sin cuenta AWS):

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.smoke_test_guardrail.aws_bedrock_guardrail.this will be created
  + resource "aws_bedrock_guardrail" "this" {
      + blocked_input_messaging   = "This input is not allowed due to content policy violations."
      + blocked_outputs_messaging = "This output is not allowed due to content policy violations."
      + created_at                = (known after apply)
      + description               = (known after apply)
      + guardrail_arn             = (known after apply)
      + guardrail_id              = (known after apply)
      + name                      = "andes-cargo-module-smoke-test-guardrail"
      + region                    = "us-east-1"
      + status                    = (known after apply)
      + tags                      = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
      + tags_all                  = {
          + "Environment" = "dev"
          + "ManagedBy"   = "terraform"
          + "Project"     = "andes-cargo"
        }
      + updated_at                = (known after apply)
      + version                   = (known after apply)

      + content_policy_config {
          + tier_config = (known after apply)

          + filters_config {
              + input_strength  = "HIGH"
              + output_strength = "NONE"
              + type            = "PROMPT_ATTACK"
            }
        }

      + sensitive_information_policy_config {
          + pii_entities_config {
              + action         = "ANONYMIZE"
              + input_action   = (known after apply)
              + input_enabled  = (known after apply)
              + output_action  = (known after apply)
              + output_enabled = (known after apply)
              + type           = "EMAIL"
            }
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Plan: 1 to add — un solo recurso, con las dos políticas activas mostradas exactamente como el patrón dynamic del main.tf las tradujo: content_policy_config.filters_config con el filtro de PROMPT_ATTACK, sensitive_information_policy_config.pii_entities_config con la entidad EMAIL. Nota que input_action, input_enabled, output_action y output_enabled de pii_entities_config aparecen como (known after apply): el módulo no los declaró (son optional, computed en el schema de la lección 2), así que AWS los completa con sus valores por defecto reales solo cuando el recurso se crea de verdad — otra confirmación de que este plan, aunque completo, nunca necesitó consultar nada fuera del HCL declarado.


Paso 5 — Instalando el módulo en andes-cargo-infra/

Con el molde ya probado, la lección 4 lo instala en el proyecto real, dentro de bedrock.tf, junto al rol IAM de mínimo privilegio. Antes de eso, confirma que el módulo mismo —copiado tal cual, sin ningún cambio— también pasa fmt y validate dentro del proyecto completo:

cd andes-cargo-infra/
terraform fmt -check -recursive
terraform validate

Qué esperar (literal):

Success! The configuration is valid.

Sin salida de fmt -check (código de salida 0) — el módulo, ya formateado en el Paso 3, entra al proyecto real sin necesitar ningún ajuste adicional.


Errores comunes

Olvidar el default = [] en content_filters/pii_entities, y forzar a cada llamada del módulo a declarar ambas listas aunque no las necesite (de diseño de input rígido). Qué pasa: alguien quita el default de una de las dos variables, pensando que "siempre hace falta declarar algo". Cómo detectarlo: si un terraform plan que solo necesita la política de contenido (sin PII) falla con The argument "pii_entities" is required, but no definition was found. Cómo corregirlo: el patrón correcto, ya usado por modules/s3-bucket/ con bucket_policy_json = null y por este módulo con default = [], es que cada política sea verdaderamente opcional — el caso más simple posible (ninguna lista declarada) debe funcionar sin error.

Usar count en vez de dynamic para repetir filters_config dentro del mismo content_policy_config (de confusión entre "repetir un recurso" y "repetir un bloque"). Qué pasa: alguien, familiarizado con el patrón count = var.algo ? 1 : 0 de modules/s3-bucket/, intenta aplicar count directamente dentro de un bloque anidado. Cómo detectarlo: un error de Terraform del estilo Blocks of type "filters_config" are not expected here o una sintaxis que ni siquiera compila. Cómo corregirlo: count y for_each a nivel de recurso crean o no crean recursos completos (instancias separadas en el state, cada una con su propio índice); dynamic crea o no crea bloques dentro de un mismo recurso — son mecanismos distintos para problemas distintos, y filters_config repetido varias veces dentro de un solo aws_bedrock_guardrail es, precisamente, el segundo caso.

Pasar un type de filtro con un valor que no existe en el catálogo real de Bedrock (PROMPT_ATTACK, HATE, SEXUAL, VIOLENCE, INSULTS, MISCONDUCT) y esperar que terraform validate lo detecte (de expectativa sobre lo que valida un schema). Qué pasa: alguien escribe type = "SPAM" —un valor que no es parte del catálogo real de tipos de filtro de contenido de Bedrock— y espera que terraform validate lo rechace. Cómo detectarlo: validate pasa sin quejarse, porque type es, en el schema del provider, simplemente string — no hay una lista cerrada de valores válidos codificada ahí. Cómo corregirlo: terraform validate confirma tipos (¿es un string?, ¿está el bloque bien anidado?), no valores de negocio válidos para la API real de Bedrock. Un type inválido pasaría validate y plan sin error, y solo fallaría en un apply real contra la API de Bedrock (fuera del alcance de este laboratorio $0) — la misma distinción que la lección 1 de este módulo ya estableció entre "sintaxis correcta" y "funciona de verdad".


Ejercicios

Ejercicio 1 — Explica, sin mirar el main.tf, para qué sirve el [1] : [] en el for_each del dynamic "content_policy_config". Un compañero, viendo el código por primera vez, pregunta por qué no simplemente se usa for_each = var.content_filters directamente en el bloque exterior.

Ver solución

Porque content_policy_config y content_filters no tienen la misma cardinalidad: content_policy_config es un bloque que existe como máximo una vez por guardrail (es donde se agrupan todos los filtros de contenido), mientras que filters_config, adentro, se repite una vez por cada filtro de la lista content_filters. El for_each = length(var.content_filters) > 0 ? [1] : [] en el bloque exterior responde a una pregunta binaria —¿hay al menos un filtro que declarar?— y produce, como máximo, una sola instancia de content_policy_config. El for_each = var.content_filters del dynamic interior sí itera de verdad sobre la lista completa, una vez por filtro, dentro de ese único bloque exterior.

Ejercicio 2 — Predice el resultado de un terraform plan con content_filters = [] y pii_entities = [] (ambas listas vacías, los valores por defecto). ¿Cuántos bloques de política aparecerían en el aws_bedrock_guardrail planeado?

Ver solución

Cero. Con ambas listas vacías, la condición length(var.content_filters) > 0 y su equivalente para PII evalúan a false, así que ambos for_each exteriores producen [] — ningún content_policy_config ni sensitive_information_policy_config se declara en absoluto. El resultado sería un aws_bedrock_guardrail válido (los tres argumentos required a nivel de recurso —name, los dos mensajes de bloqueo— seguirían presentes), pero sin ninguna política de contenido ni de información sensible activa — un guardrail que existe pero no filtra nada, el mismo caso ya nombrado en el Ejercicio 3 de la lección 2.

Ejercicio 3 — Decide si este módulo, tal como está, sirve para declarar la política de temas denegados (topic_policy_config) que el Módulo 4 necesita. Sin escribir código nuevo, ¿el módulo actual la soporta, necesita una extensión menor, o necesita reescribirse?

Ver solución

Necesita una extensión menor, siguiendo exactamente el mismo patrón ya establecido. El módulo actual no tiene ninguna variable ni bloque dynamic para topic_policy_config — agregarlo significa una nueva variable "denied_topics" (lista de objetos con name, definition, type, y opcionalmente examples, siguiendo el schema de la lección 2), más un dynamic "topic_policy_config" en main.tf con la misma estructura de doble anidamiento (for_each condicional exterior, for_each sobre la lista interior) que ya usan content_policy_config y sensitive_information_policy_config. Ninguna de las dos políticas ya construidas necesitaría cambiar — exactamente la ventaja de haber invertido en la estructura dynamic correcta desde esta lección, la misma que ya viste con modules/s3-bucket/ en terraform-and-iac-guide.


Resumen y siguiente paso

En esta lección construiste modules/bedrock-guardrail/: un molde con dos políticas de guardrail (contenido, información sensible) como inputs de lista, usando bloques dynamic anidados dos veces para traducir esas listas en múltiples bloques repetidos dentro de un mismo recurso — el primer patrón nuevo de Terraform que esta guía necesitó, más allá de lo que terraform-and-iac-guide ya cubrió. Validaste el módulo de forma aislada, con fmt (encontrando y corrigiendo un desalineamiento real), validate y plan —los tres corridos de verdad, sin LocalStack, con un resultado literal de Plan: 1 to add—.

Antes de avanzar deberías poder: explicar la diferencia entre dynamic y count/for_each a nivel de recurso; escribir de memoria el patrón for_each = length(var.algo) > 0 ? [1] : [] para un bloque condicional; y explicar por qué terraform validate no detecta un valor de type inválido para la API real de Bedrock.

La lección 4 construye la segunda pieza de este módulo: BedrockManifestExtractorRole, el rol IAM de mínimo privilegio que le da a extract-shipment-manifest-fields permiso para invocar exactamente un modelo, nunca el servicio completo.

Recursos

  1. Terraform Language Docs — dynamic blocks — referencia oficial del mecanismo central de esta lección.
  2. Terraform Registry — aws_bedrock_guardrail — el recurso base del módulo, mismo schema citado en la lección 2.
  3. terraform-and-iac-guide, Módulo 5, lección 6 (06-hands-on-building-an-s3-bucket-module.md) — el mismo patrón de validación aislada que esta lección sigue.
  4. Terraform Docs — Command: fmt — referencia oficial, incluidos los códigos de salida de -check.