Course 2 · after Fountain: The IAM repo · lesson 1 (I1)
One resource per file
- Properties
- I Honor the lower layer
- Goal
- Build the first environment of the access repo from an empty tree. Write one Terraform resource block per file under
access/envs/prod, name every file after the resource address inside it, wire one provider block that reaches either Floci or a real account, and check in the local backend so a fresh clone runsterraform initwith no AWS account. Then break the convention on purpose and watch Terraform accept it, which is the gap lesson 3 closes. - Done when
terraform fmt -check -recursive access,terraform initandterraform validateare all green inaccess/envs/prod, and, aftergit add -N access,git diff checkpoint/i1 -- access/envs access/backends access/scriptsprints nothing. The other half of prescription 1, the rules that fail a two-resource file and a misnamed one in the editor, is built in lesson 3.- Restart from
- checkpoint/i0, the repo before access/ existed (the lesson whose checkpoint to reload if this one breaks)
- Mode
- self-paced or live · about 40 min
Run with an agent
Paste this to your agent.
curl -fsSL https://raw.githubusercontent.com/INTENTIUS/waterpark/main/skills/i1-one-type-per-file/SKILL.md and follow that skill with me, step by step. Confirm with me before you install or write anything.
The skill is skills/i1-one-type-per-file/SKILL.md (on GitHub). Installable too, with npx skills add INTENTIUS/waterpark, or by copying skills/i1-one-type-per-file into ~/.claude/skills/, or as a Fountain agent skill from INTENTIUS/waterpark.
Do · 40 min
You need: a water park checkout on main, `terraform` 1.9 or newer on the PATH, `tflint` from `brew install terraform-linters/tap/tflint`, `just access-init` run once in that checkout, no Floci and no AWS account.
Context
- The access repo is this repo. It holds Terraform at
access/envs/<env>/<resource_type>.<label>.tf, oneresourceblock per file, with the provider prefix dropped from the type.aws_iam_role.site_publisherlives iniam_role.site_publisher.tf. - Terraform already reads every
.tfin a directory as one module, so nothing assembles anything and there is no generated file. The names buy a reader an index and buy the machine nothing, which is why the machine has to be taught to care about them. - Terraform will not teach it.
terraform validateandterraform fmtboth accept a file calledrole.tfholding four resources. The two rules that refuse it are Rego, run bytflint-ruleset-opa, and lesson 3 writes them. - A file that holds no
resourceblock is exempt from the naming rule, which is howprovider.tf,variables.tf,versions.tf,locals.tfandoutputs.tfkeep their conventional names. - One provider block covers both targets. The
flocivariable defaults totrue, so the solo path needs no credential. The checked-in backend is local for the same reason, andaccess/scripts/backend s3 envs/prodswaps in the real one.
Do
Make a worktree at the checkpoint this lesson starts from. Run this in your water park checkout.
git fetch origin --tags git worktree add ../waterpark-i1 checkpoint/i0 cd ../waterpark-i1checkpoint/i0is the repo beforeaccess/existed, so this directory hascontent/andskills/and no estate at all. Every command below runs here. Your original checkout is untouched and stays available as the reference copy.Make the directories, and copy in the plumbing you are not writing by hand. The versions pin, the provider, the variables, the locals and the two backend files never change again across the whole course, so take them from the checkout you cloned rather than typing them. Substitute the real path if your checkout is not
../waterpark.mkdir -p access/envs/prod access/envs/dev access/backends access/scripts cp ../waterpark/access/envs/prod/versions.tf ../waterpark/access/envs/prod/provider.tf ../waterpark/access/envs/prod/variables.tf ../waterpark/access/envs/prod/locals.tf access/envs/prod/ cp ../waterpark/access/envs/dev/README.md access/envs/dev/ cp ../waterpark/access/backends/backend.local.tf ../waterpark/access/backends/backend.s3.tf access/backends/ cp ../waterpark/access/scripts/backend access/scripts/backend cp ../waterpark/access/.gitignore access/.gitignore chmod +x access/scripts/backendNow read
access/envs/prod/provider.tf. One block, two targets. Withvar.flocitrue it pointsiam,stsands3athttp://localhost:4566, hands the provider the throwawaytestkey pair, and skips every call that would resolve a real account. With-var floci=falsethe same code talks towaterpark-prod. Nothing in this lesson starts Floci, and nothing in this lesson needs it.Write the two buckets, one file each. The estate has a bucket the published site is served from and a bucket the build artifacts land in.
access/envs/prod/s3_bucket.waterpark_site.tfresource "aws_s3_bucket" "waterpark_site" { bucket = "waterpark-site" tags = { owner = local.owner role = "the bucket the published site is served from" } }access/envs/prod/s3_bucket.waterpark_artifacts.tfresource "aws_s3_bucket" "waterpark_artifacts" { bucket = "waterpark-artifacts" tags = { owner = local.owner role = "build artifacts and the lesson checkpoints" } }Both carry an
ownertag, so an access review can answer who to ask about a resource from the tag alone.local.ownercame in withlocals.tfin step 2.Write the role.
site-publisheris the workload that builds the site and writes it to the site bucket.access/envs/prod/iam_role.site_publisher.tfresource "aws_iam_role" "site_publisher" { name = "site-publisher" description = "Builds the site and writes it to the site bucket." assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "codebuild.amazonaws.com" } Action = "sts:AssumeRole" }] }) tags = { owner = local.owner persona = "service" } }Write the policy and the attachment, as two more files. This is the shape the convention forces. Three resources means three files, and a reader who sees
aws_iam_policy.site_publisher_read_artifactsin a plan can guess the path without searching.access/envs/prod/iam_policy.site_publisher_read_artifacts.tfresource "aws_iam_policy" "site_publisher_read_artifacts" { name = "site-publisher-read-artifacts" description = "Read on waterpark-artifacts for site-publisher, so a build can pick up the checkpoint bundle." policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = [ "s3:GetObject", "s3:GetObjectVersion", "s3:GetBucketLocation", "s3:ListBucket", ] Resource = [ aws_s3_bucket.waterpark_artifacts.arn, "${aws_s3_bucket.waterpark_artifacts.arn}/*", ] }] }) tags = { owner = local.owner } }access/envs/prod/iam_role_policy_attachment.site_publisher_read_artifacts.tfresource "aws_iam_role_policy_attachment" "site_publisher_read_artifacts" { role = aws_iam_role.site_publisher.name policy_arn = aws_iam_policy.site_publisher_read_artifacts.arn }Three files for one grant is the cost of the convention, and lesson 2 is where the module collapses them back into one call. Notice that the grant is already spelled as four named actions rather than
s3:*, which is the rule lesson 3 writes down.Write the environment’s output, so something outside this directory can read the role back by name.
access/envs/prod/outputs.tfoutput "roles" { description = "The workload roles this environment declares, by principal name." value = { site-publisher = aws_iam_role.site_publisher.arn } }Pick the backend and run the checks. The swap script copies one of the two backend files into the env directory and removes the other, because Terraform allows one backend block per root module.
access/scripts/backend local envs/prod terraform fmt -check -recursive access terraform -chdir=access/envs/prod init terraform -chdir=access/envs/prod validateThe script’s own last line offers
init -reconfigure. That flag matters when a backend that was already initialised is being swapped, which is the live path in lesson 6. This directory has never been initialised, so plaininitis the one to run.fmt -checkis silent and exits 0 when every file is formatted.initwrites.terraform/and a lock file, both of whichaccess/.gitignorekeeps out of the repo.validatesays the configuration is valid. All three ran with no credential and no account.Compare your tree with the checkpoint the lesson ends at.
git add -N access git diff --stat checkpoint/i1 -- access/envs access/backends access/scriptsNothing printed means your tree is the reference tree.
git add -Nrecords the new files as intent-to-add so the diff can see them, and stages no content. Drop--statfor the line-by-line version.access/README.mdstays out of the compare because the reference one already describes lessons you have not reached.Break the convention on purpose, and watch nothing complain.
mv access/envs/prod/iam_role.site_publisher.tf access/envs/prod/role.tf cat access/envs/prod/iam_policy.site_publisher_read_artifacts.tf >> access/envs/prod/role.tf rm access/envs/prod/iam_policy.site_publisher_read_artifacts.tf terraform -chdir=access/envs/prod validate terraform fmt -check -recursive accessrole.tfnow holds tworesourceblocks and repeats neither address, and both commands still exit 0. That is the honest state of prescription 1 at the end of lesson 1. The convention is real and the enforcement is not. Lesson 3 writesone-type-per-fileandpath-matches-name, and against this exact tree they sayrole.tf holds 2 resource blocks. One resource per file. Move aws_iam_policy.site_publisher_read_artifacts into its own iam_policy.site_publisher_read_artifacts.tf.androle.tf holds aws_iam_role.site_publisher. Rename the file to iam_role.site_publisher.tf, so the path repeats the resource address.Put it back with two commands, or leave the mess, because lesson 2 starts in a fresh worktree of its own.
git checkout checkpoint/i1 -- access/envs/prod rm access/envs/prod/role.tf
Self-paced
The whole lesson runs on the laptop. No Floci, no AWS account, no credential anywhere. terraform init reaches the provider registry once to download the AWS provider and touches nothing else. The lock file it writes is deliberately not committed, because a lock file records provider hashes for the platforms it was generated on and students take this course on three of them.
tflint sits idle here. It is in the setup list because lesson 3 needs it, and because installing it early means one fewer thing to do later. Note the tap, brew install terraform-linters/tap/tflint rather than the core formula.
What this lesson cannot show is an applied estate. Nothing here has been created anywhere. Lesson 4 starts Floci and applies, and lesson 5 is the first time IAM itself refuses something.
When you are finished with the worktree, git worktree remove ../waterpark-i1 from your main checkout takes it away.
Live
Twelve minutes. The room watches step 5 produce three files for one grant and groan, then watches step 9 pass both checks and stop groaning. Say this while step 9 is on screen. Terraform does not read file names, so every naming convention you have ever worked under was enforced by people remembering, and people stop remembering on a Friday. In lesson 3 we make the machine remember instead.
Further reading
- The estate , the accounts, principals and resources this repo declares
- Prescriptions , P1 and P2
- access/README.md , the layout, the two backends and the rule pack in one page
- The AWS desk
- Decisions 31, 32 and 33
- Issues A1 and A2