Module 3: Infrastructure As Code For An Ai Endpoint

5. Hands-on: `terraform plan` of the complete AI infrastructure

Description

Lessons 3 and 4 built two pieces separately: the bedrock-guardrail module and BedrockManifestExtractorRole. This lesson combines them, inside bedrock.tf, and runs terraform plan against the complete andes-cargo-infra/ project — everything the previous three guides in this ecosystem already built, plus this module's two new pieces. The result is real, executed in this environment, with no LocalStack running, no AWS account.

Connection to the module

This is the lesson that literally fulfills lesson 1's promise: a complete AI infrastructure plan, with a real number of resources to create, with no network endpoint responding. It's also this module's last lesson before lesson 6 marks, with the same honesty, exactly where that same plan stops being able to turn into a real apply in this lab.


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

Module 2, lesson 5's draft — a single aws_bedrock_guardrail with one filter, written only to test Infracost against it — gets replaced by the complete version lessons 3 and 4 built:

# 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",
    ]

    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
}

Notice this bedrock.tf added a second PII entity (PHONE, in addition to EMAIL) compared to lesson 3's isolated example — the complete list of two entities Andes Cargo actually needs to protect in a real free-text manifest, not just a minimal example's list.


Step 2 — terraform fmt and terraform validate, on the complete project

cd andes-cargo-infra/
terraform fmt -check -recursive
echo "exit: $?"
terraform validate

What to expect (literal — executed in this environment, on the complete andes-cargo-infra/):

exit: 0
Success! The configuration is valid.

No output from fmt -check — the entire project, including the files inherited from the previous three guides and this module's two new ones, is already correctly formatted. validate confirms, again, that all the HCL — old and new — is syntactically correct and matches both installed providers' schemas.


Step 3 — terraform plan, the complete project

terraform init -input=false
terraform plan -input=false -no-color -out=tfplan

What to expect (literal — executed in this environment, with no LocalStack running, no AWS account):

Initializing modules...
- bedrock_manifest_extractor_role in modules/iam-role
- manifest_extractor_guardrail in modules/bedrock-guardrail

Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Reusing previous version of hashicorp/archive from the dependency lock file
- Using previously-installed hashicorp/aws v6.60.0
- Using previously-installed hashicorp/archive v2.8.0

Terraform has been successfully initialized!

The complete terraform plan block is long — seventeen resources, counting everything inherited from terraform-and-iac-guide, aws-serverless-and-containers-guide, and cloud-security-and-guardrails-guide. This lesson shows the summary and the two complete new blocks; the rest are the same resources already confirmed, unchanged, in those guides' previous modules:

  # module.bedrock_manifest_extractor_role.aws_iam_role.this will be created
  + resource "aws_iam_role" "this" {
      + name = "BedrockManifestExtractorRole"
      + assume_role_policy = jsonencode({
          + Statement = [{
              + Action    = "sts:AssumeRole"
              + Effect    = "Allow"
              + Principal = { Service = "lambda.amazonaws.com" }
            }]
          + Version = "2012-10-17"
        })
      ...
    }

  # module.bedrock_manifest_extractor_role.aws_iam_role_policy.this will be created
  + resource "aws_iam_role_policy" "this" {
      + name = "BedrockManifestExtractorRole-policy"
      + policy = jsonencode({
          + Statement = [{
              + Action   = "bedrock:InvokeModel"
              + Effect   = "Allow"
              + Resource = "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-lite-v1:0"
              + Sid      = "InvokeManifestExtractionModelOnly"
            }]
          + Version = "2012-10-17"
        })
      ...
    }

  # module.manifest_extractor_guardrail.aws_bedrock_guardrail.this will be created
  + resource "aws_bedrock_guardrail" "this" {
      + 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."
      + region                    = "us-east-1"
      ...

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

      + sensitive_information_policy_config {
          + pii_entities_config {
              + action = "ANONYMIZE"
              + type   = "EMAIL"
            }
          + pii_entities_config {
              + action = "ANONYMIZE"
              + type   = "PHONE"
            }
        }
    }

  # (14 additional resources, inherited from terraform-and-iac-guide, aws-serverless-
  #  and-containers-guide and cloud-security-and-guardrails-guide, unchanged in
  #  this module: aws_dynamodb_table.shipments, aws_lambda_function.process_shipment_manifest,
  #  aws_s3_bucket.this, module.shipment_docs_bucket.*, module.lambda_manifest_processor_role.*,
  #  module.app_server_role.*, module.github_oidc.*, aws_s3_bucket_notification.shipment_docs_trigger,
  #  aws_lambda_permission.allow_s3_invoke, data.aws_iam_policy_document.lambda_dynamodb_write)

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

Changes to Outputs:
  + bedrock_manifest_extractor_role_arn = (known after apply)
  + bucket_name_in_use                  = "andes-cargo-shipment-docs"
  + environment_in_use                  = "dev"
  + manifest_extractor_guardrail_arn    = (known after apply)
  + table_name_in_use                   = "Shipments"

Saved the plan to: tfplan

Plan: 17 to add, 0 to change, 0 to destroy. — seventeen, not two or three: the entire project, not just this module's new infrastructure. It's the exact same number you'd see, on your own machine, running these same commands against the same HCL, with no LocalStack running — direct proof this plan never depended, at any point, on a network connection to AWS or LocalStack.


Step 4 — Isolating just what's new, with -target

Seventeen resources is a useful plan to confirm nothing broke, but it mixes this module's new pieces with what's already built. To see, cleanly, exactly how many resources this specific module adds, use -target — the same flag lesson 4 already introduced, now on both pieces at once:

terraform plan -input=false -no-color \
  -target=module.manifest_extractor_guardrail \
  -target=module.bedrock_manifest_extractor_role \
  -out=tfplan-ai-only

What to expect (literal — executed in this environment):

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

Changes to Outputs:
  + bedrock_manifest_extractor_role_arn = (known after apply)
  + manifest_extractor_guardrail_arn    = (known after apply)

Warning: Resource targeting is in effect

You are creating a plan with the -target option, which means that the result
of this plan may not represent all of the changes requested by the current
configuration.

The -target option is not for routine use, and is provided only for
exceptional situations such as recovering from errors or mistakes, or when
Terraform specifically suggests to use it as part of an error message.

Exactly three resources — verify it yourself, with terraform show -json and a filter listing each one by its full address:

terraform show -json tfplan-ai-only | python3 -c "
import json, sys
data = json.load(sys.stdin)
for rc in data['resource_changes']:
    print(rc['address'], '->', rc['change']['actions'])
"

What to expect (literal):

module.bedrock_manifest_extractor_role.aws_iam_role.this -> ['create']
module.bedrock_manifest_extractor_role.aws_iam_role_policy.this -> ['create']
module.manifest_extractor_guardrail.aws_bedrock_guardrail.this -> ['create']

One resource of each type, each with exactly one action (create) — literally "1 to add" per resource, three times, adding up to the Plan: 3 to add above. Notice Terraform's warning about -target: it's real, and worth taking seriously outside a learning context — -target is a diagnostic tool, useful here to isolate exactly what this module adds, but not the normal workflow with this project. Step 3's plan with no -target — the full seventeen resources — is the one that would actually get applied in a real CI/CD flow, the same one cicd-and-gitops-on-aws-guide already built.


Common mistakes

Using -target as the normal workflow, instead of a one-off diagnostic tool (bad-habit-picked-up-in-this-lesson mistake). What happens: someone, after seeing how convenient isolating "just what's new" with -target is in this lesson, starts using it as routine for every andes-cargo-infra/ plan. How to spot it: if your habitual workflow with this project always includes one or more -target flags. How to fix it: Terraform itself warns about it in this lesson's output — "not for routine use." Using it as a habit hides real changes other project resources might need (for example, if something depended on a new output), exactly the risk the warning names. Reserve it for one-off diagnostics, as in this lesson; the real application flow goes through the complete plan, with no -target, reviewed in a pull request — the pattern cicd-and-gitops-on-aws-guide already established.

Interpreting Plan: 17 to add as if this lesson had created seventeen new resources (not distinguishing "inherited" from "new" mistake). What happens: someone, seeing the number 17, assumes this module built seventeen pieces of infrastructure. How to spot it: if your summary of this module says "we created 17 resources." How to fix it: this lesson's Step 4 exists exactly for this distinction — only three of those seventeen are new to this module (the guardrail, the role, its inline policy); the other fourteen already existed, unchanged, from the previous three sibling guides. Step 3's complete plan confirms nothing inherited broke when bedrock.tf got added; Step 4's isolated plan confirms exactly how much this specific module added.

Running terraform apply (with no -target, on Step 3's complete tfplan) expecting all seventeen resources to actually get created in this environment (forward-looking expectation mistake, without having read lesson 6 yet). What happens: someone, encouraged by the clean plan, tries applying immediately. How to spot it: if your next step, skipping lesson 6, is terraform apply tfplan. How to fix it: a clean plan confirms the HCL is correct and Terraform knows exactly what to create — it never confirms the environment where it would apply is available. Lesson 6 documents, precisely and with an official citation, exactly why that apply wouldn't finish in this specific lab, for the Bedrock resource in particular.


Exercises

Exercise 1 — Explain, without running any command, why Plan: 17 to add and Plan: 3 to add (with -target) don't contradict each other. A colleague, seeing both numbers in the same lesson, asks which one is "the real one." Answer them.

See solution

They don't contradict each other because they answer different questions. Plan: 17 to add, with no -target, answers "how many resources does the complete andes-cargo-infra/, including the entire history of the previous guides, need to actually exist?" Plan: 3 to add, with -target limited to the two new modules, answers "how many resources does this module's AI infrastructure specifically add?" Both numbers are correct, simultaneously, because they measure different scopes of the same project — the second is an exact subset of the first, not an alternative result.

Exercise 2 — Predict what would happen if you ran terraform plan -target=module.manifest_extractor_guardrail (without the role's second -target). How many resources would it show, and why that number, not another?

See solution

It would show Plan: 1 to add — only module.manifest_extractor_guardrail.aws_bedrock_guardrail.this. -target limits the plan exactly to the named resource or module (and anything that resource depends on, which in this case is no other new piece); without the second -target for module.bedrock_manifest_extractor_role, that role and its inline policy simply don't show up in the plan, neither as something to create nor as something skipped with an error — Terraform treats them as if they didn't exist in this specific run.

Exercise 3 — Verify, with terraform show -json and your own filter, that none of Step 3's 14 "inherited" resources shows up marked with the update or delete action. Using Step 4's same Python pattern but on tfplan (the complete one, with no -target), confirm the only actions present are create.

See solution
terraform show -json tfplan | python3 -c "
import json, sys
data = json.load(sys.stdin)
actions = set()
for rc in data['resource_changes']:
    actions.update(rc['change']['actions'])
print(sorted(actions))
"

The expected result is ['create'] — a single action type present across the entire plan, for all seventeen resources. This confirms, programmatically (not just by reading the 0 to change, 0 to destroy summary), that adding bedrock.tf didn't modify or threaten to destroy any resource inherited from the previous three guides — the exact definition of an additive extension, the same promise this guide's design made from the start.


Summary and next step

This lesson ran terraform plan against the complete andes-cargo-infra/, with lessons 3 and 4's AI infrastructure already integrated into bedrock.tf: Plan: 17 to add, 0 to change, 0 to destroy, executed for real, with no LocalStack, no AWS account. You isolated, with -target, exactly this module's three new resources (Plan: 3 to add), and verified, with terraform show -json, that none of the inherited resources changed state. Everything you see in this lesson is literal — real output from real commands, run while writing it.

Before moving on you should be able to: explain the difference between the complete plan and one isolated with -target; name the exact three resources this module adds; and explain why a clean plan never confirms, on its own, that a real apply is going to finish.

Lesson 6 marks, with the same precision and an official citation, exactly where this same plan stops being able to turn into a real apply in this lab — the only step in this module that doesn't run for real here.

Resources

  1. Terraform Docs — Command: plan — official reference for -target, including the warning cited in this lesson.
  2. Terraform Docs — JSON Output Format — official reference for the format terraform show -json produces, used in Step 4.
  3. cicd-and-gitops-on-aws-guide, Module 3 — the real plan-on-PR/apply-on-merge flow that makes -target a diagnostic tool, not the normal flow.