Files
computer-playbook/roles/sys-svc-container/files/python/container.py
kevinveenbirkenbachandClaude Opus 5 b387c80528 fix(container): reject a directory standing in for a CA bind source
must_exist accepted any existing path, so a directory that dockerd auto-created where a bind source was missing passed the guard. The wrapper was then mounted over the container path and set as --entrypoint, and every image that has an ENTRYPOINT died with rc 126 'is a directory: permission denied' — seven identical retries, because the directory persists. Requiring a regular file routes that state into the existing 'CA injection disabled' fallback, which the same suite already exercises green when the wrapper is simply absent.

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

501 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
def die(msg: str, code: int = 2) -> None:
print(f"[container] {msg}", file=sys.stderr)
raise SystemExit(code)
def warn(msg: str) -> None:
print(f"[container][WARN] {msg}", file=sys.stderr)
def must_exist(path: str, label: str) -> str:
p = Path(path)
if not p.is_file():
die(f"{label} is not a regular file: {path}")
return str(p)
FLAGS_TAKE_VALUE = {
"-e",
"--env",
"--env-file",
"--network",
"--name",
"-v",
"--volume",
"-u",
"--user",
"-w",
"--workdir",
"--entrypoint",
"-p",
"--publish",
"--security-opt",
"--add-host",
"--dns",
"--dns-search",
"--dns-option",
"--label",
"-l",
"--hostname",
"-h",
"--platform",
"--restart",
"--pull",
"--runtime",
"--ipc",
"--pid",
"--cap-add",
"--cap-drop",
"--mount",
"--gpus",
"--group-add",
"--shm-size",
"--stop-timeout",
"--stop-signal",
"--log-driver",
"--log-opt",
}
def split_docker_run_argv(argv: list[str]) -> tuple[list[str], list[str]]:
"""
Split argv into:
- run_opts: docker run options (everything before IMAGE)
- image_and_args: [IMAGE, ...ARGS]
"""
if not argv:
die("Usage: container run [docker-run-flags...] IMAGE [COMMAND/ARGS...]")
run_opts: list[str] = []
i = 0
while i < len(argv):
a = argv[i]
if a == "--":
run_opts.append(a)
i += 1
break
if a.startswith("-"):
run_opts.append(a)
if a in FLAGS_TAKE_VALUE:
i += 1
if i >= len(argv):
die(f"docker run flag requires a value: {a}")
run_opts.append(argv[i])
i += 1
continue
break
if i >= len(argv):
die("Missing IMAGE argument")
return run_opts, argv[i:]
def extract_entrypoint(run_opts: list[str]) -> tuple[list[str], str | None]:
"""
Remove --entrypoint from run_opts and return (new_opts, entrypoint_value).
Supports:
--entrypoint sh
--entrypoint=sh
"""
out: list[str] = []
entrypoint: str | None = None
i = 0
while i < len(run_opts):
a = run_opts[i]
if a == "--entrypoint":
if i + 1 >= len(run_opts):
die("--entrypoint requires a value")
entrypoint = run_opts[i + 1]
i += 2
continue
if a.startswith("--entrypoint="):
entrypoint = a.split("=", 1)[1]
i += 1
continue
out.append(a)
i += 1
return out, entrypoint
def extract_pull_policy(run_opts: list[str]) -> str:
"""
Supported:
--pull always|missing|never
--pull=always|missing|never
Default: "missing"
"""
policy = "missing"
i = 0
while i < len(run_opts):
a = run_opts[i]
if a == "--pull":
if i + 1 < len(run_opts):
policy = str(run_opts[i + 1]).strip() or policy
i += 2
continue
if a.startswith("--pull="):
policy = a.split("=", 1)[1].strip() or policy
i += 1
continue
i += 1
policy = policy.lower()
if policy not in {"always", "missing", "never"}:
policy = "missing"
return policy
def docker_pull(image: str) -> None:
try:
p = subprocess.run(
["docker", "pull", image],
check=False,
capture_output=True,
text=True,
)
except FileNotFoundError:
die("docker not found. Please install Docker.", code=127)
if p.returncode != 0:
msg = (p.stderr or p.stdout or "").strip()
die(f"docker pull failed for {image}: {msg}", code=2)
def inspect_image_entrypoint(image: str) -> list[str]:
try:
p = subprocess.run(
[
"docker",
"image",
"inspect",
image,
"--format",
"{{json .Config.Entrypoint}}",
],
check=False,
capture_output=True,
text=True,
)
except FileNotFoundError:
die("docker not found. Please install Docker.", code=127)
if p.returncode != 0:
msg = (p.stderr or p.stdout or "").strip()
die(f"docker image inspect failed for {image}: {msg}", code=2)
raw = (p.stdout or "").strip()
if raw in ("", "null", "None"):
return []
try:
val = json.loads(raw)
except Exception:
return []
if isinstance(val, list):
return [str(x) for x in val]
if isinstance(val, str) and val:
return [val]
return []
def try_inspect_entrypoint_with_pull(image: str, pull_policy: str) -> list[str]:
if pull_policy == "always":
docker_pull(image)
try:
return inspect_image_entrypoint(image)
except SystemExit:
if pull_policy in {"missing", "always"}:
docker_pull(image)
return inspect_image_entrypoint(image)
raise
def require_ca_env_soft() -> tuple[str, str, str, str, str] | None:
"""
Return (ca_cert_host, wrapper_host, trust_name, ca_container,
wrapper_container) or None if CA injection is not available. The two
container-side paths come from the CA_TRUST group_vars SPOT, forwarded
as env like the host-side trio.
"""
ca_host = os.environ.get("CA_TRUST_CERT_HOST", "").strip()
wrapper_host = os.environ.get("CA_TRUST_WRAPPER_HOST", "").strip()
trust_name = os.environ.get("CA_TRUST_NAME", "").strip()
ca_container = os.environ.get("CA_TRUST_CERT_CONTAINER", "").strip()
wrapper_container = os.environ.get("CA_TRUST_WRAPPER_CONTAINER", "").strip()
host_missing = []
if not ca_host:
host_missing.append("CA_TRUST_CERT_HOST")
if not wrapper_host:
host_missing.append("CA_TRUST_WRAPPER_HOST")
if not trust_name:
host_missing.append("CA_TRUST_NAME")
container_missing = []
if not ca_container:
container_missing.append("CA_TRUST_CERT_CONTAINER")
if not wrapper_container:
container_missing.append("CA_TRUST_WRAPPER_CONTAINER")
if host_missing:
warn(
"CA injection disabled (missing env: "
+ ", ".join(host_missing + container_missing)
+ "). Falling back to plain 'docker run'."
)
return None
if container_missing:
die(
"CA injection misconfigured: "
+ ", ".join(container_missing)
+ " not set while the CA_TRUST host env is present. Redeploy the "
"systemd units / update the caller env to the CA_TRUST group_vars SPOT."
)
try:
ca_host = must_exist(ca_host, "CA trust certificate")
wrapper_host = must_exist(wrapper_host, "CA trust wrapper script")
except SystemExit:
warn(
"CA injection disabled (CA files not found). Falling back to plain 'docker run'."
)
return None
return ca_host, wrapper_host, trust_name, ca_container, wrapper_container
def exec_docker(cmd: list[str], debug: bool) -> int:
if debug:
print(">>> " + " ".join(shlex.quote(x) for x in cmd), file=sys.stderr)
try:
return subprocess.run(cmd, check=False).returncode
except FileNotFoundError:
die("docker not found. Please install Docker.", code=127)
except Exception as exc:
die(f"Unexpected error: {exc}", code=1)
def container_run(argv: list[str], debug: bool, with_ca: bool) -> int:
"""
Wrap docker run only if CA injection is available.
Otherwise fallback to plain docker run.
"""
if not argv:
die("Usage: container run [docker-run-flags...] IMAGE [COMMAND/ARGS...]")
if not with_ca:
return exec_docker(["docker", "run", *argv], debug=debug)
ca_env = require_ca_env_soft()
if not ca_env:
return exec_docker(["docker", "run", *argv], debug=debug)
ca_host, wrapper_host, trust_name, ca_container, wrapper_container = ca_env
run_opts, image_and_args = split_docker_run_argv(argv)
pull_policy = extract_pull_policy(run_opts)
run_opts, user_entrypoint = extract_entrypoint(run_opts)
image = image_and_args[0]
user_args = image_and_args[1:]
inject_opts: list[str] = [
"-v",
f"{ca_host}:{ca_container}:ro",
"-v",
f"{wrapper_host}:{wrapper_container}:ro",
"-e",
f"CA_TRUST_CERT={ca_container}",
"-e",
f"CA_TRUST_NAME={trust_name}",
"--entrypoint",
wrapper_container,
]
extra_opts: list[str] = []
extra_host = os.environ.get(
"CA_TRUST_CERT_EXTRA_HOST", "/opt/package-frontend-ca.crt"
).strip()
extra_path = Path(extra_host) if extra_host else None
if extra_path and extra_path.is_file() and extra_path.stat().st_size > 0:
extra_container = str(Path(ca_container).parent / "ca-trust-extra.crt")
extra_opts = [
"-v",
f"{extra_host}:{extra_container}:ro",
"-e",
f"CA_TRUST_CERT_EXTRA={extra_container}",
]
final_cmd: list[str] = ["docker", "run"]
final_cmd.extend(run_opts)
final_cmd.extend(inject_opts)
final_cmd.extend(extra_opts)
final_cmd.append(image)
if user_entrypoint:
final_cmd.append(user_entrypoint)
final_cmd.extend(user_args)
else:
ep = try_inspect_entrypoint_with_pull(image, pull_policy=pull_policy)
if not ep:
warn(
"Image has no ENTRYPOINT and none was provided. "
"Injecting CA env vars without entrypoint wrapper."
)
ca_inject_opts: list[str] = [
"-v",
f"{ca_host}:{ca_container}:ro",
"-e",
f"CA_TRUST_CERT={ca_container}",
"-e",
f"CA_TRUST_NAME={trust_name}",
"-e",
f"NODE_EXTRA_CA_CERTS={ca_container}",
"-e",
f"SSL_CERT_FILE={ca_container}",
"-e",
f"REQUESTS_CA_BUNDLE={ca_container}",
"-e",
f"CURL_CA_BUNDLE={ca_container}",
]
return exec_docker(
["docker", "run", *run_opts, *ca_inject_opts, image, *user_args],
debug=debug,
)
final_cmd.extend(ep)
final_cmd.extend(user_args)
if debug:
print(">>> " + " ".join(shlex.quote(x) for x in final_cmd), file=sys.stderr)
os.execvp(final_cmd[0], final_cmd) # noqa: S606 list-form exec, argv comes from the wrapper itself
return 0
def passthrough_any(subcmd: str, argv: list[str], debug: bool) -> int:
"""
Passthrough for ANY docker subcommand.
This keeps the wrapper future-proof so new docker subcommands don't require
code changes as long as callers use `container <subcmd> ...`.
"""
return exec_docker(["docker", subcmd, *argv], debug=debug)
_EXEC_TIMEOUT_UNITS = {"s": 1, "min": 60, "h": 3600, "d": 86400}
def parse_exec_timeout_seconds(value: str) -> int:
"""Parse a systemd-style duration ('60min', '7d', '45s', '2h') to seconds.
Args:
value: duration string; a bare number counts as seconds.
Returns:
The duration in whole seconds.
"""
raw = value.strip()
for suffix, factor in sorted(_EXEC_TIMEOUT_UNITS.items(), key=lambda i: -len(i[0])):
if raw.endswith(suffix):
return int(float(raw[: -len(suffix)])) * factor
return int(float(raw))
def exec_timeout_prefix() -> list[str]:
"""coreutils timeout prefix for `container exec` from CONTAINER_EXEC_TIMEOUT.
Returns:
['timeout', '--kill-after=15', '<seconds>'] when the env var holds a
positive duration; [] when it is unset, empty or 0 (no limit). Ansible
task timeouts are SIGALRM-based and sleep through a wedged docker
socket; this OS-level kill is the enforcement that still fires there.
"""
raw = os.environ.get("CONTAINER_EXEC_TIMEOUT", "").strip()
if not raw or raw == "0":
return []
try:
seconds = parse_exec_timeout_seconds(raw)
except ValueError:
die(f"Invalid CONTAINER_EXEC_TIMEOUT duration: {raw!r}")
if seconds <= 0:
return []
return ["timeout", "--kill-after=15", str(seconds)]
def main() -> int:
parser = argparse.ArgumentParser(
prog="container",
description="Infinito container wrapper (CA-aware docker wrapper).",
)
parser.add_argument(
"--debug", action="store_true", help="Print executed docker commands."
)
parser.add_argument(
"command",
nargs="?",
help="Subcommand: run|<any docker subcommand>|docker",
)
parser.add_argument("args", nargs=argparse.REMAINDER)
ns = parser.parse_args()
debug = bool(ns.debug)
cmd = (ns.command or "").strip()
args = list(ns.args)
if args and args[0] == "--":
args = args[1:]
if not cmd:
parser.print_help()
return 2
if cmd == "run":
return container_run(
args,
debug=debug,
with_ca=os.environ.get("CA_CONTAINER_ENABLED") == "1",
)
if cmd == "docker":
return exec_docker(["docker", *args], debug=debug)
if cmd == "exec":
return exec_docker(
[*exec_timeout_prefix(), "docker", "exec", *args], debug=debug
)
return passthrough_any(cmd, args, debug=debug)
if __name__ == "__main__":
raise SystemExit(main())