ansible-test - Fix container detection. (#79530)

pull/79541/head
Matt Clay 3 years ago committed by GitHub
parent 31f95e201a
commit 80d2f8da02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -38,6 +38,7 @@ minor_changes:
- ansible-test - Integration tests can be excluded from retries triggered by the ``--retry-on-error`` option by - 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 adding the ``retry/never`` alias. This is useful for tests that cannot pass on a retry or are too
slow to make retries useful. 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: bugfixes:
- ansible-test - Multiple containers now work under Podman without specifying the ``--docker-network`` option. - 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. - 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 - 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 - 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 - 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: known_issues:
- ansible-test - Using Docker on systems with SELinux may require setting SELinux to permissive mode. - ansible-test - Using Docker on systems with SELinux may require setting SELinux to permissive mode.
Podman should work with SELinux in enforcing mode. Podman should work with SELinux in enforcing mode.

@ -1,8 +1,10 @@
"""Linux control group constants, classes and utilities.""" """Linux control group constants, classes and utilities."""
from __future__ import annotations from __future__ import annotations
import codecs
import dataclasses import dataclasses
import pathlib import pathlib
import re
class CGroupPath: class CGroupPath:
@ -55,25 +57,54 @@ class CGroupEntry:
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class MountEntry: class MountEntry:
"""A single mount entry parsed from '/proc/{pid}/mounts' in the proc filesystem.""" """A single mount info entry parsed from '/proc/{pid}/mountinfo' in the proc filesystem."""
device: pathlib.PurePosixPath mount_id: int
parent_id: int
device_major: int
device_minor: int
root: pathlib.PurePosixPath
path: pathlib.PurePosixPath path: pathlib.PurePosixPath
type: str
options: tuple[str, ...] options: tuple[str, ...]
fields: tuple[str, ...]
type: str
source: pathlib.PurePosixPath
super_options: tuple[str, ...]
@classmethod @classmethod
def parse(cls, value: str) -> MountEntry: def parse(cls, value: str) -> MountEntry:
"""Parse the given mount line from the proc filesystem and return a mount entry.""" """Parse the given mount info line from the proc filesystem and return a mount entry."""
device, path, mtype, options, _a, _b = value.split(' ') # 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( return cls(
device=pathlib.PurePosixPath(device), mount_id=int(mount_id),
path=pathlib.PurePosixPath(path), parent_id=int(parent_id),
type=mtype, device_major=int(device_major),
device_minor=int(device_minor),
root=_decode_path(root),
path=_decode_path(path),
options=tuple(options.split(',')), options=tuple(options.split(',')),
fields=tuple(fields),
type=mtype,
source=_decode_path(source),
super_options=tuple(super_options.split(',')),
) )
@classmethod @classmethod
def loads(cls, value: str) -> tuple[MountEntry, ...]: 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()) 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)

@ -31,6 +31,7 @@ from ...util_common import (
from ...docker_util import ( from ...docker_util import (
get_docker_command, get_docker_command,
get_docker_info, get_docker_info,
get_docker_container_id,
) )
from ...constants import ( from ...constants import (
@ -69,11 +70,14 @@ def show_dump_env(args: EnvConfig) -> None:
if not args.show and not args.dump: if not args.show and not args.dump:
return return
container_id = get_docker_container_id()
data = dict( data = dict(
ansible=dict( ansible=dict(
version=get_ansible_version(), version=get_ansible_version(),
), ),
docker=get_docker_details(args), docker=get_docker_details(args),
container_id=container_id,
environ=os.environ.copy(), environ=os.environ.copy(),
location=dict( location=dict(
pwd=os.environ.get('PWD', None), pwd=os.environ.get('PWD', None),

@ -73,7 +73,7 @@ class CGroupMount:
def check_container_cgroup_status(args: EnvironmentConfig, config: DockerConfig, container_name: str, expected_mounts: tuple[CGroupMount, ...]) -> None: 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.""" """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] stdout = docker_exec(args, container_name, cmd, capture=True)[0]
cgroups_stdout, mounts_stdout = stdout.split('\n\n') cgroups_stdout, mounts_stdout = stdout.split('\n\n')

@ -6,15 +6,12 @@ import enum
import json import json
import os import os
import pathlib import pathlib
import re
import socket import socket
import time import time
import urllib.parse import urllib.parse
import typing as t import typing as t
from .io import (
read_text_file,
)
from .util import ( from .util import (
ApplicationError, ApplicationError,
common_environment, common_environment,
@ -297,7 +294,7 @@ def detect_host_properties(args: CommonConfig) -> ContainerHostProperties:
multi_line_commands = ( multi_line_commands = (
' && '.join(single_line_commands), ' && '.join(single_line_commands),
'cat /proc/1/cgroup', 'cat /proc/1/cgroup',
'cat /proc/1/mounts', 'cat /proc/1/mountinfo',
) )
options = ['--volume', '/sys/fs/cgroup:/probe:ro'] options = ['--volume', '/sys/fs/cgroup:/probe:ro']
@ -573,24 +570,47 @@ def get_podman_hostname() -> str:
@cache @cache
def get_docker_container_id() -> t.Optional[str]: def get_docker_container_id() -> t.Optional[str]:
"""Return the current container ID if running in a container, otherwise return None.""" """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 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<id>[0-9a-f]{64})/userdata/hostname$', str(mount.root)):
container_id = match.group('id')
engine = 'Podman'
break
if os.path.exists(path): # Docker generates /etc/hostname in the BuildHostnameFile function.
# File content varies based on the environment: # That function ends up using the containerRoot function to generate a path like: {prefix}/{container_id}/hostname
# No Container: / # NOTE: The {prefix} portion of the path can vary, so should not be relied upon.
# Docker: /docker/c86f3732b5ba3d28bb83b6e14af767ab96abbc52de31313dcb1176a62d91a507 # See: https://github.com/moby/moby/blob/cd8a090e6755bee0bdd54ac8a894b15881787097/container/container_unix.go#L58
# Azure Pipelines (Docker): /azpl_job/0f2edfed602dd6ec9f2e42c867f4d5ee640ebf4c058e6d3196d4393bb8fd0891 # See: https://github.com/moby/moby/blob/92e954a2f05998dc05773b6c64bbe23b188cb3a0/daemon/container.go#L86
# Podman: /../../../../../.. # This behavior has existed for at least ~7 years and was present in Docker version 1.0.1.
contents = read_text_file(path) # 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
cgroup_path, cgroup_name = os.path.split(contents.strip()) if match := re.search('/(?P<id>[0-9a-f]{64})/hostname$', str(mount.root)):
container_id = match.group('id')
if cgroup_path in ('/docker', '/azpl_job'): engine = 'Docker'
container_id = cgroup_name break
if container_id: 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 return container_id

Loading…
Cancel
Save