water park

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 runs terraform init with 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 init and terraform validate are all green in access/envs/prod, and, after git add -N access, git diff checkpoint/i1 -- access/envs access/backends access/scripts prints 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

closes P1closes P2

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

hands-on self-paced live

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

Do

  1. 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-i1
    

    checkpoint/i0 is the repo before access/ existed, so this directory has content/ and skills/ and no estate at all. Every command below runs here. Your original checkout is untouched and stays available as the reference copy.

  2. 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/backend
    

    Now read access/envs/prod/provider.tf. One block, two targets. With var.floci true it points iam, sts and s3 at http://localhost:4566, hands the provider the throwaway test key pair, and skips every call that would resolve a real account. With -var floci=false the same code talks to waterpark-prod. Nothing in this lesson starts Floci, and nothing in this lesson needs it.

  3. 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.tf

    resource "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.tf

    resource "aws_s3_bucket" "waterpark_artifacts" {
      bucket = "waterpark-artifacts"
    
      tags = {
        owner = local.owner
        role  = "build artifacts and the lesson checkpoints"
      }
    }
    

    Both carry an owner tag, so an access review can answer who to ask about a resource from the tag alone. local.owner came in with locals.tf in step 2.

  4. Write the role. site-publisher is the workload that builds the site and writes it to the site bucket.

    access/envs/prod/iam_role.site_publisher.tf

    resource "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"
      }
    }
    
  5. 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_artifacts in a plan can guess the path without searching.

    access/envs/prod/iam_policy.site_publisher_read_artifacts.tf

    resource "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.tf

    resource "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.

  6. Write the environment’s output, so something outside this directory can read the role back by name.

    access/envs/prod/outputs.tf

    output "roles" {
      description = "The workload roles this environment declares, by principal name."
      value = {
        site-publisher = aws_iam_role.site_publisher.arn
      }
    }
    
  7. 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 validate
    

    The 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 plain init is the one to run.

    fmt -check is silent and exits 0 when every file is formatted. init writes .terraform/ and a lock file, both of which access/.gitignore keeps out of the repo. validate says the configuration is valid. All three ran with no credential and no account.

  8. Compare your tree with the checkpoint the lesson ends at.

    git add -N access
    git diff --stat checkpoint/i1 -- access/envs access/backends access/scripts
    

    Nothing printed means your tree is the reference tree. git add -N records the new files as intent-to-add so the diff can see them, and stages no content. Drop --stat for the line-by-line version. access/README.md stays out of the compare because the reference one already describes lessons you have not reached.

  9. 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 access
    

    role.tf now holds two resource blocks 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 writes one-type-per-file and path-matches-name, and against this exact tree they say role.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. and role.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