blumeops/mise-tasks/container-version-check
Erich Blume c00d7db507
Some checks failed
Deploy Fly.io Proxy / deploy (push) Failing after 14m10s
Recurring maintenance batch (2026-05-27) (#360)
Bundle of recurring overdue tasks:

- Ringtail flake update
- Security & compliance report review
- Tooling deps bump (prek, fly, mise, forgejo workflows)
- Top stale doc review
- Top stale service review (if trivial)

Larger items (service version bumps requiring upgrades, non-local container migration) split out as separate PRs.

Reviewed-on: #360
2026-05-28 06:01:57 -07:00

269 lines
9.1 KiB
Text
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["pyyaml==6.0.3", "rich==15.0.0", "typer==0.26.2"]
# ///
#MISE description="Validate container version consistency across container.py, Dockerfiles, nix derivations, and service-versions.yaml"
#USAGE flag "--all-files" help="Check all containers, not just changed ones"
"""Validate that container versions are consistent across all declaration sites.
For each container directory under containers/, checks:
1. Any container.py must declare VERSION=<value>
2. Any Dockerfile must declare ARG CONTAINER_APP_VERSION=<value>
3. Any default.nix must produce a version (via dagger call nix-version)
4. At least one build file (container.py, Dockerfile, or default.nix) must exist
5. A matching entry in service-versions.yaml must exist with non-null current-version
6. All resolved versions from (1), (2), (3), and (5) must agree
By default, only checks containers whose files differ from main.
Pass --all-files to check every container.
Usage:
mise run container-version-check # changed containers only
mise run container-version-check --all-files # all containers
"""
import re
import shutil
import subprocess
from pathlib import Path
import typer
import yaml
from rich.console import Console
from rich.table import Table
REPO_ROOT = Path(__file__).parent.parent
CONTAINERS_DIR = REPO_ROOT / "containers"
SERVICE_VERSIONS_FILE = REPO_ROOT / "service-versions.yaml"
# Containers that are utility/test images, not tracked services
BLACKLIST = {"kubectl"}
# Container dir name → service-versions.yaml name (when they differ)
CONTAINER_TO_SERVICE = {
"kiwix-serve": "kiwix",
}
# Container dir name → nixpkgs package name for dagger nix-version.
# Used for containers that use an unmodified nixpkgs package (version matches upstream).
# Containers with local overrides (e.g. ntfy) declare version in default.nix
# and are detected automatically via NIX_VERSION_PATTERN.
NIX_PACKAGE_MAP = {
"authentik": "authentik",
}
CONTAINER_PY_VERSION_PATTERN = re.compile(r'^VERSION\s*=\s*["\']([^"\']+)["\']', re.MULTILINE)
VERSION_ARG_PATTERN = re.compile(r"^ARG\s+CONTAINER_APP_VERSION=(\S+)", re.MULTILINE)
NIX_VERSION_PATTERN = re.compile(r'^\s*version\s*=\s*"([^"]+)"\s*;', re.MULTILINE)
app = typer.Typer()
console = Console()
def strip_v(version: str) -> str:
"""Strip leading 'v' prefix for comparison."""
return version.lstrip("v")
def changed_containers() -> set[str] | None:
"""Return container names with changes vs main, or None on git failure."""
result = subprocess.run(
["git", "diff", "--name-only", "main...HEAD"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
if result.returncode != 0:
return None
names: set[str] = set()
sv_changed = False
for line in result.stdout.splitlines():
if line.startswith("containers/"):
parts = line.split("/")
if len(parts) >= 2:
names.add(parts[1])
if line == "service-versions.yaml":
sv_changed = True
# If service-versions.yaml changed, check all containers
if sv_changed:
return None
return names
def get_nix_version(container_name: str, nix_file: Path) -> str | None:
"""Extract nix package version. Tries local nix file first, then dagger."""
# Try extracting version declared directly in the nix file (local overrides)
match = NIX_VERSION_PATTERN.search(nix_file.read_text())
if match:
return match.group(1)
# Fall back to dagger for unmodified nixpkgs packages
pkg = NIX_PACKAGE_MAP.get(container_name)
if pkg is None:
return None
if not shutil.which("dagger"):
return None
result = subprocess.run(
["dagger", "call", "nix-version", f"--package={pkg}"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
if result.returncode != 0:
return None
return result.stdout.strip().splitlines()[-1].strip()
@app.command()
def main(
all_files: bool = typer.Option(False, "--all-files", help="Check all containers, not just changed ones"),
) -> None:
"""Validate container version consistency."""
# Determine which containers to check
if all_files:
scope = None # check all
else:
scope = changed_containers() # None means check all (fallback)
# Load service versions
data = yaml.safe_load(SERVICE_VERSIONS_FILE.read_text())
services = {svc["name"]: svc for svc in data.get("services", [])}
errors: list[tuple[str, str]] = []
results: list[dict] = []
for container_dir in sorted(CONTAINERS_DIR.iterdir()):
if not container_dir.is_dir():
continue
name = container_dir.name
if name in BLACKLIST:
continue
if scope is not None and name not in scope:
continue
container_py = container_dir / "container.py"
dockerfile = container_dir / "Dockerfile"
nix_file = container_dir / "default.nix"
has_container_py = container_py.exists()
has_dockerfile = dockerfile.exists()
has_nix = nix_file.exists()
versions: dict[str, str] = {}
entry = {
"name": name,
"has_container_py": has_container_py,
"has_dockerfile": has_dockerfile,
"has_nix": has_nix,
"versions": versions,
}
results.append(entry)
# Rule 4: at least one build file
if not has_container_py and not has_dockerfile and not has_nix:
errors.append((name, "No container.py, Dockerfile, or default.nix found"))
continue
# Rule 1: container.py must declare VERSION
if has_container_py:
match = CONTAINER_PY_VERSION_PATTERN.search(container_py.read_text())
if match:
versions["container.py"] = match.group(1)
else:
errors.append((name, "container.py missing VERSION declaration"))
# Rule 2: Dockerfile must declare CONTAINER_APP_VERSION
if has_dockerfile:
match = VERSION_ARG_PATTERN.search(dockerfile.read_text())
if match:
versions["dockerfile"] = match.group(1)
else:
errors.append((name, "Dockerfile missing ARG CONTAINER_APP_VERSION"))
# Rule 3: nix derivation must produce a version
if has_nix:
nix_ver = get_nix_version(name, nix_file)
if nix_ver is not None:
versions["nix"] = nix_ver
elif name in NIX_PACKAGE_MAP:
errors.append((name, "Failed to extract nix version via dagger"))
# Rule 4: service-versions.yaml entry with non-null version
svc_name = CONTAINER_TO_SERVICE.get(name, name)
svc = services.get(svc_name)
if svc is None:
errors.append((name, f"No entry '{svc_name}' in service-versions.yaml"))
elif svc.get("current-version") is None:
errors.append((name, f"Null current-version for '{svc_name}' in service-versions.yaml"))
else:
versions["service-versions"] = str(svc["current-version"])
# Rule 5: all resolved versions must match
if len(versions) >= 2:
normalized = {src: strip_v(v) for src, v in versions.items()}
unique = set(normalized.values())
if len(unique) > 1:
detail = ", ".join(f"{src}={v}" for src, v in sorted(versions.items()))
errors.append((name, f"Version mismatch: {detail}"))
# Output
console.print("[bold]Container Version Sync Check[/bold]")
if scope is not None:
console.print(f"Scope: {len(scope)} container(s) changed vs main")
else:
console.print("Scope: all containers")
console.print()
if results:
table = Table(show_header=True, header_style="bold")
table.add_column("Container")
table.add_column("Build")
table.add_column("Versions")
table.add_column("Status")
for entry in results:
name = entry["name"]
build_parts = []
if entry["has_container_py"]:
build_parts.append("dagger")
if entry["has_dockerfile"]:
build_parts.append("dockerfile")
if entry["has_nix"]:
build_parts.append("nix")
ver_parts = [f"{src}={v}" for src, v in sorted(entry["versions"].items())]
has_error = any(e[0] == name for e in errors)
status = "[red]FAIL[/red]" if has_error else "[green]OK[/green]"
table.add_row(
name,
"+".join(build_parts),
", ".join(ver_parts) or "—",
status,
)
console.print(table)
console.print()
if errors:
console.print(f"[bold red]{len(errors)} error(s):[/bold red]")
for name, msg in errors:
console.print(f" {name}: {msg}")
console.print()
raise typer.Exit(code=1)
if not results:
console.print("[dim]No containers to check.[/dim]")
else:
console.print("[bold green]All container versions are consistent![/bold green]")
if __name__ == "__main__":
app()