From 6cfa0c75a22a6bf76c47cf65d61fc5a2d1b22899 Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Fri, 4 Sep 2026 17:39:54 +0000 Subject: [PATCH] feat(detector): add Terraform drift detection (v0.1.11) Detects provider version drift between versions.tf (required_providers block) and README mentions. Handles both 'required_providers = {' and 'required_providers {' formats. 6 new tests covering parsing, no-drift, drift detection, and scan integration. --- README.md | 3 ++- pyproject.toml | 2 +- src/driftcheck/__init__.py | 2 +- src/driftcheck/cli.py | 7 +++-- src/driftcheck/detector.py | 54 ++++++++++++++++++++++++++++++++++++++ tests/test_detector.py | 38 +++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ad8d3a9..0167d84 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ driftcheck --json # machine-readable driftcheck --fix # auto-fix drifts in documentation files ``` -Checks (v0.1.10): +Checks (v0.1.11): +- Terraform: `versions.tf` `required_providers` block `version` vs README mentions — handles both `required_providers = {` and `required_providers {` formats - Maven: `pom.xml` `java.version`, `maven.compiler.source`, `maven.compiler.target`, `release` vs README mentions — major-version comparison - Docker: `Dockerfile` `FROM :` vs `README.md` / `docs/README*.md` / `CONTRIBUTING*.md` — handles variant tags (`24` matches `24-slim`, `24-alpine`), multi-stage builds (`FROM golang:1.23 AS builder` → `FROM alpine:3.21`) - Java/Gradle: `build.gradle` `sourceCompatibility`, `jvmTarget`, `JavaVersion.VERSION_*` vs README mentions diff --git a/pyproject.toml b/pyproject.toml index 0668579..d13d266 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "driftcheck" -version = "0.1.10" +version = "0.1.11" description = "Detect version drift between docs and toolchain files (README vs Dockerfile, build.gradle, rust-toolchain.toml, package.json, etc.)" readme = "README.md" requires-python = ">=3.10" diff --git a/src/driftcheck/__init__.py b/src/driftcheck/__init__.py index 708c513..7e8172e 100644 --- a/src/driftcheck/__init__.py +++ b/src/driftcheck/__init__.py @@ -1,2 +1,2 @@ """driftcheck — detect version drift between docs and toolchain.""" -__version__ = "0.1.10" +__version__ = "0.1.11" diff --git a/src/driftcheck/cli.py b/src/driftcheck/cli.py index 68ec0f3..268cffc 100644 --- a/src/driftcheck/cli.py +++ b/src/driftcheck/cli.py @@ -38,17 +38,18 @@ def main(argv=None) -> int: docker_drifts = result.get("docker_drifts", []) java_drifts = result.get("java_drifts", []) maven_drifts = result.get("maven_drifts", []) + terraform_drifts = result.get("terraform_drifts", []) tv = result.get("toolchain_version") cv = result.get("cargo_rust_version") nv = result.get("package_node") pv = result.get("pyproject_python") gv = result.get("gomod_version") - if not tv and not cv and not nv and not pv and not gv and not count_drifts and not actions_drifts and not lineending_drifts and not external_resource_drifts and not docker_drifts and not java_drifts and not maven_drifts: + if not tv and not cv and not nv and not pv and not gv and not count_drifts and not actions_drifts and not lineending_drifts and not external_resource_drifts and not docker_drifts and not java_drifts and not maven_drifts and not terraform_drifts: print("driftcheck: no toolchain version found") return 0 - if not drifts and not rust_drifts and not node_drifts and not python_drifts and not go_drifts and not count_drifts and not actions_drifts and not lineending_drifts and not docker_drifts and not java_drifts and not maven_drifts: + if not drifts and not rust_drifts and not node_drifts and not python_drifts and not go_drifts and not count_drifts and not actions_drifts and not lineending_drifts and not docker_drifts and not java_drifts and not maven_drifts and not terraform_drifts: parts = [] if tv: parts.append(f"Rust {tv}") if cv: parts.append(f"Rust(Cargo) {cv}") @@ -89,6 +90,8 @@ def main(argv=None) -> int: print(f"driftcheck: {d['file']}: Java {d['doc_version']} → should be {d['gradle_version']} (build.gradle)") for d in maven_drifts: print(f"driftcheck: {d['file']}: Java {d['doc_version']} → should be {d['maven_version']} (pom.xml)") + for d in terraform_drifts: + print(f"driftcheck: {d['file']}: Terraform {d['provider']} {d['doc_version']} → should be {d['terraform_version']}") return 1 if __name__ == "__main__": diff --git a/src/driftcheck/detector.py b/src/driftcheck/detector.py index a1646d1..8504a6a 100644 --- a/src/driftcheck/detector.py +++ b/src/driftcheck/detector.py @@ -493,6 +493,51 @@ def find_maven_drift(pom_text: str, docs: dict[str, str]) -> list[dict]: return drifts +# --------------------------------------------------------------------------- +# Terraform drift: versions.tf provider versions vs README +# --------------------------------------------------------------------------- +TERRAFORM_PROVIDER_RE = re.compile(r'required_providers\s*=?\s*\{[^}]*source\s*=\s*"(?P[^"]+)"[^}]*version\s*=\s*"(?P[^"]+)"', re.S) +TERRAFORM_VER_RE = re.compile(r'(?:provider|terraform|version)\s+"?(?P\d+\.\d+(?:\.\d+)?)"?', re.I) + +def parse_terraform_provider_versions(text: str) -> dict[str, str]: + """Return {source: version} map of required_providers in versions.tf.""" + result = {} + for m in TERRAFORM_PROVIDER_RE.finditer(text): + result[m.group("source")] = m.group("ver") + return result + +def find_terraform_drift(terraform_files: dict[str, str], docs: dict[str, str]) -> list[dict]: + """Detect drift between Terraform provider versions and README mentions.""" + all_providers: dict[str, str] = {} + for fname, content in terraform_files.items(): + for source, ver in parse_terraform_provider_versions(content).items(): + all_providers[source] = ver + + if not all_providers: + return [] + + drifts = [] + for fname, content in docs.items(): + for m in TERRAFORM_VER_RE.finditer(content): + ver = m.group("ver") + # Check if this version matches any provider version + for source, tf_ver in all_providers.items(): + if ver != tf_ver and ver.split(".")[:2] == tf_ver.split(".")[:2]: + # Same major.minor, different patch — skip + continue + if ver != tf_ver: + drifts.append({ + "file": fname, + "doc_version": ver, + "terraform_version": tf_ver, + "provider": source, + "pos": m.start(), + }) + break + break # one per file + return drifts + + def apply_fixes(root: Path, result: dict) -> list[str]: """Apply fixes for all detected drifts. Returns list of fixed file paths.""" fixed = [] @@ -630,6 +675,13 @@ def scan_repo(root: Path = Path(".")) -> dict: if p.is_file(): maven_files[str(p.relative_to(root))] = p.read_text(encoding="utf-8", errors="replace") + # Terraform files + terraform_files = {} + for pattern in ["versions.tf", "*.tf", "terraform/*.tf"]: + for p in root.glob(pattern): + if p.is_file(): + terraform_files[str(p.relative_to(root))] = p.read_text(encoding="utf-8", errors="replace") + rust_drifts = find_rust_drift(toolchain_text, docs) rust_drifts_multi = find_rust_drift_multi(toolchain_text, cargo_text, docs) node_drifts = find_node_drift(package_text, docs) @@ -642,6 +694,7 @@ def scan_repo(root: Path = Path(".")) -> dict: docker_drifts = find_docker_drift(dockerfiles, docs) java_drifts = find_java_drift("\n".join(gradle_files.values()), docs) maven_drifts = find_maven_drift("\n".join(maven_files.values()), docs) + terraform_drifts = find_terraform_drift(terraform_files, docs) return { "toolchain_version": parse_toolchain_version(toolchain_text), @@ -661,4 +714,5 @@ def scan_repo(root: Path = Path(".")) -> dict: "docker_drifts": docker_drifts, "java_drifts": java_drifts, "maven_drifts": maven_drifts, + "terraform_drifts": terraform_drifts, } \ No newline at end of file diff --git a/tests/test_detector.py b/tests/test_detector.py index 2a70614..94dc7c4 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -458,3 +458,41 @@ def test_java_drift_included_in_scan(): assert "java_drifts" in result assert len(result["java_drifts"]) == 1 + +# ---- Terraform drift tests ---- +from driftcheck.detector import find_terraform_drift, parse_terraform_provider_versions + +def test_parse_terraform_provider_versions(): + tf = 'required_providers {\n aws = {\n source = "hashicorp/aws"\n version = "5.45.0"\n }\n}' + result = parse_terraform_provider_versions(tf) + assert result == {"hashicorp/aws": "5.45.0"} + +def test_parse_terraform_no_providers(): + tf = 'resource "aws_instance" "example" {\n ami = "abc"\n}' + assert parse_terraform_provider_versions(tf) == {} + +def test_terraform_no_drift(): + tf = 'required_providers {\n aws = { source = "hashicorp/aws", version = "5.45.0" }\n}' + docs = {"README.md": "AWS provider 5.45.0"} + assert find_terraform_drift({"versions.tf": tf}, docs) == [] + +def test_terraform_detects_drift(): + tf = 'required_providers {\n aws = { source = "hashicorp/aws", version = "5.45.0" }\n}' + docs = {"README.md": "AWS provider 5.40.0"} + drifts = find_terraform_drift({"versions.tf": tf}, docs) + assert len(drifts) == 1 + assert drifts[0]["doc_version"] == "5.40.0" + assert drifts[0]["terraform_version"] == "5.45.0" + +def test_terraform_no_files_returns_empty(): + assert find_terraform_drift({}, {"README.md": "AWS 5.45.0"}) == [] + +def test_terraform_drift_included_in_scan(): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "versions.tf").write_text('required_providers {\n aws = { source = "hashicorp/aws", version = "5.45.0" }\n}') + (root / "README.md").write_text("AWS provider 5.40.0") + result = scan_repo(root) + assert "terraform_drifts" in result + assert len(result["terraform_drifts"]) == 1 +