shoc-backend/scripts/test-terraform-import-plan-check.py

293 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Deterministic tests for check-terraform-import-plan.py."""
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from terraform_import_plan_resources import (
DEV_IMPORT_BASELINE,
DEV_IMPORT_IDS,
REQUIRED_RESOURCES,
)
SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py")
def run_case(
environment: str,
*,
actions_by_address: dict[str, list[str]] | None = None,
omit_address: str | None = None,
extra_resource: tuple[str, str, list[str]] | None = None,
allowed_updates: tuple[str, ...] = (),
import_id_overrides: dict[str, str] | None = None,
state_overrides: dict[str, dict[str, object]] | None = None,
empty: bool = False,
) -> subprocess.CompletedProcess[str]:
changes = []
if not empty:
for address, resource_type in REQUIRED_RESOURCES[environment].items():
if address == omit_address:
continue
actions = (actions_by_address or {}).get(address, ["no-op"])
change: dict[str, object] = {"actions": actions}
if environment == "dev":
change["importing"] = {
"id": (import_id_overrides or {}).get(
address, DEV_IMPORT_IDS[address]
)
}
if (
address
== "module.environment.aws_elastic_beanstalk_environment.this"
):
state: dict[str, object] = {
"tags": DEV_IMPORT_BASELINE["environment_tags"],
"setting": [],
}
change["before"] = state
change["after"] = state
elif (
address
== "module.environment.aws_route53_record.api_alias[0]"
):
state = {"alias": [DEV_IMPORT_BASELINE["api_alias"]]}
change["before"] = state
change["after"] = state
if address in (state_overrides or {}):
change["before"] = (state_overrides or {})[address]
change["after"] = (state_overrides or {})[address]
changes.append(
{
"address": address,
"mode": "managed",
"type": resource_type,
"change": change,
}
)
if extra_resource:
address, resource_type, actions = extra_resource
changes.append(
{
"address": address,
"mode": "managed",
"type": resource_type,
"change": {"actions": actions},
}
)
with tempfile.TemporaryDirectory() as directory:
plan_path = Path(directory) / "plan.json"
plan_path.write_text(
json.dumps({"resource_changes": changes}), encoding="utf-8"
)
command = [
sys.executable,
str(SCRIPT),
str(plan_path),
"--environment",
environment,
]
for allowed_address in allowed_updates:
command.extend(["--allow-update-address", allowed_address])
return subprocess.run(command, check=False, capture_output=True, text=True)
def main() -> int:
controlled_address = "module.environment.aws_iam_role.github_deploy"
cases = [
*[
(f"{environment} no-op", run_case(environment), 0)
for environment in REQUIRED_RESOURCES
],
("empty", run_case("dev", empty=True), 1),
(
"missing required",
run_case("dev", omit_address=controlled_address),
1,
),
(
"initial update",
run_case("dev", actions_by_address={controlled_address: ["update"]}),
1,
),
(
"unexpected initial action",
run_case("dev", actions_by_address={controlled_address: ["read"]}),
1,
),
(
"wrong import id",
run_case(
"dev",
import_id_overrides={controlled_address: "wrong-role"},
),
1,
),
(
"wrong environment tags",
run_case(
"dev",
state_overrides={
"module.environment.aws_elastic_beanstalk_environment.this": {
"tags": {
"Name": "shoc-backend-dev",
"env": "dev",
"project": "shoc",
},
"setting": [],
}
},
),
1,
),
(
"managed settings during import",
run_case(
"dev",
state_overrides={
"module.environment.aws_elastic_beanstalk_environment.this": {
"tags": DEV_IMPORT_BASELINE["environment_tags"],
"setting": [{"name": "ASPNETCORE_ENVIRONMENT"}],
}
},
),
1,
),
(
"wrong api alias",
run_case(
"dev",
state_overrides={
"module.environment.aws_route53_record.api_alias[0]": {
"alias": [
{
"name": "shoc-backend-dev.us-east-1.elasticbeanstalk.com",
"zone_id": "Z117KPS5GTRQ2G",
"evaluate_target_health": True,
}
]
}
},
),
1,
),
(
"controlled update",
run_case(
"dev",
actions_by_address={controlled_address: ["update"]},
allowed_updates=(controlled_address,),
),
0,
),
(
"tf-poc controlled update",
run_case(
"tf-poc",
actions_by_address={controlled_address: ["update"]},
allowed_updates=(controlled_address,),
),
0,
),
(
"wrong controlled address",
run_case(
"dev",
actions_by_address={controlled_address: ["update"]},
allowed_updates=("module.environment.aws_iam_role.runtime",),
),
1,
),
(
"create",
run_case("dev", actions_by_address={controlled_address: ["create"]}),
1,
),
(
"replacement",
run_case(
"dev",
actions_by_address={controlled_address: ["delete", "create"]},
),
1,
),
(
"destroy",
run_case("dev", actions_by_address={controlled_address: ["delete"]}),
1,
),
(
"outside address",
run_case(
"dev",
extra_resource=(
"module.environment.aws_iam_role.other",
"aws_iam_role",
["no-op"],
),
),
1,
),
(
"wrong type",
run_case(
"dev",
extra_resource=(controlled_address, "aws_iam_role_policy", ["no-op"]),
),
1,
),
(
"dev resource in staging",
run_case(
"staging",
extra_resource=(
"module.environment.aws_iam_role_policy.runtime_dynamo[0]",
"aws_iam_role_policy",
["no-op"],
),
),
1,
),
(
"live webhook policy in tf-poc",
run_case(
"tf-poc",
extra_resource=(
"module.environment.aws_iam_role_policy.runtime_webhook[0]",
"aws_iam_role_policy",
["no-op"],
),
),
1,
),
]
failures = [
(name, result, expected)
for name, result, expected in cases
if result.returncode != expected
]
if failures:
print(
"FAIL: plan-check cases failed: "
+ ", ".join(name for name, _, _ in failures),
file=sys.stderr,
)
for name, result, expected in failures:
print(
f"{name}: expected {expected}, got {result.returncode}\n"
f"{result.stdout}{result.stderr}",
file=sys.stderr,
)
return 1
print("PASS: Terraform import plan safety checks")
return 0
if __name__ == "__main__":
raise SystemExit(main())