Add check_compose.py compose pre-validator

This commit is contained in:
2026-05-08 09:37:48 +08:00
commit 51adc59953

169
check_compose.py Executable file
View 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())