mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
A transient Debian/Ubuntu mirror fetch aborts a docker build layer with exit 100 (it killed the web-app-nextcloud swarm openldap build while the same image built fine elsewhere). New lint under tests/lint/docker/dockerfile/apt/ flags any RUN with apt-get update/install that omits Acquire::Retries; retrofit all 15 apt Dockerfiles with -o Acquire::Retries=3. Both apt lints move into the apt/ subpackage. dnf retries by default and apk has no equivalent flag, so both stay out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
2.8 KiB
Python
93 lines
2.8 KiB
Python
"""Every Dockerfile ``RUN`` calling ``apt-get update``/``install`` must pass
|
|
``Acquire::Retries`` so a transient mirror fetch retries instead of aborting the
|
|
build with exit 100 (``dnf`` retries by default, ``apk`` has no flag)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unittest
|
|
from typing import TYPE_CHECKING
|
|
|
|
from utils.cache.files import read_text
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_REPO_ROOT = PROJECT_ROOT
|
|
_ROLES_ROOT = _REPO_ROOT / "roles"
|
|
|
|
_J2_CTRL_RE = re.compile(r"\{%-?.*?-?%\}", re.DOTALL)
|
|
_RUN_START_RE = re.compile(r"^\s*RUN\b", re.IGNORECASE)
|
|
_APT_CMD_RE = re.compile(
|
|
r"\bapt-get\b(?:\s+-\S+(?:\s+[^-\s]\S*)?)*\s+(?:update|install)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_RETRIES_RE = re.compile(r"Acquire::Retries", re.IGNORECASE)
|
|
|
|
|
|
def _collect_dockerfiles() -> list[Path]:
|
|
paths: set[Path] = set()
|
|
paths.update(_ROLES_ROOT.glob("*/files/Dockerfile"))
|
|
paths.update(_ROLES_ROOT.glob("*/files/**/Dockerfile"))
|
|
paths.update(_ROLES_ROOT.glob("*/templates/Dockerfile*.j2"))
|
|
return sorted(paths)
|
|
|
|
|
|
def _iter_run_blocks(source: str):
|
|
cleaned = _J2_CTRL_RE.sub("", source)
|
|
lines = cleaned.splitlines()
|
|
i = 0
|
|
while i < len(lines):
|
|
if not _RUN_START_RE.match(lines[i]):
|
|
i += 1
|
|
continue
|
|
start = i + 1
|
|
buf = [lines[i]]
|
|
while lines[i].rstrip().endswith("\\") and i + 1 < len(lines):
|
|
i += 1
|
|
buf.append(lines[i])
|
|
yield start, "\n".join(buf)
|
|
i += 1
|
|
|
|
|
|
def _violations(dockerfile: Path) -> list[str]:
|
|
relative = dockerfile.relative_to(_REPO_ROOT).as_posix()
|
|
failures: list[str] = []
|
|
source = read_text(str(dockerfile))
|
|
for lineno, block in _iter_run_blocks(source):
|
|
if not _APT_CMD_RE.search(block):
|
|
continue
|
|
if _RETRIES_RE.search(block):
|
|
continue
|
|
failures.append(
|
|
f"{relative}:{lineno}: RUN calls `apt-get update`/`install` "
|
|
"without `Acquire::Retries`. A transient mirror fetch aborts the "
|
|
"build with exit 100. Add `-o Acquire::Retries=3` to the apt-get "
|
|
"call (or write it to apt.conf in the same RUN)."
|
|
)
|
|
return failures
|
|
|
|
|
|
class TestDockerfileAptRetries(unittest.TestCase):
|
|
def test_apt_calls_set_acquire_retries(self) -> None:
|
|
self.assertTrue(
|
|
_ROLES_ROOT.is_dir(),
|
|
f"'roles' directory not found at: {_ROLES_ROOT}",
|
|
)
|
|
|
|
failures: list[str] = []
|
|
for path in _collect_dockerfiles():
|
|
failures.extend(_violations(path))
|
|
|
|
self.assertFalse(
|
|
failures,
|
|
"Dockerfile apt-get retry-hardening contract violated:\n\n"
|
|
+ "\n".join(f" {f}" for f in failures),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|