diff --git a/README.md b/README.md index 7f19364..1cae924 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.14): +Checks (v0.1.15): +- Kubernetes: image tags in manifests (`k8s/**/*.yaml`, `deploy/**/*.yaml`) vs README mentions — handles variant tags - GitHub Actions version: detect outdated `uses: action@version` in `.github/workflows/*.yml/.yaml` — compares against known latest versions for 18 popular actions - GitLab CI: `.gitlab-ci.yml` image tags vs README mentions - CircleCI: `.circleci/config.yml` docker image tags vs README mentions diff --git a/pyproject.toml b/pyproject.toml index 03f51f2..403568d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "driftcheck" -version = "0.1.14" +version = "0.1.15" 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 45661de..4e569aa 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.14" +__version__ = "0.1.15" diff --git a/src/driftcheck/cli.py b/src/driftcheck/cli.py index 373ee13..a054c56 100644 --- a/src/driftcheck/cli.py +++ b/src/driftcheck/cli.py @@ -42,17 +42,18 @@ def main(argv=None) -> int: circleci_drifts = result.get("circleci_drifts", []) gitlab_drifts = result.get("gitlab_drifts", []) gh_actions_version_drifts = result.get("gh_actions_version_drifts", []) + k8s_drifts = result.get("k8s_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 and not terraform_drifts and not circleci_drifts and not gitlab_drifts and not gh_actions_version_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 and not circleci_drifts and not gitlab_drifts and not gh_actions_version_drifts and not k8s_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 and not terraform_drifts and not circleci_drifts and not gitlab_drifts and not gh_actions_version_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 and not circleci_drifts and not gitlab_drifts and not gh_actions_version_drifts and not k8s_drifts: parts = [] if tv: parts.append(f"Rust {tv}") if cv: parts.append(f"Rust(Cargo) {cv}") @@ -99,6 +100,8 @@ def main(argv=None) -> int: print(f"driftcheck: {d['file']}: {d['doc_image']} → should be {d['circleci_image']} (CircleCI)") for d in gitlab_drifts: print(f"driftcheck: {d['file']}: {d['doc_image']} → should be {d['gitlab_image']} (GitLab CI)") + for d in k8s_drifts: + print(f"driftcheck: {d['file']}: {d['doc_image']} → should be {d['k8s_image']} (Kubernetes)") for d in gh_actions_version_drifts: print(f"driftcheck: {d['file']}: {d['action']}@{d['current']} → should be {d['suggested']}") return 1 diff --git a/src/driftcheck/detector.py b/src/driftcheck/detector.py index 00f5cb6..572b272 100644 --- a/src/driftcheck/detector.py +++ b/src/driftcheck/detector.py @@ -707,6 +707,61 @@ def find_gh_actions_version_drift(root: Path) -> list[dict]: return drifts +# --------------------------------------------------------------------------- +# Kubernetes drift: image tags in manifests vs README +# --------------------------------------------------------------------------- +K8S_IMAGE_RE = re.compile(r'image:\s*(?P[\w.\-/]+):(?P[\w.\-]+)') +K8S_VER_RE = re.compile(r'(?:image|docker|container)?\s*(?P[\w.\-/]+):(?P[\w.\-]+)|(?:version|tag)\s+(?P[\d.]+[\w.\-]*)', re.I) + +def parse_k8s_images(text: str) -> dict[str, str]: + """Return {image: tag} map of container images in K8s manifests.""" + result = {} + for m in K8S_IMAGE_RE.finditer(text): + result[m.group("image")] = m.group("tag") + return result + +def find_k8s_drift(k8s_files: dict[str, str], docs: dict[str, str]) -> list[dict]: + """Detect drift between K8s image tags and README mentions.""" + all_images: dict[str, str] = {} + for fname, content in k8s_files.items(): + for image, tag in parse_k8s_images(content).items(): + all_images[image] = tag + + if not all_images: + return [] + + def tags_match(doc_tag: str, k8s_tag: str) -> bool: + """Return True when tags are equivalent (handles '24' vs '24-slim').""" + if doc_tag == k8s_tag: + return True + if k8s_tag.startswith(doc_tag + "-"): + return True + if doc_tag.startswith(k8s_tag + "-"): + return True + return False + + drifts = [] + for fname, content in docs.items(): + for m in K8S_VER_RE.finditer(content): + img = (m.group("image") or "").lower() + tag = m.group("tag") or m.group("tag2") + if not tag: + continue + for k8s_img, k8s_tag in all_images.items(): + if img and img not in k8s_img and k8s_img not in img: + continue + if not tags_match(tag, k8s_tag): + drifts.append({ + "file": fname, + "doc_image": f"{img or k8s_img}:{tag}", + "k8s_image": f"{k8s_img}:{k8s_tag}", + "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 = [] @@ -865,6 +920,13 @@ def scan_repo(root: Path = Path(".")) -> dict: if p.is_file(): gitlab_files[str(p.relative_to(root))] = p.read_text(encoding="utf-8", errors="replace") + # Kubernetes manifests + k8s_files = {} + for pattern in ["k8s/**/*.yaml", "k8s/**/*.yml", "kubernetes/**/*.yaml", "kubernetes/**/*.yml", "deploy/**/*.yaml", "deploy/**/*.yml"]: + for p in root.glob(pattern): + if p.is_file(): + k8s_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) @@ -880,6 +942,7 @@ def scan_repo(root: Path = Path(".")) -> dict: terraform_drifts = find_terraform_drift(terraform_files, docs) circleci_drifts = find_circleci_drift(circleci_files, docs) gitlab_drifts = find_gitlab_drift(gitlab_files, docs) + k8s_drifts = find_k8s_drift(k8s_files, docs) return { "toolchain_version": parse_toolchain_version(toolchain_text), @@ -903,4 +966,5 @@ def scan_repo(root: Path = Path(".")) -> dict: "terraform_drifts": terraform_drifts, "circleci_drifts": circleci_drifts, "gitlab_drifts": gitlab_drifts, + "k8s_drifts": k8s_drifts, } \ No newline at end of file diff --git a/tests/test_detector.py b/tests/test_detector.py index dd2ceb4..c5ef357 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -622,3 +622,43 @@ def test_gh_actions_version_drift_in_scan(): assert "gh_actions_version_drifts" in result assert len(result["gh_actions_version_drifts"]) >= 1 + +# ---- Kubernetes drift tests ---- +from driftcheck.detector import find_k8s_drift, parse_k8s_images + +def test_parse_k8s_images(): + yaml = 'apiVersion: v1\nkind: Pod\nspec:\n containers:\n - image: nginx:1.25' + result = parse_k8s_images(yaml) + assert result == {"nginx": "1.25"} + +def test_parse_k8s_no_images(): + yaml = 'apiVersion: v1\nkind: ConfigMap' + assert parse_k8s_images(yaml) == {} + +def test_k8s_no_drift(): + yaml = 'containers:\n - image: nginx:1.25' + docs = {"README.md": "Uses nginx:1.25"} + assert find_k8s_drift({"k8s/deployment.yaml": yaml}, docs) == [] + +def test_k8s_detects_drift(): + yaml = 'containers:\n - image: nginx:1.25' + docs = {"README.md": "Uses nginx:1.21"} + drifts = find_k8s_drift({"k8s/deployment.yaml": yaml}, docs) + assert len(drifts) == 1 + assert drifts[0]["doc_image"] == "nginx:1.21" + assert drifts[0]["k8s_image"] == "nginx:1.25" + +def test_k8s_no_files_returns_empty(): + assert find_k8s_drift({}, {"README.md": "nginx:1.25"}) == [] + +def test_k8s_drift_included_in_scan(): + import tempfile + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "k8s").mkdir() + (root / "k8s" / "deployment.yaml").write_text('image: nginx:1.25') + (root / "README.md").write_text("Uses nginx:1.21") + result = scan_repo(root) + assert "k8s_drifts" in result + assert len(result["k8s_drifts"]) == 1 +