Files
computer-playbook/tests/integration/infrastructure/compose/test_templates_require_image.py
T
kevinveenbirkenbachandClaude Opus 5 5137465c6f style: delete every comment the comments-valid policy rejects
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.

Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:22:02 +02:00

197 lines
5.5 KiB
Python

# tests/integration/test_compose_templates_require_image_for_build.py
#
# Integration test:
# Ensures that every sys-svc-compose template/file that contains a `build:` mapping
# also contains an `image:` key on the same indentation level (same YAML mapping level).
#
# Checked files:
# - roles/*/templates/compose.yml.j2
# - roles/*/files/compose.yml
#
# Rationale:
# Your CA-injection logic (and other tooling) may need a stable image name to inspect
# entrypoint/cmd. Compose allows build without image, but then image names are not stable.
from __future__ import annotations
import re
import unittest
from dataclasses import dataclass
from typing import TYPE_CHECKING
from utils.cache.files import read_text
from utils.roles.mapping import ROLE_FILE_TEMPL_COMPOSE
from . import PROJECT_ROOT
if TYPE_CHECKING:
from pathlib import Path
BUILD_RE = re.compile(r"^(?P<indent>[ \t]*)build:\s*(#.*)?$")
IMAGE_RE = re.compile(r"^(?P<indent>[ \t]*)image:\s*(?P<rest>.+)?$")
CONTAINER_IMAGE_LOOKUP_RE = re.compile(
r"^(?P<indent>[ \t]*)\{\{\s*lookup\(\s*['\"]container_image['\"]\s*,"
)
COMMENTED_RE = re.compile(r"^\s*#")
BLANK_RE = re.compile(r"^\s*$")
@dataclass
class Finding:
file: str
build_line: int
build_indent: int
note: str
def _is_ignored_line(line: str) -> bool:
return bool(BLANK_RE.match(line) or COMMENTED_RE.match(line))
def _indent_len(indent: str) -> int:
return len(indent.replace("\t", " "))
def _block_bounds_for_key(
lines: list[str], idx: int, key_indent_len: int
) -> tuple[int, int]:
"""
Determine bounds of the YAML mapping block that contains the key at lines[idx],
based on indentation dropping below key_indent_len.
Returns (start_inclusive, end_exclusive).
"""
start = idx
j = idx - 1
while j >= 0:
ln = lines[j]
if _is_ignored_line(ln):
j -= 1
continue
cur_indent = len(ln) - len(ln.lstrip(" \t"))
cur_indent_len = _indent_len(ln[:cur_indent])
if cur_indent_len < key_indent_len:
break
start = j
j -= 1
end = idx + 1
k = idx + 1
while k < len(lines):
ln = lines[k]
if _is_ignored_line(ln):
k += 1
end = k
continue
cur_indent = len(ln) - len(ln.lstrip(" \t"))
cur_indent_len = _indent_len(ln[:cur_indent])
if cur_indent_len < key_indent_len:
break
k += 1
end = k
return start, end
def _has_image_same_indent(
lines: list[str], start: int, end: int, indent_str: str
) -> bool:
needle = f"{indent_str}image:"
container_image_lookup_needle = f"{indent_str}{{{{"
for i in range(start, end):
ln = lines[i]
if _is_ignored_line(ln):
continue
if ln.startswith(needle):
return True
if ln.startswith(
container_image_lookup_needle
) and CONTAINER_IMAGE_LOOKUP_RE.match(ln):
return True
return False
def _scan_file_for_missing_image(path: Path) -> list[Finding]:
try:
text = read_text(str(path))
except UnicodeDecodeError:
return []
lines = text.splitlines()
findings: list[Finding] = []
for idx, ln in enumerate(lines):
if _is_ignored_line(ln):
continue
m = BUILD_RE.match(ln)
if not m:
continue
indent_str = m.group("indent") or ""
indent_len = _indent_len(indent_str)
b_start, b_end = _block_bounds_for_key(lines, idx, indent_len)
if not _has_image_same_indent(lines, b_start, b_end, indent_str):
findings.append(
Finding(
file=str(path),
build_line=idx + 1,
build_indent=indent_len,
note="Found `build:` without `image:` at the same indentation level in the same mapping block.",
)
)
return findings
class TestComposeBuildRequiresImage(unittest.TestCase):
def test_all_compose_templates_and_files_have_image_for_build(self) -> None:
roles_dir = PROJECT_ROOT / "roles"
patterns = [
f"*/{ROLE_FILE_TEMPL_COMPOSE}",
"*/files/compose.yml",
]
targets: list[Path] = []
for pat in patterns:
targets.extend(sorted(roles_dir.glob(pat)))
self.assertTrue(
targets,
f"No compose templates/files found under {roles_dir} for patterns: {patterns}",
)
all_findings: list[Finding] = []
for f in targets:
all_findings.extend(_scan_file_for_missing_image(f))
if all_findings:
msg_lines = [
"Some sys-svc-compose templates/files contain a `build:` key but are missing an `image:` key at the same indentation level (same YAML mapping level).",
"",
"Offenders:",
]
msg_lines.extend(
f"- {it.file}:{it.build_line} (build indent={it.build_indent}) -> {it.note}"
for it in all_findings
)
msg_lines.append("")
msg_lines.append(
"Fix: for each service that has `build:`, add a stable `image:` name on the same level, e.g."
)
msg_lines.append(" service:")
msg_lines.append(" image: myorg/myimage:local")
msg_lines.append(" build:")
msg_lines.append(" context: .")
self.fail("\n".join(msg_lines))
if __name__ == "__main__":
unittest.main()