AI EngineeringZero to ProductionHome·About·What’s new·Contact
AWS AI Automation · Chapter W7

IaC: Terraform & CDK

Clicking in the console does not scale. This chapter turns the W4–W6 stack — KB, guardrail, OpenSearch, IAM — into a versioned, reproducible artifact in Terraform and CDK.

⏱️ ~2 hours🧪 3 labs🎯 Advanced
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • AWS credentials, Node.js (CDK needs it), + pip install aws-cdk-lib constructs
  • AWS credentials (aws configure) + Bedrock model access enabled in your region + pip install boto3
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Explain why clicking in the console does not scale and IaC does.
  • Provision a Guardrail and a Knowledge Base with Terraform.
  • Provision the same with AWS CDK (Python).
  • Wire the IAM roles these resources require, least-privilege.
▶ Runnable companionEvery code block in this lesson is also a standalone file under code/aws7-iac-bedrock/ in the course, with a README on how to run it. Read here, run there — no need to copy-paste.

Why IaC for AWS AI intermediate

Here's the failure you're trying to avoid. You spend an afternoon in the AWS console wiring up W4–W6 by hand: create the OpenSearch collection, create the IAM role, click through the Knowledge Base wizard, attach the guardrail. It works. Three weeks later a teammate needs the same setup in a staging account and asks "how did you build this?" You open the console to retrace your clicks — but you've forgotten which strength you set on the HATE filter, the exact ARN you pasted into the role, and the one checkbox you toggled at 6pm. You rebuild it from memory, it's almost the same, and now dev and staging behave subtly differently. Nobody can say why. That gap between "what I clicked" and "what actually exists" is the disease. Infrastructure as Code (IaC) is the cure.

The core idea is a mental flip. Console clicking is imperative — you issue a sequence of actions and hope you remember them. IaC is declarative — you write down the end state you want in a text file, and a tool (Terraform or CDK) figures out how to make reality match it. Think of it as the difference between giving a friend turn-by-turn driving directions from memory versus handing them the destination address and letting the GPS route them there. When the roads change, the address still works; the memorized turns don't.

Why does this matter especially for AWS AI resources, and not just any cloud thing? Two reasons that bit you in earlier chapters:

So IaC turns your whole W4–W6 stack — KB, guardrail, OpenSearch, roles — into a single versioned, reviewable artifact. This is the same operational discipline your LLMOps chapters preached, now pointed at AWS AI. The common beginner mistake is treating IaC as "just a way to type commands in a file" — it's not; the value is that the file is the source of truth, so anything that drifts away from it (a hand-edit in the console) becomes visible and fixable rather than a silent mystery.

Console clicks — imperative IaC file — declarative you · dev click, click… no record dev ≠ stage ≠ prod rebuilt from memory → silent drift main.tf (in Git) one source of truth dev stage prod one file → identical & reviewable The file is the source of truth. Console clicks leave no record, so each environment gets rebuilt from memory and drifts apart; one IaC file applied everywhere yields byte-identical, reviewable stacks.
🗺️ How to read this diagram

Compare the two halves split by the dashed vertical line: the left is the console way, the right is the IaC way, both trying to produce the same stack.

  • On the left, you click through the console (dashed grey arrows = untracked actions) and the only "record" is your memory. When a second environment is needed it's rebuilt from that memory, so the red box shows the result: dev ≠ stage ≠ prod, drifted apart with nobody able to say why.
  • On the right, a single main.tf checked into Git is the one source of truth. The solid blue arrows apply that same file to each environment.
  • The payoff is the row of green boxes — dev, stage, and prod all come out identical because they were produced from one reviewable file, not three sets of remembered clicks.

In short: imperative clicking scatters truth into memory and drifts; declarative IaC pins truth in a file so every environment matches it.

Terraform: guardrail + KB intermediate

Imagine you've just been handed a blank text file and told "recreate the guardrail and Knowledge Base from W6 and W4." With boto3 you'd write a Python script that calls create_guardrail, waits, calls create_knowledge_base, handles the case where the role isn't ready yet, retries… it's a program, and you own every step. Terraform flips that on its head: you don't write the steps, you write the things. That's what the file below is — a list of the two resources you want to exist, and nothing about how to build them.

The grammar is worth slowing down on because every Terraform file you ever read follows it. A resource block reads resource "<aws-type>" "<your-nickname>" { … }. The first quoted word is fixed by the provider — aws_bedrock_guardrail is a real, specific resource type that maps to a real Bedrock API. The second quoted word is a name you invent, a local handle so other parts of the file can point at this one (you'll see aws_iam_role.kb.arn do exactly that in the next lab). Think of the type as the noun ("a guardrail") and the nickname as the pronoun you'll use to refer back to it. Inside the braces, nested blocks like content_policy_config and storage_configuration are the settings — and they mirror, almost one-for-one, the request shape you sent to boto3 in earlier chapters, just written as HCL (HashiCorp Configuration Language) instead of a Python dict.

The single most important line to notice — and the reason IaC beats a hand-written script here — is role_arn = aws_iam_role.kb.arn. You are not pasting a literal ARN string; you are referencing another resource in the file. That reference is a promise: "this KB depends on that role." Terraform reads all such references before doing anything, assembles them into a dependency graph, and creates resources in the only order that can work — role and OpenSearch collection first, KB last. This is precisely the provisioning-order problem that tripped up the boto3 version in W4, and here you never hand-order a thing; the references order it for you. The common newcomer mistake is to hard-code ARNs as plain strings "to be safe" — that severs the dependency link and quietly breaks Terraform's ordering. Prefer references; let the graph work.

references in main.tf → a dependency graph → a safe creation order aws_iam_role.kb the role …collection.vec OpenSearch collection knowledge_base.docs role_arn = ...kb.arn ↑ these references ARE the dependency edges Terraform sorts 1 · create role + collection (no deps) 2 · create OpenSearch depends on nothing here 3 · create KB last needs role + collection first References become the creation order. Because the KB block points at aws_iam_role.kb.arn and the collection, Terraform knows the KB depends on both and creates them first — solving the W4 boto3 ordering problem with no hand-ordering.
🗺️ How to read this diagram

Read left to right: the left column is the three resource blocks as written in main.tf; the right column is the order Terraform actually creates them in.

  • On the left, the KB block (blue) contains the line role_arn = aws_iam_role.kb.arn and a similar collection reference. The blue arrows going up from the KB to the role and collection are those references — each one is a dependency edge saying "I need that thing to exist first."
  • The dashed grey arrow in the middle is Terraform reading all those edges and topologically sorting them into a build order.
  • On the right, the green boxes are created first because they depend on nothing (role, then collection); the amber box — the KB — is created last because it needs both. That last box is the payoff: the exact ordering you had to hand-manage with boto3 in W4, now derived for you.

In short: a reference like ...kb.arn is not just a value — it's the edge that tells Terraform what to build before what.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab W7.1
main.tf# main.tf — a Bedrock guardrail and a Knowledge Base, in Terraform.
resource "aws_bedrock_guardrail" "support" {
  name                      = "support-guardrail"
  blocked_input_messaging   = "I can't help with that."
  blocked_outputs_messaging = "I can't provide that."

  content_policy_config {
    filters_config {
      type            = "HATE"
      input_strength  = "HIGH"
      output_strength = "HIGH"
    }
  }
  sensitive_information_policy_config {
    pii_entities_config {
      type   = "EMAIL"
      action = "ANONYMIZE"
    }
  }
}

resource "aws_bedrockagent_knowledge_base" "docs" {
  name     = "support-docs"
  role_arn = aws_iam_role.kb.arn

  knowledge_base_configuration {
    type = "VECTOR"
    vector_knowledge_base_configuration {
      embedding_model_arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"
    }
  }
  storage_configuration {
    type = "OPENSEARCH_SERVERLESS"
    opensearch_serverless_configuration {
      collection_arn    = aws_opensearchserverless_collection.vec.arn
      vector_index_name = "support-index"
      field_mapping {
        vector_field   = "vec"
        text_field     = "text"
        metadata_field = "meta"
      }
    }
  }
}
▶ How this works

This is Infrastructure as Code (IaC): instead of clicking buttons in the AWS web console, you describe the cloud resources you want in a text file, and Terraform makes reality match the file. This file (main.tf, written in Terraform's language called HCL) asks for two things: a safety guardrail and a Knowledge Base (a searchable store of documents). You never say how to build them — only what you want.

  1. Each resource "type" "name" { ... } block declares one cloud thing. The first quoted word is the AWS resource type; the second is a private nickname you choose so other parts of the file can refer to it. Here aws_bedrock_guardrail nicknamed support is the guardrail.
  2. Inside the guardrail, content_policy_config turns on a filter: block HATE content at HIGH strength on both the user's input and the model's output. sensitive_information_policy_config tells it to ANONYMIZE (hide) any EMAIL it detects.
  3. The second resource, aws_bedrockagent_knowledge_base nicknamed docs, is the document store. role_arn = aws_iam_role.kb.arn hands it a set of permissions — that aws_iam_role.kb is defined in the next lab; Terraform will link them automatically.
  4. knowledge_base_configuration picks the embedding model (the AI that turns text into searchable number-vectors), and storage_configuration points at an OpenSearch collection to hold those vectors. field_mapping just names the three columns: the vector, the text, and its metadata.

What the output means: Nothing runs yet — this is a declaration. You'd run terraform apply to make it real; Terraform then prints each resource it creates and a green Apply complete! summary.

Try this: Read the two quoted words after each resource. Can you say, in English, what AWS thing each block asks for and what you nicknamed it? That habit is the whole skill of reading Terraform.

Terraform resolves the dependency orderNotice role_arn = aws_iam_role.kb.arn and the collection reference. Terraform builds the graph and creates the role and OpenSearch collection before the KB — the exact ordering that tripped up the boto3 version in W4.

The IAM role (least privilege) advanced

Every breach post-mortem you'll ever read has the same sentence buried in it: "the compromised role had more permissions than it needed." Someone, under deadline, attached AdministratorAccess to a service "just to get it working," meant to tighten it later, and never did. Months later that over-broad role is the blast radius when something goes wrong. The Knowledge Base you declared above needs to touch other AWS services to do its job — and how tightly you scope that permission is the difference between a contained incident and a catastrophic one.

Here's the concept that makes it click. In AWS, a service doesn't have permissions of its own; it assumes a role — think of a role as a lanyard-badge the KB clips on before walking into the building. The badge answers two separate questions, and confusing them is the classic beginner error. The trust policy (assume_role_policy) answers "who is allowed to wear this badge?" — here, only the bedrock.amazonaws.com service, nobody else. The permission policy (the aws_iam_role_policy) answers a completely different question: "once you're wearing it, what doors does it open?" Who versus what. Keep those two straight and IAM stops being mysterious.

The principle governing the second question is least privilege: grant exactly the abilities the resource needs to function, and not one more. Notice the discipline in the lab below — three statements, each pinned to a real need the KB has:

What's absent is the whole point: there is no "allow everything" statement, no wildcard on actions. If this KB were later coerced — by a bug or an attacker — into trying to delete an S3 bucket or invoke a different model, AWS would simply refuse, because that action was never granted. The jsonencode({ … }) wrapper is just plumbing: IAM policies are JSON documents, and it converts the readable HCL object into the JSON text AWS's API expects. When you review a role in a real security audit, your eye should go straight to two things — does the trust policy name a single expected principal, and does every permission trace back to a stated need? Anything you can't justify is a finding.

aws_iam_role.kb the badge the KB wears Trust policy — WHO only bedrock.amazonaws.commay assume this role Permission policy — WHAT exactly 3 scoped allows, no more s3:GetObject read docs InvokeModel embed aoss: write vec no "allow everything" → else refused Who versus what — and nothing spare. The role's trust policy names a single principal that may assume it; its permission policy grants exactly the three abilities the KB needs, so any ungranted action is refused by default.
🗺️ How to read this diagram

Start at the top box — the role itself — then follow the two arrows down into the two questions every role answers.

  • The left blue box is the trust policy: it answers who is allowed to wear the badge — here only the bedrock.amazonaws.com service, nobody else.
  • The right purple box is the permission policy: it answers the separate question of what the badge can do. Its three green boxes are the only granted abilities — read S3 documents, invoke the Titan embedding model, and write to the OpenSearch collection — each pinned to a specific resource.
  • The red strip at the bottom is the payoff: there is no wildcard "allow everything," so any action outside those three (deleting a bucket, calling another model) is refused by default. In an audit, that absence is what you're checking for.

In short: keep "who may assume it" and "what it may do" as two separate, narrow answers, and the blast radius stays tiny.

Lab W7.2
iam.tf# The KB's execution role: read the S3 data source, use the embeddings
# model, and write to the OpenSearch collection — nothing more.
resource "aws_iam_role" "kb" {
  name = "bedrock-kb-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "bedrock.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "kb" {
  role = aws_iam_role.kb.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { Effect = "Allow", Action = ["s3:GetObject", "s3:ListBucket"],
        Resource = ["arn:aws:s3:::support-docs", "arn:aws:s3:::support-docs/*"] },
      { Effect = "Allow", Action = ["bedrock:InvokeModel"],
        Resource = "arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0" },
      { Effect = "Allow", Action = ["aoss:APIAccessAll"],
        Resource = "arn:aws:aoss:*:*:collection/*" },
    ]
  })
}
▶ How this works

Before the Knowledge Base above can do its job it needs permission to touch other AWS services. In AWS, permissions are packaged into an IAM role — think of it as a badge the Knowledge Base wears. This file builds that badge and keeps it as small as possible, a security idea called least privilege: grant only what's needed, nothing extra.

  1. The first resource, aws_iam_role nicknamed kb, creates the badge itself. Its assume_role_policy answers who is allowed to wear it: only the bedrock.amazonaws.com service. jsonencode({...}) just converts the HCL block into the JSON text AWS expects.
  2. The second resource, aws_iam_role_policy, attaches the actual permissions to that badge via role = aws_iam_role.kb.id (linking back to the role above).
  3. Each item in Statement is one permission: Effect = "Allow", a list of Actions, and the Resource they apply to. The three allowed abilities map exactly to the comment: read documents from the S3 bucket (s3:GetObject, s3:ListBucket), call the embedding model (bedrock:InvokeModel), and write into the OpenSearch collection (aoss:APIAccessAll).
  4. Notice there is no "allow everything" line. If the KB later tried to, say, delete an S3 bucket, AWS would refuse — because that action was never granted here.

What the output means: Also just a declaration. After terraform apply, AWS would show a new role named bedrock-kb-role with exactly these three permissions and nothing more.

Try this: Cover the code and read only the comment at the top. Then check: does every permission below trace back to one of those three needs? If a line didn't, that would be a red flag in a real security review.

The same in CDK (Python) expert

Prefer code over HCL? CDK generates CloudFormation from Python. The resource model mirrors Terraform's; pick one per team and stay consistent.

You've just written the whole stack in HCL — so why is there a second way to do the exact same thing? Because teams don't all think in configuration files. A team that lives in Python all day often finds HCL an awkward second language: no loops they recognize, no functions, no IDE autocomplete they trust. AWS's answer is the Cloud Development Kit (CDK) — you describe your infrastructure in a real programming language, and CDK compiles that into a deployment template rather than executing it directly.

The crucial thing to understand is that CDK is not a rival cloud engine to Terraform in the way it first appears. Under the hood CDK generates CloudFormation — AWS's own native IaC format — and CloudFormation is what actually creates the resources. So the pipeline is: your Python → (via cdk synth) a CloudFormation template → (via cdk deploy) real AWS resources. Terraform, by contrast, talks to the AWS APIs itself. The practical upshot is that CDK is AWS-only and rides on CloudFormation's behavior, while Terraform is multi-cloud and keeps its own state. For an all-AWS shop, that difference rarely decides anything — the honest tie-breaker is which language your team already reads fluently.

Two vocabulary anchors will make the lab readable. A Stack is CDK's unit of deployment — one bundle of resources that gets created, updated, or torn down together (the rough analog of a Terraform root module). And the Cfn prefix you'll see on CfnGuardrail stands for CloudFormation: these are L1 constructs, the lowest-level, thinnest wrappers that map one-to-one onto the raw CloudFormation resource. That one-to-one-ness is exactly why the code below is so easy to line up against the Terraform version — the nested ContentPolicyConfigProperty and ContentFilterConfigProperty classes are just Python's way of writing the same nested content_policy_config { filters_config { … } } blocks you wrote in HCL. Same infrastructure, same fields, two dialects. The mistake to avoid is agonizing over the choice as if it were architectural; it's a team-fluency call, and the one rule that actually matters is pick one and stay consistent, because a codebase split between HCL and CDK is one nobody can reason about as a whole.

both dialects end at the same AWS resources CDK · Python ai_stack.py CloudFormation template synth AWS resources guardrail · KB · role deploy Terraform · HCL main.tf talks to AWS APIs directly (no CloudFormation) Two dialects, one destination. CDK compiles Python through cdk synth into a CloudFormation template that cdk deploy applies; Terraform reads HCL and calls the AWS APIs itself — both land on the same resources, so the choice is team fluency, not architecture.
🗺️ How to read this diagram

Follow the two rows from the left; both start with an IaC file and both arrive at the same green box on the right.

  • The top row is CDK: your Python (ai_stack.py) is turned by cdk synth into a CloudFormation template — an intermediate step — and then cdk deploy creates the resources. Note it passes through CloudFormation rather than talking to AWS directly.
  • The bottom row is Terraform: HCL (main.tf) skips the intermediate template and talks to the AWS APIs directly, which is why its arrow goes straight to the resources.
  • The payoff is the shared green box: both routes produce the identical guardrail, KB, and role. Because the destination is the same, the pick is about which language your team reads fluently — not about capability.

In short: CDK routes through a CloudFormation template, Terraform hits the APIs itself, and both end at the same AWS resources — so pick one and stay consistent.

Lab W7.3
ai_stack.pyfrom aws_cdk import Stack, aws_bedrock as bedrock
from constructs import Construct

class AiStack(Stack):
    def __init__(self, scope: Construct, cid: str, **kw):
        super().__init__(scope, cid, **kw)

        bedrock.CfnGuardrail(
            self, "SupportGuardrail",
            name="support-guardrail",
            blocked_input_messaging="I can't help with that.",
            blocked_outputs_messaging="I can't provide that.",
            content_policy_config=bedrock.CfnGuardrail.ContentPolicyConfigProperty(
                filters_config=[bedrock.CfnGuardrail.ContentFilterConfigProperty(
                    type="HATE", input_strength="HIGH", output_strength="HIGH")]
            ),
        )
        # a CfnKnowledgeBase construct wires the same fields as the Terraform above
▶ How this works

This does the same job as Lab W7.1 but in Python instead of HCL, using the AWS CDK (Cloud Development Kit). CDK lets you describe infrastructure in a real programming language; behind the scenes it generates AWS's own template format (CloudFormation) and deploys it. A CDK Stack is one deployable bundle of resources.

  1. The import lines pull in CDK's building blocks — Stack and the Bedrock resource library.
  2. class AiStack(Stack): defines your stack by inheriting from CDK's Stack. The __init__ method is where you list the resources; super().__init__(...) runs CDK's own setup first — standard Python for "do the parent's setup, then mine".
  3. bedrock.CfnGuardrail(self, "SupportGuardrail", ...) creates the guardrail. self attaches it to this stack, and "SupportGuardrail" is its id within the stack. The keyword arguments (name, blocked_input_messaging, content_policy_config) are the very same settings you saw in the Terraform version — just written as Python instead.
  4. The ContentPolicyConfigProperty/ContentFilterConfigProperty objects are CDK's typed way of nesting the HATE filter — the Python equivalent of the nested content_policy_config { filters_config { ... } } blocks in HCL. The closing comment notes a Knowledge Base construct would wire up identically.

What the output means: No output on its own. You'd run cdk synth to see the generated CloudFormation template, then cdk deploy to create the guardrail in AWS.

Try this: Put this side by side with Lab W7.1. Find the four matching pieces — the name, the two blocked messages, and the HATE filter. Same infrastructure, two dialects: pick whichever your team prefers and stay consistent.

Exercise W7.1 — Stand up W4–W6 as code

Context: The capstone of the IaC lesson is retiring the console clicks: everything you built by hand in W4 and W6 becomes reproducible code. Real reproducibility is only proven when you can destroy the stack and re-apply it to an identical result.

Your task: Take the Knowledge Base and guardrail you created by hand in W4 and W6 and express the whole stack in Terraform (or CDK), then plan/diff, apply, and finally destroy and re-apply to prove reproducibility.

Requirements:

  • Both the guardrail and the KB (with role and vector store) are declared in code
  • Review the plan/diff before applying
  • After apply, confirm the live resources match the declarations
  • destroy tears the stack down cleanly
  • A second apply reproduces the identical stack

💡 Hint: Let attribute references wire the dependency order for you, then trust the destroy/re-apply cycle as the real proof that the stack is reproducible.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · A guardrail in TerraformBeginner

Context: Clicking a guardrail together in the console is fine once; doing it repeatably across environments is not. Expressing it as an aws_bedrock_guardrail resource makes it reviewable in a PR and reproducible by apply — the IaC equivalent of create_guardrail.

Your task: Write an aws_bedrock_guardrail Terraform resource with blocked input and output messaging and one HATE content filter at HIGH strength.

Requirements:

  • A single aws_bedrock_guardrail resource block
  • Set blocked_input_messaging and blocked_outputs_messaging
  • One content_policy_config with a filters_config for HATE
  • Filter sets input_strength and output_strength to HIGH
  • This is HCL config, reviewed not run

💡 Hint: Map each boto3 kwarg to its HCL attribute (camelCase → snake_case); the nested filter mirrors filtersConfig.

Show solution

IaC makes the guardrail reproducible and reviewable. This is the Terraform equivalent of create_guardrail.

resource "aws_bedrock_guardrail" "support" {
  name                      = "support-guardrail"
  blocked_input_messaging   = "I can't help with that."
  blocked_outputs_messaging = "Response withheld by policy."

  content_policy_config {
    filters_config {
      type           = "HATE"
      input_strength = "HIGH"
      output_strength = "HIGH"
    }
  }
}
Exercise 2 · A least-privilege IAM roleIntermediate

Context: An agent or KB needs a role, and the difference between safe and dangerous is scope. Least privilege means the trust policy names exactly who may assume the role and the permission policy names exactly one action on one resource ARN — nothing wildcarded.

Your task: Write an aws_iam_role that only bedrock.amazonaws.com can assume, plus an aws_iam_role_policy granting just bedrock:InvokeModel on a single model ARN.

Requirements:

  • Trust policy allows sts:AssumeRole only for the Bedrock service principal
  • Permission policy allows a single action: bedrock:InvokeModel
  • Resource is one specific foundation-model ARN, not a wildcard
  • Use jsonencode for both policy documents
  • HCL config, reviewed not run

💡 Hint: Two separate concerns: the assume-role policy answers who, the role policy answers what — keep both as narrow as the task allows.

Show solution

Least privilege: the trust policy names the Bedrock service, and the permission policy scopes the Action and Resource narrowly.

resource "aws_iam_role" "bedrock" {
  name = "bedrock-invoke-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "bedrock.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "invoke" {
  role = aws_iam_role.bedrock.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "bedrock:InvokeModel"
      Resource = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"
    }]
  })
}
Exercise 3 · A Knowledge Base resourceAdvanced

Context: A Knowledge Base ties together an embedding model, a vector store, and a role — and the order they must be created in is a dependency graph. Terraform derives that graph automatically from attribute references, so you never hand-order the creates.

Your task: Write an aws_bedrockagent_knowledge_base with a Titan embedding model and an OpenSearch Serverless store, referencing the role and collection by attribute rather than hard-coded strings.

Requirements:

  • knowledge_base_configuration is VECTOR with a Titan embedding_model_arn
  • storage_configuration is OPENSEARCH_SERVERLESS
  • Reference role_arn and the collection ARN via resource attributes (e.g. aws_iam_role.kb.arn)
  • Provide the OpenSearch field_mapping (vector, text, metadata fields)
  • HCL config, reviewed not run

💡 Hint: Prefer aws_iam_role.kb.arn-style references over literal ARNs — that is what lets Terraform order the creates for you.

Show solution

Terraform builds a dependency graph from references like role_arn = aws_iam_role.kb.arn, so ordering is automatic.

resource "aws_bedrockagent_knowledge_base" "kb" {
  name     = "support-kb"
  role_arn = aws_iam_role.kb.arn

  knowledge_base_configuration {
    type = "VECTOR"
    vector_knowledge_base_configuration {
      embedding_model_arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"
    }
  }

  storage_configuration {
    type = "OPENSEARCH_SERVERLESS"
    opensearch_serverless_configuration {
      collection_arn    = aws_opensearchserverless_collection.kb.arn
      vector_index_name = "kb-index"
      field_mapping {
        vector_field   = "v"
        text_field     = "t"
        metadata_field = "m"
      }
    }
  }
}
Exercise 4 · The same guardrail in CDK (Python)Expert

Context: Some shops standardize on CDK rather than Terraform. CDK synthesizes CloudFormation, and its L1 constructs mirror the raw resource shape: the same guardrail becomes an aws_bedrock.CfnGuardrail with nested property classes standing in for the HCL blocks.

Your task: Express the beginner guardrail as an aws_bedrock.CfnGuardrail construct inside a CDK Stack subclass in Python.

Requirements:

  • Define a Stack subclass whose __init__ instantiates the construct
  • Use CfnGuardrail with name and the two blocked-messaging arguments
  • Nest ContentPolicyConfigProperty containing a ContentFilterConfigProperty for HATE at HIGH
  • Property-class nesting mirrors the Terraform blocks one-to-one
  • Python CDK config, reviewed not run

💡 Hint: L1 (Cfn*) constructs are the closest thing to raw CloudFormation — the nested *Property classes replace the HCL sub-blocks.

Show solution

CDK generates CloudFormation; nested L1 property classes mirror the Terraform blocks.

from aws_cdk import Stack, aws_bedrock as bedrock
from constructs import Construct

class AiStack(Stack):
    def __init__(self, scope: Construct, cid: str, **kw):
        super().__init__(scope, cid, **kw)
        bedrock.CfnGuardrail(
            self, "SupportGuardrail",
            name="support-guardrail",
            blocked_input_messaging="I can't help with that.",
            blocked_outputs_messaging="Response withheld by policy.",
            content_policy_config=bedrock.CfnGuardrail.ContentPolicyConfigProperty(
                filters_config=[
                    bedrock.CfnGuardrail.ContentFilterConfigProperty(
                        type="HATE", input_strength="HIGH", output_strength="HIGH"),
                ],
            ),
        )
Exercise 5 · Generate HCL from a policy specProfessional

Context: When many filters share a shape, hand-writing each HCL block invites copy-paste drift. Generating the filters_config blocks from a Python spec keeps a single source of truth and turns policy edits into data edits.

Your task: Write an offline filters_hcl(filters) generator that turns a list of content-filter dicts into the corresponding filters_config HCL blocks.

Requirements:

  • Input is a list of dicts with type and input/output strengths
  • Emit one filters_config { ... } block per filter
  • Quote string values correctly inside the generated HCL
  • Join the blocks into one string suitable to drop into a resource
  • Runs offline; verify by printing the output for a two-filter spec

💡 Hint: Build each block with an f-string and remember to escape the embedded quotes so the emitted HCL is syntactically valid.

Show solution

Templating IaC from data keeps a single source of truth and avoids copy-paste drift.

def filters_hcl(filters):
    blocks = []
    for f in filters:
        blocks.append(
            "  filters_config {\n"
            f"    type            = \"{f['type']}\"\n"
            f"    input_strength  = \"{f['in']}\"\n"
            f"    output_strength = \"{f['out']}\"\n"
            "  }")
    return "\n".join(blocks)

spec = [{"type":"HATE","in":"HIGH","out":"HIGH"},
        {"type":"VIOLENCE","in":"MEDIUM","out":"HIGH"}]
print(filters_hcl(spec))
Exercise 6 · Reproducible multi-env stack with a drift checkIndustry scenario

Context: Platform teams must ship byte-identical guardrail+KB stacks to dev, stage, and prod, and catch anything changed out-of-band. IaC's whole value is that desired state is code; a drift check flags when reality no longer matches it.

Your task: Model an offline drift(desired, actual) checker that compares a deployed config dict against the IaC-declared desired state and returns the differing keys.

Requirements:

  • Iterate the desired config and compare each key against the actual config
  • For any mismatch, record both the desired and actual values
  • Keys present in desired but missing from actual count as drift
  • Return an empty result (and report in-sync) when everything matches
  • Demonstrate a drifted field and a clean run; runs offline

💡 Hint: A dict of {key: {desired, actual}} for mismatches makes the report self-explanatory; emptiness means in-sync.

Show solution

IaC's value is that desired state is code; a drift check flags anything changed out-of-band.

def drift(desired, actual):
    diffs = {}
    for k, v in desired.items():
        if actual.get(k) != v:
            diffs[k] = {"desired": v, "actual": actual.get(k)}
    return diffs

desired = {"guardrail_name": "house", "hate_strength": "HIGH",
           "embedding_model": "amazon.titan-embed-text-v2:0"}
actual  = {"guardrail_name": "house", "hate_strength": "MEDIUM",  # drifted!
           "embedding_model": "amazon.titan-embed-text-v2:0"}
d = drift(desired, actual)
print(d)   # {'hate_strength': {'desired':'HIGH','actual':'MEDIUM'}}
print("drift detected" if d else "in sync")

✓ Checkpoint — you can move on when you can…

  • Explain why AWS AI resources specifically benefit from IaC (dependency order, reproducibility).
  • Write a Terraform resource for a guardrail and a KB.
  • Write the least-privilege IAM role a KB needs.
  • Express the same stack in CDK and pick one tool for a team.

Knowledge check check yourself

✓ Knowledge check

The boto3 KB setup in W4 was tripped up by provisioning order. How does Terraform solve this, and what expresses the dependency in the code?

Show answer
Terraform builds a dependency graph from references like role_arn = aws_iam_role.kb.arn and the OpenSearch collection reference, so it creates the IAM role and collection before the KB automatically -- you don't hand-order the steps.
✓ Knowledge check

What does least privilege mean for the KB execution role in the example, and how is it enforced?

Show answer
The role grants only the three abilities the KB actually needs: read the S3 data source (s3:GetObject/ListBucket), call the embedding model (bedrock:InvokeModel), and write to the OpenSearch collection (aoss:APIAccessAll). There is no 'allow everything' statement, so any ungranted action (e.g. deleting a bucket) is refused.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in