From 80d2f8da02052f64396da6b8caaf820eedbf18e2 Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Mon, 5 Dec 2022 13:33:14 -0800 Subject: ansible-test - Fix container detection. (#79530) --- .../ansible-test-container-management.yml | 4 ++ test/lib/ansible_test/_internal/cgroup.py | 49 ++++++++++++++---- .../_internal/commands/env/__init__.py | 4 ++ .../ansible_test/_internal/dev/container_probe.py | 2 +- test/lib/ansible_test/_internal/docker_util.py | 58 +++++++++++++++------- 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/changelogs/fragments/ansible-test-container-management.yml b/changelogs/fragments/ansible-test-container-management.yml index 3dd182aaab..04961b98ee 100644 --- a/changelogs/fragments/ansible-test-container-management.yml +++ b/changelogs/fragments/ansible-test-container-management.yml @@ -38,6 +38,7 @@ minor_changes: - ansible-test - Integration tests can be excluded from retries triggered by the ``--retry-on-error`` option by adding the ``retry/never`` alias. This is useful for tests that cannot pass on a retry or are too slow to make retries useful. + - ansible-test - The ``ansible-test env`` command now detects and reports the container ID if running in a container. bugfixes: - ansible-test - Multiple containers now work under Podman without specifying the ``--docker-network`` option. - ansible-test - Prevent concurrent / repeat pulls of the same container image. @@ -47,6 +48,9 @@ bugfixes: - ansible-test - Show the exception type when reporting errors during instance provisioning. - ansible-test - Pass the ``XDG_RUNTIME_DIR`` environment variable through to container commands. - ansible-test - Connection attempts to managed remote instances no longer abort on ``Permission denied`` errors. + - ansible-test - Detection for running in a Podman or Docker container has been fixed to detect more scenarios. + The new detection relies on ``/proc/self/mountinfo`` instead of ``/proc/self/cpuset``. + Detection now works with custom cgroups and private cgroup namespaces. known_issues: - ansible-test - Using Docker on systems with SELinux may require setting SELinux to permissive mode. Podman should work with SELinux in enforcing mode. diff --git a/test/lib/ansible_test/_internal/cgroup.py b/test/lib/ansible_test/_internal/cgroup.py index 66a72c9a7f..b55d878dc3 100644 --- a/test/lib/ansible_test/_internal/cgroup.py +++ b/test/lib/ansible_test/_internal/cgroup.py @@ -1,8 +1,10 @@ """Linux control group constants, classes and utilities.""" from __future__ import annotations +import codecs import dataclasses import pathlib +import re class CGroupPath: @@ -55,25 +57,54 @@ class CGroupEntry: @dataclasses.dataclass(frozen=True) class MountEntry: - """A single mount entry parsed from '/proc/{pid}/mounts' in the proc filesystem.""" - device: pathlib.PurePosixPath + """A single mount info entry parsed from '/proc/{pid}/mountinfo' in the proc filesystem.""" + mount_id: int + parent_id: int + device_major: int + device_minor: int + root: pathlib.PurePosixPath path: pathlib.PurePosixPath - type: str options: tuple[str, ...] + fields: tuple[str, ...] + type: str + source: pathlib.PurePosixPath + super_options: tuple[str, ...] @classmethod def parse(cls, value: str) -> MountEntry: - """Parse the given mount line from the proc filesystem and return a mount entry.""" - device, path, mtype, options, _a, _b = value.split(' ') + """Parse the given mount info line from the proc filesystem and return a mount entry.""" + # See: https://man7.org/linux/man-pages/man5/proc.5.html + # See: https://github.com/torvalds/linux/blob/aea23e7c464bfdec04b52cf61edb62030e9e0d0a/fs/proc_namespace.c#L135 + mount_id, parent_id, device_major_minor, root, path, options, *remainder = value.split(' ') + fields = remainder[:-4] + separator, mtype, source, super_options = remainder[-4:] + + assert separator == '-' + + device_major, device_minor = device_major_minor.split(':') return cls( - device=pathlib.PurePosixPath(device), - path=pathlib.PurePosixPath(path), - type=mtype, + mount_id=int(mount_id), + parent_id=int(parent_id), + device_major=int(device_major), + device_minor=int(device_minor), + root=_decode_path(root), + path=_decode_path(path), options=tuple(options.split(',')), + fields=tuple(fields), + type=mtype, + source=_decode_path(source), + super_options=tuple(super_options.split(',')), ) @classmethod def loads(cls, value: str) -> tuple[MountEntry, ...]: - """Parse the given output from the proc filesystem and return a tuple of mount entries.""" + """Parse the given output from the proc filesystem and return a tuple of mount info entries.""" return tuple(cls.parse(line) for line in value.splitlines()) + + +def _decode_path(value: str) -> pathlib.PurePosixPath: + """Decode and return a path which may contain octal escape sequences.""" + # See: https://github.com/torvalds/linux/blob/aea23e7c464bfdec04b52cf61edb62030e9e0d0a/fs/proc_namespace.c#L150 + path = re.sub(r'(\\[0-7]{3})', lambda m: codecs.decode(m.group(0).encode('ascii'), 'unicode_escape'), value) + return pathlib.PurePosixPath(path) diff --git a/test/lib/ansible_test/_internal/commands/env/__init__.py b/test/lib/ansible_test/_internal/commands/env/__init__.py index d11c4765d9..44f229f879 100644 --- a/test/lib/ansible_test/_internal/commands/env/__init__.py +++ b/test/lib/ansible_test/_internal/commands/env/__init__.py @@ -31,6 +31,7 @@ from ...util_common import ( from ...docker_util import ( get_docker_command, get_docker_info, + get_docker_container_id, ) from ...constants import ( @@ -69,11 +70,14 @@ def show_dump_env(args: EnvConfig) -> None: if not args.show and not args.dump: return + container_id = get_docker_container_id() + data = dict( ansible=dict( version=get_ansible_version(), ), docker=get_docker_details(args), + container_id=container_id, environ=os.environ.copy(), location=dict( pwd=os.environ.get('PWD', None), diff --git a/test/lib/ansible_test/_internal/dev/container_probe.py b/test/lib/ansible_test/_internal/dev/container_probe.py index 0a6a3b20f6..84b88f4bb6 100644 --- a/test/lib/ansible_test/_internal/dev/container_probe.py +++ b/test/lib/ansible_test/_internal/dev/container_probe.py @@ -73,7 +73,7 @@ class CGroupMount: def check_container_cgroup_status(args: EnvironmentConfig, config: DockerConfig, container_name: str, expected_mounts: tuple[CGroupMount, ...]) -> None: """Check the running container to examine the state of the cgroup hierarchies.""" - cmd = ['sh', '-c', 'cat /proc/1/cgroup && echo && cat /proc/1/mounts'] + cmd = ['sh', '-c', 'cat /proc/1/cgroup && echo && cat /proc/1/mountinfo'] stdout = docker_exec(args, container_name, cmd, capture=True)[0] cgroups_stdout, mounts_stdout = stdout.split('\n\n') diff --git a/test/lib/ansible_test/_internal/docker_util.py b/test/lib/ansible_test/_internal/docker_util.py index efbebb6c6b..fbaca78630 100644 --- a/test/lib/ansible_test/_internal/docker_util.py +++ b/test/lib/ansible_test/_internal/docker_util.py @@ -6,15 +6,12 @@ import enum import json import os import pathlib +import re import socket import time import urllib.parse import typing as t -from .io import ( - read_text_file, -) - from .util import ( ApplicationError, common_environment, @@ -297,7 +294,7 @@ def detect_host_properties(args: CommonConfig) -> ContainerHostProperties: multi_line_commands = ( ' && '.join(single_line_commands), 'cat /proc/1/cgroup', - 'cat /proc/1/mounts', + 'cat /proc/1/mountinfo', ) options = ['--volume', '/sys/fs/cgroup:/probe:ro'] @@ -573,24 +570,47 @@ def get_podman_hostname() -> str: @cache def get_docker_container_id() -> t.Optional[str]: """Return the current container ID if running in a container, otherwise return None.""" - path = '/proc/self/cpuset' + mountinfo_path = pathlib.Path('/proc/self/mountinfo') container_id = None + engine = None + + if mountinfo_path.is_file(): + # NOTE: This method of detecting the container engine and container ID relies on implementation details of each container engine. + # Although the implementation details have remained unchanged for some time, there is no guarantee they will continue to work. + # There have been proposals to create a standard mechanism for this, but none is currently available. + # See: https://github.com/opencontainers/runtime-spec/issues/1105 + + mounts = MountEntry.loads(mountinfo_path.read_text()) + + for mount in mounts: + if str(mount.path) == '/etc/hostname': + # Podman generates /etc/hostname in the makePlatformBindMounts function. + # That function ends up using ContainerRunDirectory to generate a path like: {prefix}/{container_id}/userdata/hostname + # NOTE: The {prefix} portion of the path can vary, so should not be relied upon. + # See: https://github.com/containers/podman/blob/480c7fbf5361f3bd8c1ed81fe4b9910c5c73b186/libpod/container_internal_linux.go#L660-L664 + # See: https://github.com/containers/podman/blob/480c7fbf5361f3bd8c1ed81fe4b9910c5c73b186/vendor/github.com/containers/storage/store.go#L3133 + # This behavior has existed for ~5 years and was present in Podman version 0.2. + # See: https://github.com/containers/podman/pull/248 + if match := re.search('/(?P[0-9a-f]{64})/userdata/hostname$', str(mount.root)): + container_id = match.group('id') + engine = 'Podman' + break - if os.path.exists(path): - # File content varies based on the environment: - # No Container: / - # Docker: /docker/c86f3732b5ba3d28bb83b6e14af767ab96abbc52de31313dcb1176a62d91a507 - # Azure Pipelines (Docker): /azpl_job/0f2edfed602dd6ec9f2e42c867f4d5ee640ebf4c058e6d3196d4393bb8fd0891 - # Podman: /../../../../../.. - contents = read_text_file(path) - - cgroup_path, cgroup_name = os.path.split(contents.strip()) - - if cgroup_path in ('/docker', '/azpl_job'): - container_id = cgroup_name + # Docker generates /etc/hostname in the BuildHostnameFile function. + # That function ends up using the containerRoot function to generate a path like: {prefix}/{container_id}/hostname + # NOTE: The {prefix} portion of the path can vary, so should not be relied upon. + # See: https://github.com/moby/moby/blob/cd8a090e6755bee0bdd54ac8a894b15881787097/container/container_unix.go#L58 + # See: https://github.com/moby/moby/blob/92e954a2f05998dc05773b6c64bbe23b188cb3a0/daemon/container.go#L86 + # This behavior has existed for at least ~7 years and was present in Docker version 1.0.1. + # See: https://github.com/moby/moby/blob/v1.0.1/daemon/container.go#L351 + # See: https://github.com/moby/moby/blob/v1.0.1/daemon/daemon.go#L133 + if match := re.search('/(?P[0-9a-f]{64})/hostname$', str(mount.root)): + container_id = match.group('id') + engine = 'Docker' + break if container_id: - display.info('Detected execution in Docker container: %s' % container_id, verbosity=1) + display.info(f'Detected execution in {engine} container ID: {container_id}', verbosity=1) return container_id -- cgit v1.2.1