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.
- 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
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.
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:
- Dependencies are tangled. A Knowledge Base can't exist until its IAM role and its OpenSearch collection exist first — the KB references both. In W4 you hit this the hard way with boto3: create things in the wrong order and the API rejects you. IaC tools read those references and build a dependency graph, so they create the role and collection before the KB automatically. You describe what connects to what; the tool derives the order.
- Reproducibility is non-negotiable. You need the same stack in dev, stage, and prod — and you need to prove it's the same. A file in Git is diffable, reviewable in a pull request, and re-appliable to a byte-identical result. A memory of clicks is none of those things.
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.
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.tfchecked 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.
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.
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.arnand 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.
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"
}
}
}
}
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.
- 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. Hereaws_bedrock_guardrailnicknamedsupportis the guardrail. - Inside the guardrail,
content_policy_configturns on a filter: blockHATEcontent atHIGHstrength on both the user's input and the model's output.sensitive_information_policy_configtells it toANONYMIZE(hide) anyEMAILit detects. - The second resource,
aws_bedrockagent_knowledge_basenicknameddocs, is the document store.role_arn = aws_iam_role.kb.arnhands it a set of permissions — thataws_iam_role.kbis defined in the next lab; Terraform will link them automatically. knowledge_base_configurationpicks the embedding model (the AI that turns text into searchable number-vectors), andstorage_configurationpoints at an OpenSearch collection to hold those vectors.field_mappingjust 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.
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:
- Read the documents it indexes (
s3:GetObject,s3:ListBucket) — and scoped to the one bucket, not all of S3. - Call the embedding model that turns text into vectors (
bedrock:InvokeModel) — scoped to the specific Titan model ARN. - Write into the vector store (
aoss:APIAccessAllfor OpenSearch Serverless).
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.
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.comservice, 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.
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/*" },
]
})
}
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.
- The first resource,
aws_iam_rolenicknamedkb, creates the badge itself. Itsassume_role_policyanswers who is allowed to wear it: only thebedrock.amazonaws.comservice.jsonencode({...})just converts the HCL block into the JSON text AWS expects. - The second resource,
aws_iam_role_policy, attaches the actual permissions to that badge viarole = aws_iam_role.kb.id(linking back to the role above). - Each item in
Statementis one permission:Effect = "Allow", a list ofActions, and theResourcethey 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). - 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.
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.
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 bycdk synthinto a CloudFormation template — an intermediate step — and thencdk deploycreates 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.
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
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.
- The
importlines pull in CDK's building blocks —Stackand the Bedrock resource library. class AiStack(Stack):defines your stack by inheriting from CDK'sStack. 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".bedrock.CfnGuardrail(self, "SupportGuardrail", ...)creates the guardrail.selfattaches 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.- The
ContentPolicyConfigProperty/ContentFilterConfigPropertyobjects are CDK's typed way of nesting the HATE filter — the Python equivalent of the nestedcontent_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/diffbefore applying - After
apply, confirm the live resources match the declarations destroytears the stack down cleanly- A second
applyreproduces 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.
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_guardrailresource block - Set
blocked_input_messagingandblocked_outputs_messaging - One
content_policy_configwith afilters_configfor HATE - Filter sets
input_strengthandoutput_strengthto 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"
}
}
}
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:AssumeRoleonly for the Bedrock service principal - Permission policy allows a single action:
bedrock:InvokeModel Resourceis one specific foundation-model ARN, not a wildcard- Use
jsonencodefor 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"
}]
})
}
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_configurationisVECTORwith a Titanembedding_model_arnstorage_configurationisOPENSEARCH_SERVERLESS- Reference
role_arnand 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"
}
}
}
}
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
Stacksubclass whose__init__instantiates the construct - Use
CfnGuardrailwithnameand the two blocked-messaging arguments - Nest
ContentPolicyConfigPropertycontaining aContentFilterConfigPropertyfor 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"),
],
),
)
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))
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
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
What does least privilege mean for the KB execution role in the example, and how is it enforced?