lesson ── terraform-production ── ~16 мин ── 5 шагов
OPA + Rego, for the rules Checkov does not have. A business invariant (every resource carries a CostCenter tag), cross-resource (Lambda and RDS in the same VPC), conditional (a prod resource, only from the main branch). In this lesson you write Rego rules and run conftest against plan.json.
интерактивный sandbox
Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.
stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя
Business rule: every S3 bucket must carry CostCenter and Environment tags. One of the buckets breaks it.
cd /home/student/tf-opa
cat > main.tf <<'EOF'
resource "aws_s3_bucket" "good" {bucket = "linuxlab-opa-good"
tags = {CostCenter = "ml-team"
Environment = "dev"
}
}
resource "aws_s3_bucket" "missing_tags" {bucket = "linuxlab-opa-missing"
tags = {Owner = "student"
}
}
EOF
terraform init -no-color > /dev/null
terraform plan -no-color -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
plan.json is ready. Now we write the rule.
✓ Plan generated. The missing_tags bucket breaks the policy.
cat > policies/tags.rego <<'EOF'
package terraform.tags
import future.keywords.contains
import future.keywords.if
import future.keywords.in
mandatory_tags := {"CostCenter", "Environment"}deny contains msg if {some resource in input.resource_changes
resource.type == "aws_s3_bucket"
resource.change.actions[_] == "create"
tags := object.get(resource.change.after, "tags", {})some tag in mandatory_tags
not tags[tag]
msg := sprintf("%s missing mandatory tag %q", [resource.address, tag])}
EOF
What it does: for every aws_s3_bucket in the plan that will be
created, it checks that each mandatory tag is present. If one is missing
it adds a deny message.
Run it right away:
opa eval -d policies/ -i plan.json 'data.terraform.tags.deny'
It should return an array with two messages (CostCenter and Environment are both missing on missing_tags).
✓ Rego sees the violation. OPA spotted the bucket without tags.
Tests on the rules are critical: a buggy rule lets violations through.
cat > policies/tags_test.rego <<'EOF'
package terraform.tags
import future.keywords.if
test_deny_when_missing_tags if { mock := {"resource_changes": [{"address": "aws_s3_bucket.bad",
"type": "aws_s3_bucket",
"change": {"actions": ["create"],
"after": {"tags": {"Owner": "x"}},},
}]}
count(deny) == 2 with input as mock
}
test_no_deny_with_all_tags if { mock := {"resource_changes": [{"address": "aws_s3_bucket.good",
"type": "aws_s3_bucket",
"change": {"actions": ["create"],
"after": {"tags": {"CostCenter": "team",
"Environment": "dev",
}},
},
}]}
count(deny) == 0 with input as mock
}
test_ignores_delete_actions if { mock := {"resource_changes": [{"address": "aws_s3_bucket.gone",
"type": "aws_s3_bucket",
"change": {"actions": ["delete"], "after": null},}]}
count(deny) == 0 with input as mock
}
EOF
opa test policies/
It should show "PASS: 3/3". If the rule ignored missing tags,
test_deny_when_missing_tags would fail. That is the safety net.
✓ The Rego rules are tested. Safe to use in CI.
opa eval is flexible, but in CI you want a human-friendly format with pass/fail.
conftest, a wrapper with output, an exit code, and severity.
First, the policies/ directory layout for conftest:
# conftest reads the "main" namespace by default, rename the package
sed -i 's/^package terraform.tags$/package main/' policies/tags.rego
sed -i 's/^package terraform.tags$/package main/' policies/tags_test.rego
conftest test plan.json --policy policies/
echo "exit: $?"
It should show FAIL with two messages and exit 1.
An alternative, keep the namespace and use --all-namespaces
or --namespace terraform.tags. That is a matter of taste; CI decides
it once.
✓ conftest shows the same thing opa eval does, but in a CI-friendly format.
OpenTofu keeps the CLI and state compatible with Terraform for the
commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back
up the state and do a run on a feature branch, the differences
cluster in the newer features (variables in backend,
state encryption, OCI registry-backed modules). See
tf-opentofu-parity for the full matrix.
Add the tags:
sed -i '/"missing_tags"/,/^}$/ s|tags = {|tags = {\n CostCenter = "data-team"\n Environment = "dev"|' main.tfcat main.tf
terraform plan -no-color -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
conftest test plan.json --policy policies/
echo "exit: $?"
Now exit 0, the gate is clean. The PR is mergeable.
✓ The policy gate passes. In CI this is the last stage before the apply job.
That was a simple example, all S3 with tags. Here is what OPA does better than the rest:
# "If the plan creates a Lambda, it must be in the same VPC
# as the RDS it references through the DB_HOST env var"
deny contains msg if {some lambda in input.resource_changes
lambda.type == "aws_lambda_function"
lambda.change.actions[_] == "create"
db_host := lambda.change.after.environment[0].variables.DB_HOST
db_address := substr(db_host, 0, indexof(db_host, "."))
some db in input.resource_changes
db.type == "aws_db_instance"
db.change.after.address == db_address
# subnets
lambda_subnets := lambda.change.after.vpc_config[0].subnet_ids
db_subnet_group := db.change.after.db_subnet_group_name
count(lambda_subnets) > 0
db_subnet_group != ""
msg := sprintf(
"Lambda %q is in VPC subnet %v but reads RDS %q in subnet group %q, check VPC alignment",
[lambda.address, lambda_subnets, db.address, db_subnet_group],
)
}
Checkov will not write that. Neither will terraform-compliance. This is OPA territory.
More in tf-policy-as-code.
terraform plan -out=plan.tfplan && terraform show -json plan.tfplan > plan.json.
You run the resulting JSON through opa eval or conftest test. The rules
in Rego use input.resource_changes. deny, a list of messages;
a non-empty one means fail.
команды
opa version && opa eval --help | head -5OPA is already in the image.opa eval --data policies/ --input plan.json 'data.terraform.deny'what the rule returns.terraform show -json plan.tfplan > plan.jsonthe format OPA understands.концепции