Module 3: Infrastructure As Code For An Ai Endpoint

8. Project: Andes Cargo's AI infrastructure, declared

Description

The previous seven lessons built, piece by piece, the complete infrastructure for Andes Cargo's first generative AI workload: the mechanism that makes it possible to declare it with no real API (lesson 1), the landscape of available resources (lesson 2), the reusable module (lesson 3), the least-privilege role (lesson 4), the complete plan (lesson 5), and the exact boundary of what can actually be applied, with two different reasons for two different resources (lessons 6 and 7). This final project delivers the complete repository, bedrock.tf and modules/bedrock-guardrail/ in their final form, with the honesty ledger that closes the module with the most executed weight in this entire guide.

Connection to the module

This is the second ADR-formatted deliverable this guide produces — the first was ADR-001-llm-as-escalation-path.md —, but this time the deliverable is code, not a document: the HCL itself, actually validated and planned, is the evidence that Module 1's architecture decision already has real infrastructure behind it. Module 4 extends exactly these same files — never rewriting them — with the three remaining guardrail policies.


Step 1 — bedrock.tf, this module's final version

# bedrock.tf -- Andes Cargo's first generative AI workload. ADR-001 (Module 1)
# fixed the LLM as an escalation path, not the default; this file is the exact
# infrastructure that decision requires -- nothing more. Module 2 drafted a single
# aws_bedrock_guardrail resource here just to test Infracost against it (see
# GENAI-COST-PROFILE.md, section 6). Module 3 replaces that draft with the real
# module (modules/bedrock-guardrail/, lesson 3) and adds BedrockManifestExtractorRole,
# the least-privilege role extract-shipment-manifest-fields assumes to call
# bedrock:InvokeModel (lesson 4). Module 4 extends the guardrail with the remaining
# policies (topic, contextual grounding, word filters); this file does not build
# those yet.

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

  name                      = "andes-cargo-manifest-extractor-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"
    },
    {
      type   = "PHONE"
      action = "ANONYMIZE"
    }
  ]

  tags = local.common_tags
}

data "aws_iam_policy_document" "bedrock_manifest_extractor_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "bedrock_manifest_extractor_permissions" {
  statement {
    sid    = "InvokeManifestExtractionModelOnly"
    effect = "Allow"

    actions = [
      "bedrock:InvokeModel",
    ]

    # Scoped to exactly one model ARN -- never "bedrock:*", never a wildcard ARN:
    # Module 5, lesson 3 tests this exact statement against bedrock-least-privilege.rego.
    resources = [
      local.manifest_extractor_model_arn,
    ]
  }
}

module "bedrock_manifest_extractor_role" {
  source = "./modules/iam-role"

  role_name               = "BedrockManifestExtractorRole"
  trust_policy_json       = data.aws_iam_policy_document.bedrock_manifest_extractor_trust.json
  permissions_policy_json = data.aws_iam_policy_document.bedrock_manifest_extractor_permissions.json
  tags                    = local.common_tags
}

And the local that fixes the model's ARN, added to locals.tf alongside common_tags:

locals {
  common_tags = {
    Project     = "andes-cargo"
    Environment = var.environment
    ManagedBy   = "terraform"
  }

  # The exact Bedrock model BedrockManifestExtractorRole is allowed to invoke.
  # GENAI-COST-PROFILE.md (Module 2, section 2) chose Amazon Nova Lite. Scoping
  # bedrock:InvokeModel to this single ARN -- never "bedrock:*", never a wildcard
  # ARN -- is the least-privilege decision Module 3, lesson 4 builds.
  manifest_extractor_model_arn = "arn:aws:bedrock:${var.aws_region}::foundation-model/amazon.nova-lite-v1:0"
}

Step 2 — modules/bedrock-guardrail/, the complete mold

# modules/bedrock-guardrail/variables.tf

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     = {}
}
# modules/bedrock-guardrail/main.tf

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
}
# modules/bedrock-guardrail/outputs.tf

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
}

Step 3 — Verifying the repository, end to end

cd andes-cargo-infra/
terraform fmt -check -recursive
echo "fmt exit: $?"
terraform validate
terraform plan -input=false -no-color -out=tfplan-m3-final

What to expect (literal — run for real, with no LocalStack, no AWS account, in this environment, to close this module):

fmt exit: 0
Success! The configuration is valid.
Plan: 17 to add, 0 to change, 0 to destroy.

And, isolating just what this module added, with the same -target command from lesson 5:

terraform show -json tfplan-m3-final | python3 -c "
import json, sys
data = json.load(sys.stdin)
new = [rc['address'] for rc in data['resource_changes']
       if 'manifest_extractor' in rc['address']]
print(len(new), 'new resources from this module:')
for addr in sorted(new):
    print(' -', addr)
"

What to expect (literal):

3 new resources from this module:
 - module.bedrock_manifest_extractor_role.aws_iam_role.this
 - module.bedrock_manifest_extractor_role.aws_iam_role_policy.this
 - module.manifest_extractor_guardrail.aws_bedrock_guardrail.this

Step 4 — This module's honesty ledger

Just as ADR-001 (Module 1) and GENAI-COST-PROFILE.md (Module 2) closed their modules with an explicit document of what's what, this project closes with the same discipline, in a single table:

PieceCommandStatusExact reason
Provider landscapeterraform providers schema -jsonExecutedRuns locally against the already-installed provider; zero network
modules/bedrock-guardrail/terraform fmt / validate (isolated)ExecutedNew module, no dependency on LocalStack or an AWS account
BedrockManifestExtractorRoleterraform validate / plan -targetExecutedNew resource; plan never needs to read from the state
Complete bedrock.tfterraform plan (complete project)ExecutedPlan: 17 to add, 0 to change, 0 to destroy, with no network
BedrockManifestExtractorRoletflocal apply / awslocal iam get-role-policyRepresentativeIAM is indeed Hobby, but this sandbox has no LOCALSTACK_AUTH_TOKEN (environment limit, solvable with a real token)
aws_bedrock_guardrailtflocal applyRepresentativeBedrock is "Included in Plans: Ultimate" — no Hobby token resolves it, on any machine (service limit)

The distinction between the last two rows — both representative, for different reasons — is, precisely, lesson 7's entire argument: not every "representative" label in this guide means the same thing, and conflating them would hide real, useful information for anyone replicating this work on their own machine.


Common mistakes

Copying bedrock.tf and modules/bedrock-guardrail/ into a new project with no locals.tf or variables.tf (forgetting silent dependencies mistake). What happens: someone copies only this module's two new files, without local.manifest_extractor_model_arn, local.common_tags, or var.aws_region, and gets an undefined-reference error. How to spot it: terraform validate fails with Reference to undeclared local value or Reference to undeclared input variable. How to fix it: bedrock.tf depends on three pieces living in other files in the same project — locals.tf (the two locals used), variables.tf (var.aws_region, already with default = "us-east-1" since terraform-and-iac-guide) — copy them along with bedrock.tf, or declare their equivalents in the new project.

Presenting this project as "Andes Cargo's complete GenAI infrastructure" without clarifying the guardrail only has two of the five possible policies (undeclared incomplete-scope mistake). What happens: someone, in an interview or a README, describes this module's guardrail as "the application's complete guardrail." How to spot it: if your description of andes-cargo-manifest-extractor-guardrail doesn't mention denied topics, contextual grounding, and word filters are missing. How to fix it: bedrock.tf's header comment already says so explicitly — "Module 4 extends the guardrail with the remaining policies... this file does not build those yet" — repeat that same honesty when describing the work, the same discipline that carries this entire guide.

Not verifying Plan: 0 to change, 0 to destroy before considering the module closed (assuming "17 to add" is enough confirmation mistake). What happens: someone only checks the number of resources to add, without confirming nothing existing changed or got destroyed. How to spot it: if your check on this project stops at "I saw 17 to add" without reading the rest of the line. How to fix it: all three numbers — to add, to change, to destroy — matter together. 0 to destroy is confirmation that adding the AI infrastructure didn't put a single resource already built by the previous three guides at risk, not even by accident — exactly the additive promise this guide's design made from the start.


Exercises

Exercise 1 — Verify, yourself, that this lesson's honesty ledger matches exactly what this module's lessons 1 through 7 already demonstrated. Go through Step 4's table row by row and confirm which specific lesson backs each claim.

See solution

Row 1 (provider landscape) → lesson 2. Row 2 (modules/bedrock-guardrail/) → lesson 3. Row 3 (BedrockManifestExtractorRole, validate/plan) → lesson 4. Row 4 (complete bedrock.tf) → lesson 5. Row 5 (IAM role, representative due to the environment) → lessons 4 and 7. Row 6 (guardrail, representative due to the service) → lesson 6. If any row doesn't find its exact lesson, check that a new, unsupported claim wasn't introduced — the same traceability discipline ADR-001 and GENAI-COST-PROFILE.md already demanded of themselves.

Exercise 2 — Explain, to a hypothetical technical interviewer, why this module is described as "the most executed one in the guide" even though neither of bedrock.tf's two resources was actually applied in this environment. Answer in two or three sentences, without repeating the word "representative" more than once.

See solution

A complete answer sounds, roughly, like this: "'Executed' in this module doesn't refer to apply — it refers to fmt, validate, and plan, the three commands confirming the declared infrastructure is correct and complete, and all three ran for real, with literal output, on every piece of this module, without exception. A clean plan with seventeen resources and zero unexpected changes is a real signal of engineering quality, not a substitute for having created the resources — and this guide never pretends it is —; the limit on applying against Bedrock belongs to the service itself (only LocalStack's highest paid plan includes it), not a limitation of the code or of how this guide was written."

Exercise 3 — Predict what would change in this project if, in Module 4, the guardrail's three remaining policies (topics, contextual grounding, words) got added. Based on this lesson's modules/bedrock-guardrail/ structure, would bedrock.tf need to change, or only the module?

See solution

Both would need to change, but proportionally to what each already does. modules/bedrock-guardrail/variables.tf and main.tf would need three new variables (denied_topics, grounding_filters, word_filters, or equivalent names) and three new dynamic blocks, following exactly the same double-nesting pattern content_policy_config and sensitive_information_policy_config already establish — no change to what already works, only addition. bedrock.tf, for its part, would only need to pass the new arguments to the already-existing module "manifest_extractor_guardrail" call — neither the IAM role nor the file's structure would change. It's exactly the same kind of grow-without-breaking this module's lesson 3, Exercise 3 already anticipated.


Summary and next step

This Module 3 final project delivered bedrock.tf and modules/bedrock-guardrail/ in their complete form: a Bedrock guardrail with two active policies (content, sensitive information) declared through a reusable module with dynamic blocks, and BedrockManifestExtractorRole, with bedrock:InvokeModel scoped to a single model's ARN. You confirmed, again, clean fmt, successful validate, and Plan: 17 to add, 0 to change, 0 to destroy on the complete project — and closed with a six-row honesty ledger precisely distinguishing four pieces actually executed from two representative ones, each with its own exact reason, never a generic label.

Before moving on you should be able to: recite, from memory, this module's exact three resources added to andes-cargo-infra/; explain the difference between a representative limit "from the environment" and one "from the service," with an example of each taken from this same project; and defend, against any question, why "the most executed module in the guide" is an honest claim, not marketing exaggeration.

With bedrock.tf and modules/bedrock-guardrail/ declared, validated, and planned, the complete Module 3 — eight lessons, from the mechanism that makes validate/plan possible with no network to this project — is behind you. Module 4 picks back up exactly these same files and adds what's still missing: the guardrail's three remaining policies, and the question no validate can answer on its own — why a managed guardrail, however complete, is never, on its own, sufficient defense.

Resources

  1. terraform-and-iac-guide, Module 5, lesson 8 (08-project-andes-cargos-module-library.md) — the same module-closing project pattern, applied to Andes Cargo's first two modules.
  2. ADR-001-llm-as-escalation-path.md (Module 1, lesson 8 of this guide) — the source of the row assigning this module the responsibility of declaring the escalation path's infrastructure.
  3. Terraform Registry — aws_bedrock_guardrail — official documentation for this project's central resource.
  4. LocalStack Docs — Bedrock — the source for the honesty ledger's last row, verified again in this module's lesson 6.
  5. This module, lessons 1 through 7 — the complete source for every claim in Step 4's honesty ledger.