Mission 7.2 · Stage 7 — Team Workflow
Test and Lint Before Anyone Reviews
Goal: The PR pipeline fails automatically on invalid AWS values (tflint), insecure configuration (trivy), and broken module behaviour (terraform test with mocked providers) — before a human reads the plan.
By the end of this mission
- Catch AWS-specific mistakes with tflint and its AWS ruleset
- Scan configuration for security issues and document justified exceptions
- Write
terraform testfiles with plan-mode assertions and mock providers - Test that invalid inputs fail with
expect_failures
Part 1
Understand it first
Layers of automated checking
fmt and validate (already in CI) catch syntax and schema errors. TFLINT with the AWS ruleset knows AWS specifics: invalid instance types, deprecated arguments, previous-generation resources. SECURITY SCANNERS (trivy, checkov) flag risky configuration: public buckets, open ingress, unencrypted storage. terraform test checks YOUR logic, such as 'a worker never gets a load balancer' or 'invalid CPU sizes are rejected'. Each layer is cheap and runs in seconds, so humans review design, not typos.
terraform test and mock providers
Test files (*.tftest.hcl) contain run blocks. Each one plans (or applies) the module with given variables, then evaluates assert conditions against the result. With mock_provider "aws" (Terraform 1.7+), the AWS provider is replaced by a fake that returns generated values, so tests need no credentials, create nothing, and take seconds. That makes them ideal for module logic.
expect_failures = [var.cpu] asserts that a validation rule rejects bad input. Testing that your guardrails actually guard is as important as testing the happy path.
Findings are decisions, not noise
Scanners will flag things you've chosen deliberately. For example, ShopLite's app security group allows all egress because tasks call external APIs through NAT. The right response is an inline ignore with a written justification, reviewed like code. Silencing whole rules globally, or ignoring the scanner, means the next real finding gets lost too.
Part 2
Your project after this mission · 4 files change
- .github/
- workflows/
- terraform.ymlmodified
- bootstrap/
- main.tf
- infra/
- envs/
- dev.s3.tfbackend
- dev.tfvars
- prod.s3.tfbackend
- prod.tfvars
- database.tf
- ecs.tf
- main.tf
- providers.tf
- security.tfmodified
- tf
- modules/
- ecs-service/
- tests/
- service.tftest.hclnew
- main.tf
- variables.tf
- network/
- main.tf
- .tflint.hclnew
Part 3
Build it, step by step
- 1
tflint with the AWS ruleset
The plugin block pins the ruleset version.
tflint --initdownloads it;--recursivelints every module..tflint.hclwhole filehcl plugin "terraform" { enabled = true preset = "recommended" } plugin "aws" { enabled = true version = "0.40.0" source = "github.com/terraform-linters/tflint-ruleset-aws" }terminal$ tflint --init && tflint --recursive── expected output ──Installing "aws" plugin...Installed "aws" (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.40.0)(no output = no issues) - 2
Security scan, and a justified exception
trivy flags the app tier's unrestricted egress. It's deliberate, so record why, right where the resource is defined. The comment must sit directly above the resource. Everything else trivy reports should be fixed, not ignored.
infra/security.tfadd to filehcl # trivy:ignore:AVD-AWS-0104 App tasks call external payment/email APIs via NAT; egress # is limited at the network edge (NAT) and reviewed in security review SR-112. resource "aws_vpc_security_group_egress_rule" "app_all" { security_group_id = aws_security_group.app.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "-1" }terminal$ trivy config --severity HIGH,CRITICAL .── expected output ──infra/security.tf (terraform)Tests: 64 (SUCCESSES: 63, FAILURES: 1)Failures: 1 (HIGH: 0, CRITICAL: 1)AVD-AWS-0104 (CRITICAL): Security group rule allows unrestricted egress to any IP address.══════════════════════════════════════════infra/security.tf:51-55──────────────────────────────────────────51 ┌ resource "aws_vpc_security_group_egress_rule" "app_all" {52 │ security_group_id = aws_security_group.app.id53 │ cidr_ipv4 = "0.0.0.0/0"54 │ ip_protocol = "-1"55 └ } - 3
Write tests for the ecs-service module
Three tests: a worker (no load balancer) gets no load-balancer block and no request-count scaling; an API with a load balancer gets exactly one of each; an invalid CPU size is rejected by validation. The mock provider needs one hint, a realistic region for the
aws_regiondata source, because the log configuration uses it.modules/ecs-service/tests/service.tftest.hclwhole filehcl mock_provider "aws" { mock_data "aws_region" { defaults = { region = "ap-south-1" } } } variables { name = "test-svc" container_name = "app" cluster_id = "arn:aws:ecs:ap-south-1:123456789012:cluster/test" image = "123456789012.dkr.ecr.ap-south-1.amazonaws.com/test:1.0.0" execution_role_arn = "arn:aws:iam::123456789012:role/exec" task_role_arn = "arn:aws:iam::123456789012:role/task" subnet_ids = ["subnet-aaa", "subnet-bbb"] security_group_ids = ["sg-123"] log_group_name = "/test/app" } run "worker_has_no_load_balancer" { command = plan assert { condition = length(aws_ecs_service.this.load_balancer) == 0 error_message = "A service without load_balancer must not attach to a target group." } assert { condition = length(aws_appautoscaling_policy.requests) == 0 error_message = "Workers without a load balancer must not get request-count scaling." } } run "api_gets_lb_and_request_scaling" { command = plan variables { load_balancer = { target_group_arn = "arn:aws:elasticloadbalancing:ap-south-1:123456789012:targetgroup/api/abc" container_port = 8080 resource_label = "app/alb/123/targetgroup/api/abc" } autoscaling = { min = 2, max = 6, requests_per_target = 500 } } assert { condition = length(aws_ecs_service.this.load_balancer) == 1 error_message = "Expected exactly one load_balancer block." } assert { condition = length(aws_appautoscaling_policy.requests) == 1 error_message = "Expected request-count scaling when resource_label and requests_per_target are set." } assert { condition = aws_appautoscaling_target.this.min_capacity == 2 error_message = "min_capacity should follow autoscaling.min." } } run "rejects_invalid_fargate_cpu" { command = plan variables { cpu = 300 } expect_failures = [var.cpu] }terminal$ cd modules/ecs-service && terraform init -backend=false >/dev/null && terraform test── expected output ──tests/service.tftest.hcl... in progressrun "worker_has_no_load_balancer"... passrun "api_gets_lb_and_request_scaling"... passrun "rejects_invalid_fargate_cpu"... passtests/service.tftest.hcl... tearing downtests/service.tftest.hcl... passSuccess! 3 passed, 0 failed. - 4
Add the checks to the PR job
Insert these steps before
planin theplanjob. They need no AWS credentials, so failures show up in seconds.terraform-linters/setup-tflintandaquasecurity/trivy-actioninstall the tools..github/workflows/terraform.ymladd to fileyaml - uses: terraform-linters/setup-tflint@v4 - name: tflint run: tflint --init && tflint --recursive - name: trivy uses: aquasecurity/trivy-action@0.28.0 with: scan-type: config severity: HIGH,CRITICAL exit-code: "1" - name: module tests run: | for m in modules/*/; do if ls "$m"tests/*.tftest.hcl >/dev/null 2>&1; then (cd "$m" && terraform init -backend=false -input=false >/dev/null && terraform test) fi done
Checkpoint — you should now have
- ✓
tflint --recursivepasses with the AWS ruleset. - ✓
trivy configpasses; the one accepted finding has an inline justification. - ✓
terraform testpasses 3 tests forecs-servicewith no AWS credentials. - ✓All three run in the PR job before
plan.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Typo an instance type
In infra/probe.tf, set instance_type = "t4g.nanoo" and run tflint --recursive. (terraform validate would pass this.)
Break #2
Change module behaviour and let the tests catch it
In the module, change the request policy's count condition to just var.autoscaling.requests_per_target != null ? 1 : 0 (dropping the load-balancer check), and give the worker test autoscaling = { requests_per_target = 100 }. Run terraform test.
Part 5
Interview questions from this mission
How do you test Terraform code?
What do mock providers give you in terraform test?
A security scanner flags something you intentionally configured. What do you do?