Add check_compose.py compose pre-validator
This commit is contained in:
169
check_compose.py
Executable file
169
check_compose.py
Executable file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""dtio Docker Compose YAML pre-validator.
|
||||
|
||||
This checker scans a Compose YAML file and reports multiple likely issues in one run.
|
||||
It is a best-effort validator and does not catch every possible error.
|
||||
|
||||
Implemented checks:
|
||||
- missing ":" after a YAML key (for example: "ports" instead of "ports:")
|
||||
- tab characters (YAML should use spaces)
|
||||
- odd indentation width warnings
|
||||
- service-level keys that should be list-shaped (`ports`, `volumes`, `tmpfs`)
|
||||
- top-level keys accidentally nested under `services` due to indentation issues
|
||||
- unexpected top-level keys (warning only)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
KEY_ONLY_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*$")
|
||||
KEY_WITH_COLON_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(?:#.*)?$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
"""A single validation finding tied to one source line."""
|
||||
|
||||
line: int
|
||||
level: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
"""Track YAML path context while walking by indentation."""
|
||||
|
||||
indent: int
|
||||
key: str
|
||||
|
||||
|
||||
def strip_inline_comment(line: str) -> str:
|
||||
"""Return a line without trailing inline comment text and newline."""
|
||||
if "#" not in line:
|
||||
return line.rstrip("\n")
|
||||
return line.split("#", 1)[0].rstrip("\n")
|
||||
|
||||
|
||||
def first_content_indent(lines: List[str], start_idx: int, parent_indent: int) -> Optional[tuple[int, str, int]]:
|
||||
"""Find the first non-empty child line deeper than `parent_indent`.
|
||||
|
||||
Returns `(index, stripped_text, indent)` or `None` if no child exists.
|
||||
"""
|
||||
for i in range(start_idx, len(lines)):
|
||||
raw = strip_inline_comment(lines[i])
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
indent = len(raw) - len(raw.lstrip(" "))
|
||||
if indent <= parent_indent:
|
||||
return None
|
||||
return i, stripped, indent
|
||||
return None
|
||||
|
||||
|
||||
def validate_text(lines: List[str]) -> List[Finding]:
|
||||
"""Run text/structure checks and return all collected findings."""
|
||||
findings: List[Finding] = []
|
||||
stack: List[Frame] = []
|
||||
|
||||
for idx, raw_line in enumerate(lines, start=1):
|
||||
if "\t" in raw_line:
|
||||
findings.append(Finding(idx, "error", "tab detected; use spaces only"))
|
||||
|
||||
content = strip_inline_comment(raw_line)
|
||||
stripped = content.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
indent = len(content) - len(content.lstrip(" "))
|
||||
if indent % 2 != 0:
|
||||
findings.append(Finding(idx, "warning", f"odd indentation ({indent} spaces); YAML is indentation-sensitive"))
|
||||
|
||||
if KEY_ONLY_RE.match(content):
|
||||
key = KEY_ONLY_RE.match(content).group(1)
|
||||
findings.append(Finding(idx, "error", f"key '{key}' is missing ':'"))
|
||||
|
||||
while stack and indent <= stack[-1].indent:
|
||||
stack.pop()
|
||||
|
||||
key_match = KEY_WITH_COLON_RE.match(content)
|
||||
if key_match:
|
||||
key = key_match.group(1)
|
||||
stack.append(Frame(indent=indent, key=key))
|
||||
|
||||
path = [f.key for f in stack]
|
||||
in_service_scope = len(path) >= 3 and path[0] == "services"
|
||||
in_services_map = len(path) == 2 and path[0] == "services"
|
||||
|
||||
# Under services.<name> these should be list-shaped in this repo's demos.
|
||||
if in_service_scope and key in {"ports", "volumes", "tmpfs"}:
|
||||
child = first_content_indent(lines, idx, indent)
|
||||
if child is not None:
|
||||
child_line, child_stripped, _ = child
|
||||
if not child_stripped.startswith("-"):
|
||||
findings.append(
|
||||
Finding(
|
||||
child_line + 1,
|
||||
"error",
|
||||
f"'{key}' under service should be a YAML list item (expected '-' prefix)",
|
||||
)
|
||||
)
|
||||
|
||||
# Heuristic: these keys are usually top-level in Compose and are often
|
||||
# accidentally indented under services due to alignment mistakes.
|
||||
if in_services_map and key in {"volumes", "networks", "configs", "secrets"}:
|
||||
findings.append(
|
||||
Finding(
|
||||
idx,
|
||||
"error",
|
||||
f"'{key}:' appears under 'services:'; likely indentation error (expected top-level '{key}:')",
|
||||
)
|
||||
)
|
||||
|
||||
# Top-level sanity hints.
|
||||
if len(path) == 1 and key not in {"services", "volumes", "networks", "configs", "secrets", "name"}:
|
||||
findings.append(Finding(idx, "warning", f"unexpected top-level key '{key}' for a Compose file"))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def print_findings(findings: List[Finding], file_path: Path) -> int:
|
||||
"""Print findings in a stable format and return a process exit code."""
|
||||
if not findings:
|
||||
print(f"OK: no issues found by pre-validator in {file_path}")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(findings)} issue(s) in {file_path}:")
|
||||
for f in findings:
|
||||
print(f" {f.level.upper():7} line {f.line:>4}: {f.message}")
|
||||
|
||||
return 1 if any(f.level == "error" for f in findings) else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Parse arguments, read file, run validation, and return exit code."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Best-effort pre-validator for Docker Compose YAML (multi-issue reporting)."
|
||||
)
|
||||
parser.add_argument("file", nargs="?", default="compose.yaml", help="compose file path (default: compose.yaml)")
|
||||
args = parser.parse_args()
|
||||
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"ERROR: file not found: {file_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
lines = file_path.read_text(encoding="utf-8").splitlines()
|
||||
findings = validate_text(lines)
|
||||
return print_findings(findings, file_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user