Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/driftcheck/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""driftcheck — detect version drift between docs and toolchain."""
__version__ = "0.1.14"
__version__ = "0.1.15"
7 changes: 5 additions & 2 deletions src/driftcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions src/driftcheck/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<image>[\w.\-/]+):(?P<tag>[\w.\-]+)')
K8S_VER_RE = re.compile(r'(?:image|docker|container)?\s*(?P<image>[\w.\-/]+):(?P<tag>[\w.\-]+)|(?:version|tag)\s+(?P<tag2>[\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 = []
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand All @@ -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,
}
40 changes: 40 additions & 0 deletions tests/test_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Loading