mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
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>
365 lines
11 KiB
Python
365 lines
11 KiB
Python
"""Guard: forbid raw `docker` / `docker compose` / `docker-compose` CLI
|
|
invocations in role tasks, vars, templates, and bundled shell scripts
|
|
under ``roles/``.
|
|
|
|
The project ships a `container` / `compose` wrapper around the underlying
|
|
engine so that swapping Docker for Podman (or another OCI runtime) does
|
|
not require a sweep of every role. This test fails when a file under
|
|
``roles/`` calls the raw CLI in a place that should go through the
|
|
wrapper. Bootstrap scripts and CI workflows live outside ``roles/`` and
|
|
are intentionally out of scope: the wrapper is not yet (or never) on
|
|
their PATH.
|
|
|
|
Four forms are detected:
|
|
|
|
1. Single-line shell invocations (start of line / after a shell separator)
|
|
like `docker exec ...`, `sudo docker run ...`, `docker compose up`,
|
|
`docker-compose up`.
|
|
|
|
2. Ansible argv-lists split across lines:
|
|
|
|
ansible.builtin.command:
|
|
argv:
|
|
- docker
|
|
- exec
|
|
- "{{ FOO_CONTAINER }}"
|
|
|
|
3. Inline shell/command scalars like
|
|
`ansible.builtin.shell: "docker exec ..."`.
|
|
|
|
4. The native `community.docker.docker_container_exec` Ansible module.
|
|
It executes a command inside an already-running container — a 1:1
|
|
replacement for `compose exec`/`container exec` exists, and pinning
|
|
to Docker via this module defeats engine-swappability. Other
|
|
`community.docker.*` modules (network create, host_info,
|
|
container start/stop with image+args) are deeper engine integrations
|
|
without clean wrapper equivalents and are intentionally not flagged.
|
|
|
|
Suppression
|
|
-----------
|
|
Use the unified marker grammar (rule key ``raw-docker``) documented at
|
|
``docs/contributing/actions/testing/suppression.md``:
|
|
|
|
* File-level: ``# nocheck: raw-docker`` anywhere in the first 30 lines
|
|
excludes the whole file. Reserve this for places where the wrapper
|
|
is genuinely unavailable (CI workflow files on hosted runners,
|
|
bootstrap scripts that install the wrapper itself).
|
|
* Per-line: ``# nocheck: raw-docker`` on the offending line or the
|
|
line directly above suppresses that single finding.
|
|
|
|
File enumeration and content reading both go through
|
|
``utils.cache.files``, so:
|
|
|
|
* The set of files to scan respects ``.gitignore`` (no other ignore
|
|
lists).
|
|
* The full project-tree walk is memoised process-wide via
|
|
``iter_non_ignored_files``'s ``lru_cache``.
|
|
* File contents are memoised via ``read_text``'s ``lru_cache``. Repeat
|
|
runs inside the same process (e.g. a pytest session that re-uses
|
|
imported modules) avoid both the re-walk and the re-read.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import unittest
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from utils.annotations.suppress import (
|
|
is_suppressed_at,
|
|
is_suppressed_in_head,
|
|
)
|
|
from utils.cache.base import PROJECT_ROOT
|
|
from utils.cache.files import iter_non_ignored_files, read_text
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
file: str
|
|
line_no: int
|
|
line: str
|
|
rule: str
|
|
suggestion: str
|
|
|
|
|
|
_CMD_PREFIX = r"""
|
|
(?:
|
|
^\s* # line start
|
|
| [;&(]\s* # ; & (
|
|
| \|\s* # pipe
|
|
| &&\s* # &&
|
|
| \|\|\s* # ||
|
|
| \$(?:\(|\{)\s* # $( or ${
|
|
)
|
|
"""
|
|
|
|
_DOCKER_BIN = r"(?:sudo\s+)?(?:/usr/bin/|/bin/|/usr/local/bin/)?docker"
|
|
_DOCKER_COMPOSE_BIN = r"(?:sudo\s+)?(?:/usr/bin/|/bin/|/usr/local/bin/)?docker-compose"
|
|
|
|
_DOCKER_SUBCOMMANDS = (
|
|
"run",
|
|
"exec",
|
|
"ps",
|
|
"inspect",
|
|
"logs",
|
|
"pull",
|
|
"push",
|
|
"build",
|
|
"login",
|
|
"logout",
|
|
"tag",
|
|
"rm",
|
|
"rmi",
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"kill",
|
|
"cp",
|
|
"info",
|
|
"version",
|
|
"events",
|
|
"stats",
|
|
"system",
|
|
"container",
|
|
"image",
|
|
"volume",
|
|
"network",
|
|
"manifest",
|
|
"buildx",
|
|
"builder",
|
|
"context",
|
|
)
|
|
|
|
_COMPOSE_VERBS = (
|
|
"up",
|
|
"down",
|
|
"pull",
|
|
"push",
|
|
"build",
|
|
"config",
|
|
"ps",
|
|
"logs",
|
|
"exec",
|
|
"run",
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"rm",
|
|
"create",
|
|
"images",
|
|
"top",
|
|
)
|
|
|
|
RE_DOCKER_CMD = re.compile(
|
|
rf"{_CMD_PREFIX}{_DOCKER_BIN}\s+(?:{'|'.join(map(re.escape, _DOCKER_SUBCOMMANDS))})\b",
|
|
re.IGNORECASE | re.VERBOSE,
|
|
)
|
|
|
|
RE_DOCKER_COMPOSE_CMD = re.compile(
|
|
rf"{_CMD_PREFIX}{_DOCKER_BIN}\s+compose\s+(?:{'|'.join(map(re.escape, _COMPOSE_VERBS))})\b",
|
|
re.IGNORECASE | re.VERBOSE,
|
|
)
|
|
|
|
RE_DOCKER_DASH_COMPOSE_CMD = re.compile(
|
|
rf"{_CMD_PREFIX}{_DOCKER_COMPOSE_BIN}\s+(?:{'|'.join(map(re.escape, _COMPOSE_VERBS))})\b",
|
|
re.IGNORECASE | re.VERBOSE,
|
|
)
|
|
|
|
RULES: tuple[tuple[str, re.Pattern, str], ...] = (
|
|
(
|
|
"docker compose usage",
|
|
RE_DOCKER_COMPOSE_CMD,
|
|
"Use 'compose <verb> ...' instead of 'docker compose <verb> ...'.",
|
|
),
|
|
(
|
|
"docker-compose usage",
|
|
RE_DOCKER_DASH_COMPOSE_CMD,
|
|
"Use 'compose <verb> ...' instead of 'docker-compose <verb> ...'.",
|
|
),
|
|
(
|
|
"docker CLI usage",
|
|
RE_DOCKER_CMD,
|
|
"Use 'container <cmd> ...' instead of calling 'docker <cmd> ...' directly.",
|
|
),
|
|
)
|
|
|
|
YAML_SUFFIXES: tuple[str, ...] = (".yml", ".yaml")
|
|
|
|
RE_ARGV_DOCKER_BLOCK = re.compile(
|
|
r"""argv:[ \t]*\r?\n"""
|
|
r"""(?:[ \t]*-[ \t]+[^\r\n]*\r?\n){0,40}?"""
|
|
r"""(?P<offender>[ \t]*-[ \t]+['"]?docker['"]?[ \t]*\r?\n)""",
|
|
)
|
|
|
|
RE_YAML_KEY_DOCKER_INLINE = re.compile(
|
|
rf"""^\s*(?:-\s*)?(?:ansible\.builtin\.)?(?:shell|command|raw|cmd)\s*:"""
|
|
rf"""\s*['"]?(?:sudo\s+)?(?:/usr/bin/|/bin/|/usr/local/bin/)?docker\s+"""
|
|
rf"""(?:{"|".join(map(re.escape, _DOCKER_SUBCOMMANDS))})\b""",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
RE_YAML_COMMUNITY_DOCKER_MODULE = re.compile(
|
|
r"""^\s*(?:-\s*)?(?P<module>community\.docker\.docker_container_exec)\s*:""",
|
|
)
|
|
|
|
|
|
def _line_no_at(text: str, offset: int) -> int:
|
|
return text.count("\n", 0, offset) + 1
|
|
|
|
|
|
def _scan_yaml_argv_and_inline(text: str, rel: str) -> list[Finding]:
|
|
findings: list[Finding] = []
|
|
|
|
for match in RE_ARGV_DOCKER_BLOCK.finditer(text):
|
|
offender_line_offset = match.start("offender")
|
|
offender_text = match.group("offender").rstrip("\r\n")
|
|
findings.append(
|
|
Finding(
|
|
file=rel,
|
|
line_no=_line_no_at(text, offender_line_offset),
|
|
line=offender_text,
|
|
rule="docker argv list-item",
|
|
suggestion=(
|
|
"Use 'container' as the first argv element (or rewrite as "
|
|
"'container <cmd> ...' shell scalar) so the engine stays "
|
|
"swappable."
|
|
),
|
|
)
|
|
)
|
|
|
|
for idx, line in enumerate(text.splitlines(), start=1):
|
|
if RE_YAML_KEY_DOCKER_INLINE.search(line):
|
|
findings.append(
|
|
Finding(
|
|
file=rel,
|
|
line_no=idx,
|
|
line=line.rstrip("\n"),
|
|
rule="docker inline scalar",
|
|
suggestion=(
|
|
"Replace the leading 'docker <cmd>' with 'container "
|
|
"<cmd>' in this shell/command scalar."
|
|
),
|
|
)
|
|
)
|
|
|
|
for idx, line in enumerate(text.splitlines(), start=1):
|
|
match = RE_YAML_COMMUNITY_DOCKER_MODULE.search(line)
|
|
if match:
|
|
findings.append(
|
|
Finding(
|
|
file=rel,
|
|
line_no=idx,
|
|
line=line.rstrip("\n"),
|
|
rule="community.docker module",
|
|
suggestion=(
|
|
f"Replace '{match.group('module')}' with an "
|
|
"'ansible.builtin.command'/'shell' invocation of the "
|
|
"'container' / 'compose' wrapper so the engine stays "
|
|
"swappable."
|
|
),
|
|
)
|
|
)
|
|
|
|
return findings
|
|
|
|
|
|
def scan_text(text: str, rel: str) -> list[Finding]:
|
|
"""Apply every rule to `text`. `rel` is the project-relative POSIX
|
|
path used for diagnostic messages and YAML-rule gating."""
|
|
findings: list[Finding] = []
|
|
seen_offenders = set()
|
|
|
|
for idx, line in enumerate(text.splitlines(), start=1):
|
|
for rule_name, pattern, suggestion in RULES:
|
|
if pattern.search(line):
|
|
findings.append(
|
|
Finding(
|
|
file=rel,
|
|
line_no=idx,
|
|
line=line.rstrip("\n"),
|
|
rule=rule_name,
|
|
suggestion=suggestion,
|
|
)
|
|
)
|
|
seen_offenders.add(idx)
|
|
break
|
|
|
|
if rel.endswith(YAML_SUFFIXES):
|
|
for finding in _scan_yaml_argv_and_inline(text, rel):
|
|
if finding.line_no in seen_offenders:
|
|
continue
|
|
findings.append(finding)
|
|
seen_offenders.add(finding.line_no)
|
|
|
|
return findings
|
|
|
|
|
|
def format_findings(findings: Sequence[Finding]) -> str:
|
|
lines: list[str] = []
|
|
lines.append("Forbidden raw Docker command invocations detected.")
|
|
lines.append("")
|
|
lines.append("Why this matters:")
|
|
lines.append(
|
|
"- We enforce a convenience wrapper ('container' / 'compose') so the container engine can be switched quickly"
|
|
)
|
|
lines.append(
|
|
" (e.g., Docker -> Podman) without refactoring command strings across the repo."
|
|
)
|
|
lines.append("")
|
|
lines.append("Fix rules:")
|
|
lines.append("- 'docker <cmd> ...' -> 'container <cmd> ...'")
|
|
lines.append("- 'docker compose <verb> ...' -> 'compose <verb> ...'")
|
|
lines.append("- 'docker-compose <verb> ...' -> 'compose <verb> ...'")
|
|
lines.append(
|
|
"- 'community.docker.docker_container_exec: ...' -> 'compose exec' / 'container exec' via ansible.builtin.command."
|
|
)
|
|
lines.append("")
|
|
lines.append("Findings:")
|
|
for f in findings:
|
|
lines.append(f"- {f.file}:{f.line_no}: {f.line.strip()}")
|
|
lines.append(f" -> {f.suggestion}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
SCAN_EXTENSIONS: tuple[str, ...] = (".yml", ".yaml", ".j2", ".sh")
|
|
|
|
SCAN_PATH_PREFIX: str = "roles/"
|
|
|
|
SUPPRESS_RULE: str = "raw-docker"
|
|
HEAD_SCAN_LINES: int = 30
|
|
|
|
|
|
class TestNoRawDockerCommands(unittest.TestCase):
|
|
def test_no_raw_docker_commands_in_roles(self) -> None:
|
|
findings: list[Finding] = []
|
|
project_root_str = str(PROJECT_ROOT)
|
|
for path in iter_non_ignored_files(extensions=SCAN_EXTENSIONS):
|
|
rel = os.path.relpath(path, project_root_str).replace(os.sep, "/")
|
|
if not rel.startswith(SCAN_PATH_PREFIX):
|
|
continue
|
|
try:
|
|
text = read_text(path)
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
lines = text.splitlines()
|
|
if is_suppressed_in_head(lines, SUPPRESS_RULE, scan_lines=HEAD_SCAN_LINES):
|
|
continue
|
|
for finding in scan_text(text, rel):
|
|
if is_suppressed_at(
|
|
lines, finding.line_no, SUPPRESS_RULE, mode="same-or-above"
|
|
):
|
|
continue
|
|
findings.append(finding)
|
|
|
|
if findings:
|
|
self.fail(format_findings(findings))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|