1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
|
#
# Copyright (C) 2021-2022 Matthias Klumpp <matthias@tenstral.net>
# Copyright (C) 2017 pytest-docker contributors
#
# SPDX-License-Identifier: MIT OR LGPL-3.0+
import os
import re
import json
import time
import uuid
import timeit
import contextlib
import subprocess
import attr
import pytest
def execute(command, success_codes=(0,)):
"""Run a shell command."""
try:
output = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True)
status = 0
except subprocess.CalledProcessError as error:
output = error.output or b""
status = error.returncode
command = error.cmd
if status not in success_codes:
raise Exception('Command {} returned {}: """{}""".'.format(command, status, output.decode("utf-8")))
return output
def get_podman_ip():
# When talking to the Podman daemon via a UNIX socket, route all TCP
# traffic to podman containers via the TCP loopback interface.
podman_host = os.environ.get("PODMAN_HOST", "").strip()
if not podman_host:
return "127.0.0.1"
match = re.match(r"^tcp://(.+?):\d+$", podman_host)
if not match:
raise ValueError('Invalid value for PODMAN_HOST: "%s".' % (podman_host,))
return match.group(1)
@pytest.fixture(scope="session")
def podman_ip():
"""Determine the IP address for TCP connections to Podman containers."""
return get_podman_ip()
@attr.s(frozen=True)
class Services:
_podman_compose = attr.ib()
_services = attr.ib(init=False, default=attr.Factory(dict))
def port_for(self, service, container_port):
"""Return the "host" port for `service` and `container_port`.
E.g. If the service is defined like this:
version: '2'
services:
httpbin:
build: .
ports:
- "8000:80"
this method will return 8000 for container_port=80.
"""
# Lookup in the cache.
cache = self._services.get(service, {}).get(container_port, None)
if cache is not None:
return cache
output = self._podman_compose.execute("ps -q")
host_port = ''
data = None
for line in output.decode('utf-8').split('\n'):
# sanity check to ignore any debug output
if not line or len(line) > 16 or len(line) < 6:
continue
data = json.loads(execute('podman inspect {}'.format(line.strip())))[0]
if '{}:'.format(service) not in data['ImageName']:
continue
ports = data['NetworkSettings']['Ports']
port_data = ports.get('{}/tcp'.format(container_port), ports.get('{}/udp'.format(container_port)))
if not port_data:
continue
host_port = port_data[0]['HostPort']
break
if not host_port:
print('podman-compose ps output:', output.decode('utf-8'))
print('podman inspect output:', data)
raise ValueError('Could not detect port for "%s:%d".' % (service, container_port))
host_port = int(host_port.strip())
# Store it in cache in case we request it multiple times.
self._services.setdefault(service, {})[container_port] = host_port
return host_port
def wait_until_responsive(self, check, timeout, pause, clock=timeit.default_timer):
"""Wait until a service is responsive."""
ref = clock()
now = ref
while (now - ref) < timeout:
if check():
return
time.sleep(pause)
now = clock()
raise Exception("Timeout reached while waiting on service!")
def str_to_list(arg):
if isinstance(arg, (list, tuple)):
return arg
return [arg]
@attr.s(frozen=True)
class PodmanComposeExecutor:
_compose_files = attr.ib(converter=str_to_list)
_compose_project_name = attr.ib()
def execute(self, subcommand):
command = "podman-compose"
for compose_file in self._compose_files:
command += ' -f "{}"'.format(compose_file)
command += ' -p "{}" {}'.format(self._compose_project_name, subcommand)
return execute(command)
@pytest.fixture(scope="session")
def podman_compose_file(pytestconfig):
"""Get an absolute path to the `podman-compose.yml` file. Override this
fixture in your tests if you need a custom location."""
return os.path.join(str(pytestconfig.rootdir), "tests", "podman-compose.yml")
@pytest.fixture(scope="session")
def podman_compose_project_name():
"""Generate a project name using the current process PID. Override this
fixture in your tests if you need a particular project name."""
return "pytest{}".format(os.getpid())
@pytest.fixture(scope="package")
def podman_compose_package_project_name():
"""Generate a project name using the current process PID and a random uid.
Override this fixture in your tests if you need a particular project name.
This is a package scoped fixture. The project name will contain the scope"""
return "pytest{}-package{}".format(os.getpid(), str(uuid.uuid4()).split("-")[1])
@pytest.fixture(scope="module")
def podman_compose_module_project_name():
"""Generate a project name using the current process PID. Override this
fixture in your tests if you need a particular project name.
This is a module scoped fixture. The project name will contain the scope"""
return "pytest{}-module{}".format(os.getpid(), str(uuid.uuid4()).split("-")[1])
@pytest.fixture(scope="class")
def podman_compose_class_project_name():
"""Generate a project name using the current process PID. Override this
fixture in your tests if you need a particular project name.
This is a class scoped fixture. The project name will contain the scope"""
return "pytest{}-class{}".format(os.getpid(), str(uuid.uuid4()).split("-")[1])
@pytest.fixture(scope="function")
def podman_compose_function_project_name():
"""Generate a project name using the current process PID. Override this
fixture in your tests if you need a particular project name.
This is a function scoped fixture. The project name will contain the scope"""
return "pytest{}-function{}".format(os.getpid(), str(uuid.uuid4()).split("-")[1])
def get_cleanup_command():
return "down -t 240"
@pytest.fixture(scope="session")
def podman_cleanup():
"""Get the podman_compose command to be executed for test clean-up actions.
Override this fixture in your tests if you need to change clean-up actions."""
return get_cleanup_command()
@contextlib.contextmanager
def get_podman_services(podman_compose_file, podman_compose_project_name, podman_cleanup):
podman_compose = PodmanComposeExecutor(podman_compose_file, podman_compose_project_name)
# Spawn containers.
podman_compose.execute("up --build -d")
try:
# Let test(s) run.
yield Services(podman_compose)
finally:
# Clean up.
podman_compose.execute(podman_cleanup)
@pytest.fixture(scope="session")
def podman_services(podman_compose_file, podman_compose_project_name, podman_cleanup):
"""Start all services from a podman compose file (`podman-compose up`).
After test are finished, shutdown all services (`podman-compose down`)."""
with get_podman_services(podman_compose_file, podman_compose_project_name, podman_cleanup) as podman_service:
yield podman_service
@pytest.fixture(scope="package")
def podman_package_services(podman_compose_file, podman_compose_package_project_name, podman_cleanup):
"""Start all services from a podman compose file (`podman-compose up`).
After test are finished, shutdown all services (`podman-compose down`).
This is a package scoped fixture, container are destroy at the end of pytest class."""
with get_podman_services(
podman_compose_file, podman_compose_package_project_name, podman_cleanup
) as podman_class_services:
yield podman_class_services
@pytest.fixture(scope="module")
def podman_module_services(podman_compose_file, podman_compose_module_project_name, podman_cleanup):
"""Start all services from a podman compose file (`podman-compose up`).
After test are finished, shutdown all services (`podman-compose down`).
This is a module scoped fixture, container are destroy at the end of pytest class."""
with get_podman_services(
podman_compose_file, podman_compose_module_project_name, podman_cleanup
) as podman_class_services:
yield podman_class_services
@pytest.fixture(scope="class")
def podman_class_services(podman_compose_file, podman_compose_class_project_name, podman_cleanup):
"""Start all services from a podman compose file (`podman-compose up`).
After test are finished, shutdown all services (`podman-compose down`).
This is a class scoped fixture, container are destroy at the end of pytest class."""
with get_podman_services(
podman_compose_file, podman_compose_class_project_name, podman_cleanup
) as podman_class_services:
yield podman_class_services
@pytest.fixture(scope="function")
def podman_function_services(podman_compose_file, podman_compose_function_project_name, podman_cleanup):
"""Start all services from a podman compose file (`podman-compose up`).
After test are finished, shutdown all services (`podman-compose down`).
This is a function scoped fixture, container are destroy at the end of single test."""
with get_podman_services(
podman_compose_file, podman_compose_function_project_name, podman_cleanup
) as podman_function_services:
yield podman_function_services
|