Files
computer-playbook/tests/integration/infrastructure/docker/test_no_raw_docker.py
T
kevinveenbirkenbachandClaude Opus 4.7 3d05803dc7 refactor(svc-db-mariadb, web-app-decidim): migrate docker_container_exec to container wrapper + extract SQL
Lint scanner now flags `community.docker.docker_container_exec` — it has
a 1:1 `compose exec`/`container exec` replacement and pinning to Docker
defeats engine-swappability. Other `community.docker.*` modules (network
create, host_info, container start with image+args) have no clean wrapper
equivalent and stay legal.

Migrations:
- svc-db-mariadb (01_core, 02_init, 03_reset_root_password): replace
  module calls with `container exec -i` via `ansible.builtin.command`,
  pipe SQL via stdin. Health probes share a single SELECT 1 file.
- web-app-decidim (01_setup): same migration, preserving env vars as
  `-e KEY=VAL` argv elements and stdin for rails-runner snippets.

Extract all SQL to `roles/svc-db-mariadb/files/sql/*.sql` as plain SQL
(no Jinja). Values flow as MariaDB user variables (`@db_name`, `@db_pwd`,
`@root_pwd`, …) via a `SET` prefix in the task stdin. Identifier
substitution (db/user/encoding names) uses `PREPARE` + `CONCAT(@var, …)`
since `@vars` cannot bind identifiers directly. Password values use
`QUOTE(@var)` inside SQL and Jinja `replace("'", "''")` on the task side.

Pin MariaDB to "11.8" (current LTS major). Required for `CREATE DATABASE`
in prepared statements, supported since 10.5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 00:39:36 +02:00

402 lines
14 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
# Only treat "docker ..." as a command when it appears in a command-like context.
# i.e. start of line or after common shell operators / subshell / command substitution.
_CMD_PREFIX = r"""
(?:
^\s* # line start
| [;&(]\s* # ; & (
| \|\s* # pipe
| &&\s* # &&
| \|\|\s* # ||
| \$(?:\(|\{)\s* # $( or ${
)
"""
# Optional sudo and optional absolute path to docker binary.
_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"
# Allowlist of real docker top-level subcommands that you consider "valid invocations".
# Extend when needed (keep it explicit to avoid false positives).
_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",
)
# Allowlist of compose verbs (docker compose <verb> / docker-compose <verb>)
_COMPOSE_VERBS = (
"up",
"down",
"pull",
"push",
"build",
"config",
"ps",
"logs",
"exec",
"run",
"start",
"stop",
"restart",
"rm",
"create",
"images",
"top",
)
# Compile patterns with VERBOSE for readability.
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: order matters (prefer specific messages)
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-only rules. The line-anchored RULES above cannot see two patterns
# that are extremely common in Ansible task files: argv lists split
# across lines, and inline scalars where docker follows a YAML key. Both
# must use the wrapper. Restrict these rules to .yml/.yaml files to keep
# unrelated text immune.
YAML_SUFFIXES: tuple[str, ...] = (".yml", ".yaml")
# Multi-line scan: any `argv:` block whose first/any element is the
# bareword `docker`. The intermediate lines (between `argv:` and the
# offending `- docker` item) must each themselves be list items so the
# match cannot jump across unrelated sections. The {0,40} cap keeps the
# search bounded.
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)""",
)
# Single-line scan: a Jinja/Ansible task key whose scalar value begins
# with `docker <subcommand>`. Matches both quoted and unquoted forms and
# both fully-qualified (`ansible.builtin.shell:`) and short keys.
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,
)
# Native `community.docker.docker_container_exec` Ansible module used as
# a YAML task key. It pins the playbook to Docker just as hard as raw
# `docker exec` CLI does and must be replaced by an
# `ansible.builtin.command`/`shell` invocation of the `compose exec` /
# `container exec` wrapper. Other `community.docker.*` modules
# (network create, container start with image+args, host_info) have no
# clean wrapper equivalent and are intentionally not flagged here.
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() # line_no — dedupe across single+multi-line passes
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)
# Extensions where the wrapper convention applies inside `roles/`:
# Ansible task/var/meta YAML, Jinja2 templates, and bundled shell
# scripts that ship as role files. Documentation (.md), JSON config,
# and Python sources under roles/ are out of scope.
SCAN_EXTENSIONS: tuple[str, ...] = (".yml", ".yaml", ".j2", ".sh")
# Project-relative path prefix that scopes the scan. Every file outside
# this prefix is skipped before any rule fires.
SCAN_PATH_PREFIX: str = "roles/"
# Rule key under the unified suppression grammar. See
# docs/contributing/actions/testing/suppression.md. File-level head
# scans cover the first 30 lines (matches the catalog default).
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()