Public Access
167 lines
5.7 KiB
Python
167 lines
5.7 KiB
Python
"""Check that Docker Compose services with healthchecks have ``init: true``.
|
|
|
|
This prevents zombie process accumulation on production VMs. Without
|
|
``init: true``, Docker uses the container's PID 1 process to reap
|
|
child processes. Many images (especially those using CMD-SHELL
|
|
healthchecks with ``wget``) don't call ``wait()`` on children, causing
|
|
zombies to accumulate.
|
|
|
|
The check scans all Jinja2 docker-compose templates for services that
|
|
have a ``healthcheck:`` key but no ``init: true`` key. Since the
|
|
templates use Jinja2 syntax (not pure YAML), the check uses text-based
|
|
parsing to identify service blocks and their properties.
|
|
|
|
Usage::
|
|
|
|
python -m devx.tools.check_docker_init
|
|
python -m devx.tools.check_docker_init --path ansible/roles/observability/templates/docker-compose.yml.j2
|
|
|
|
Exit code 0 if all services with healthchecks have init: true, 1 otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
REPO_ROOT = Path.cwd()
|
|
DEFAULT_TEMPLATES_DIR = REPO_ROOT / "ansible" / "roles"
|
|
|
|
|
|
def _find_compose_templates(base: Path) -> list[Path]:
|
|
"""Find all Jinja2 docker-compose templates under a base directory."""
|
|
if base.is_file():
|
|
return [base]
|
|
if not base.is_dir():
|
|
return []
|
|
results: list[Path] = []
|
|
for pattern in ("*docker-compose*", "*compose*"):
|
|
results.extend(base.rglob(f"{pattern}.yml.j2"))
|
|
results.extend(base.rglob(f"{pattern}.yaml.j2"))
|
|
# Also check exporters-compose
|
|
results.extend(base.rglob("exporters-compose*.j2"))
|
|
# Deduplicate while preserving order
|
|
seen: set[Path] = set()
|
|
unique: list[Path] = []
|
|
for p in sorted(results):
|
|
if p not in seen:
|
|
seen.add(p)
|
|
unique.append(p)
|
|
return unique
|
|
|
|
|
|
def _parse_services(content: str) -> dict[str, list[str]]:
|
|
"""Parse service blocks from a docker-compose Jinja2 template.
|
|
|
|
Returns a mapping of service_name → list of lines in that service block.
|
|
"""
|
|
lines = content.splitlines()
|
|
in_services = False
|
|
services: dict[str, list[str]] = {}
|
|
current_svc: str | None = None
|
|
current_lines: list[str] = []
|
|
|
|
for line in lines:
|
|
if line.startswith("services:"):
|
|
in_services = True
|
|
continue
|
|
if not in_services:
|
|
continue
|
|
# Top-level keys (networks:, volumes:) end the services section
|
|
if re.match(r"^(networks|volumes):\s*$", line):
|
|
if current_svc is not None:
|
|
services[current_svc] = current_lines
|
|
current_svc = None
|
|
in_services = False
|
|
continue
|
|
# Service definition: exactly 2-space indent, ends with :
|
|
# Service names can contain Jinja2 variables like {{ app_name }}
|
|
# or {{ app_name }}-db. Match: 2-space indent + non-whitespace
|
|
# chars (including {{ }}, -, _, .) + optional spaces inside {{ }} + :
|
|
m = re.match(r"^ (\{\{.*?\}\}[a-zA-Z0-9_-]*|[a-zA-Z0-9_().-]+):\s*$", line)
|
|
if m:
|
|
if current_svc is not None:
|
|
services[current_svc] = current_lines
|
|
current_svc = m.group(1)
|
|
current_lines = []
|
|
elif current_svc is not None:
|
|
current_lines.append(line)
|
|
|
|
if current_svc is not None:
|
|
services[current_svc] = current_lines
|
|
|
|
return services
|
|
|
|
|
|
def _check_template(filepath: Path, repo_root: Path) -> list[str]:
|
|
"""Check a single docker-compose template for missing init: true.
|
|
|
|
Returns a list of error messages (empty if all OK).
|
|
"""
|
|
errors: list[str] = []
|
|
content = filepath.read_text(encoding="utf-8")
|
|
|
|
if "services:" not in content:
|
|
return errors
|
|
|
|
services = _parse_services(content)
|
|
|
|
for svc_name, svc_lines in services.items():
|
|
svc_text = "\n".join(svc_lines)
|
|
has_init = "init: true" in svc_text
|
|
has_healthcheck = "healthcheck:" in svc_text
|
|
# Skip services that are conditionally included (Jinja2 if blocks)
|
|
# but still check them — the healthcheck is inside the conditional
|
|
if has_healthcheck and not has_init:
|
|
try:
|
|
display_path = filepath.relative_to(repo_root)
|
|
except ValueError:
|
|
display_path = filepath
|
|
errors.append(
|
|
f"{display_path}: service '{svc_name}' has a healthcheck "
|
|
f"but no 'init: true'. Without init: true, CMD-SHELL "
|
|
f"healthchecks (wget, pgrep) spawn children that become "
|
|
f"zombies when PID 1 doesn't reap them. Add 'init: true' "
|
|
f"to enable Docker's built-in tini as PID 1."
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
@click.command()
|
|
@click.option(
|
|
"--path",
|
|
type=click.Path(exists=True, path_type=Path),
|
|
help="Check a specific file or directory (default: ansible/roles/).",
|
|
)
|
|
@click.option(
|
|
"--templates-dir",
|
|
type=click.Path(exists=True, path_type=Path),
|
|
default=None,
|
|
help="Override the default templates directory (default: ansible/roles/).",
|
|
)
|
|
def main(path: Path | None, templates_dir: Path | None) -> None:
|
|
"""Check that Docker Compose services with healthchecks have init: true."""
|
|
tdir = templates_dir or DEFAULT_TEMPLATES_DIR
|
|
files = _find_compose_templates(path) if path else _find_compose_templates(tdir)
|
|
|
|
all_errors: list[str] = []
|
|
for f in files:
|
|
errors = _check_template(f, tdir)
|
|
all_errors.extend(errors)
|
|
|
|
if all_errors:
|
|
click.echo("[check-docker-init] FAIL: services with healthchecks missing init: true:")
|
|
for err in all_errors:
|
|
click.echo(f" - {err}")
|
|
sys.exit(1)
|
|
else:
|
|
click.echo("[check-docker-init] OK: all services with healthchecks have init: true.")
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
main()
|