Skip to content

Commit ddca79c

Browse files
refactor: adding lovely-pytest-docker code (#816)
This PR contains following changes - Added code for lovely-pytest-docker in PSA and migrated to v2 version of docker-compose as GitHub runners have stopped supporting docker compose v1. - Also fixed the CI runs for e2e tests as it always running on latest splunk instead of version provided by addonfactory-splunk-matrix.
1 parent 533efe5 commit ddca79c

File tree

9 files changed

+256
-44
lines changed

9 files changed

+256
-44
lines changed

.github/workflows/build-test-release.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ jobs:
111111
- name: Install and run tests
112112
run: |
113113
curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1
114-
poetry install --with docs -E docker
114+
poetry install --with docs
115115
poetry run pytest -v -m doc tests/e2e
116116
117117
test-splunk-external:
@@ -143,12 +143,12 @@ jobs:
143143
export SPLUNK_APP_ID=TA_fiction
144144
export SPLUNK_VERSION=${{ matrix.splunk.version }}
145145
echo $SPLUNK_VERSION
146-
docker-compose -f "docker-compose-ci.yml" build
147-
SPLUNK_PASSWORD=Chang3d! docker-compose -f docker-compose-ci.yml up -d splunk
146+
docker compose -f "docker-compose-ci.yml" build
147+
SPLUNK_PASSWORD=Chang3d! docker compose -f docker-compose-ci.yml up -d splunk
148148
sleep 90
149149
- name: Test
150150
run: |
151-
SPLUNK_PASSWORD=Chang3d! docker-compose -f docker-compose-ci.yml up --abort-on-container-exit
151+
SPLUNK_PASSWORD=Chang3d! docker compose -f docker-compose-ci.yml up --abort-on-container-exit
152152
docker volume ls
153153
- name: Collect Results
154154
run: |
@@ -200,7 +200,7 @@ jobs:
200200
python-version: 3.7
201201
- run: |
202202
curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1
203-
poetry install -E docker
203+
poetry install
204204
poetry run pytest -v --splunk-version=${{ matrix.splunk.version }} -m docker -m ${{ matrix.test-marker }} tests/e2e
205205
206206
publish:

.licenserc.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@ header:
3636
- "entrypoint.sh"
3737
- "renovate.json"
3838
- "pytest_splunk_addon/.ignore_splunk_internal_errors"
39+
- "pytest_splunk_addon/docker_class.py"
3940

4041
comment: on-failure

Dockerfile.splunk

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@
1515
#
1616
ARG SPLUNK_VERSION=latest
1717
FROM splunk/splunk:$SPLUNK_VERSION
18-
ARG SPLUNK_VERSION=latest
1918
ARG SPLUNK_APP_ID=TA_UNKNOWN
2019
ARG SPLUNK_APP_PACKAGE=package
21-
RUN echo ${SPLUNK_VERSION} $SPLUNK_APP_PACKAGE
2220
COPY ${SPLUNK_APP_PACKAGE} /opt/splunk/etc/apps/${SPLUNK_APP_ID}
2321
COPY deps/apps /opt/splunk/etc/apps/
2422
COPY deps/build/addonfactory_test_matrix_splunk/packages/all/common /opt/splunk/etc/apps/

entrypoint.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ export PATH="~/.pyenv/bin:$PATH"
66
eval "$(pyenv init -)"
77
pyenv install 3.7.8
88
pyenv local 3.7.8
9-
curl -sSL https://install.python-poetry.org | python
9+
curl -sSL https://install.python-poetry.org | python - --version 1.5.1
1010
export PATH="/root/.local/bin:$PATH"
1111
source ~/.poetry/env
1212
sleep 15
13-
poetry install -E docker
13+
poetry install
1414
exec poetry run pytest -vv $@

poetry.lock

Lines changed: 1 addition & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ jsonschema = ">=4,<5"
4040
pytest-xdist = ">=2.3.0"
4141
filelock = "^3.0"
4242
pytest-ordering = "~0.6"
43-
lovely-pytest-docker = { version="^0", optional = true }
4443
junitparser = "^2.2.0"
4544
addonfactory-splunk-conf-parser-lib = "*"
4645
defusedxml = "^0.7.1"
@@ -50,11 +49,8 @@ xmlschema = "^1.11.3"
5049
splunksplwrapper = "^1.1.1"
5150
urllib3 = "<2"
5251

53-
[tool.poetry.extras]
54-
docker = ['lovely-pytest-docker']
5552

5653
[tool.poetry.group.dev.dependencies]
57-
lovely-pytest-docker = "~0.3.0"
5854
pytest-cov = "^3.0.0"
5955
requests-mock = "^1.8.0"
6056
freezegun = "^1.2.1"
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import functools
2+
import os
3+
from urllib.request import urlopen
4+
5+
import pytest
6+
import re
7+
import subprocess
8+
import time
9+
import timeit
10+
11+
from requests import HTTPError
12+
13+
14+
def check_url(docker_ip, public_port, path="/"):
15+
"""Check if a service is reachable.
16+
17+
Makes a simple GET request to path of the HTTP endpoint. Service is
18+
available if returned status code is < 500.
19+
"""
20+
url = "http://{}:{}{}".format(docker_ip, public_port, path)
21+
try:
22+
r = urlopen(url)
23+
return r.code < 500
24+
except HTTPError as e:
25+
# If service returns e.g. a 404 it's ok
26+
return e.code < 500
27+
except Exception:
28+
# Possible service not yet started
29+
return False
30+
31+
32+
def execute(command, success_codes=(0,)):
33+
"""Run a shell command."""
34+
try:
35+
output = subprocess.check_output(
36+
command,
37+
stderr=subprocess.STDOUT,
38+
shell=False,
39+
)
40+
status = 0
41+
except subprocess.CalledProcessError as error:
42+
output = error.output or b""
43+
status = error.returncode
44+
command = error.cmd
45+
output = output.decode("utf-8")
46+
if status not in success_codes:
47+
raise Exception('Command %r returned %d: """%s""".' % (command, status, output))
48+
return output
49+
50+
51+
class Services(object):
52+
"""A class which encapsulates services from docker compose definition.
53+
54+
This code is partly taken from
55+
https://github.com/AndreLouisCaron/pytest-docker
56+
"""
57+
58+
def __init__(self, compose_files, docker_ip, project_name="pytest"):
59+
self._docker_compose = DockerComposeExecutor(compose_files, project_name)
60+
self._services = {}
61+
self.docker_ip = docker_ip
62+
63+
def start(self, *services):
64+
"""Ensures that the given services are started via docker compose.
65+
66+
:param services: the names of the services as defined in compose file
67+
"""
68+
self._docker_compose.execute("up", "--build", "-d", *services)
69+
70+
def stop(self, *services):
71+
"""Ensures that the given services are stopped via docker compose.
72+
73+
:param services: the names of the services as defined in compose file
74+
"""
75+
self._docker_compose.execute("stop", *services)
76+
77+
def execute(self, service, *cmd):
78+
"""Execute a command inside a docker container.
79+
80+
:param service: the name of the service as defined in compose file
81+
:param cmd: list of command parts to execute
82+
"""
83+
return self._docker_compose.execute("exec", "-T", service, *cmd)
84+
85+
def wait_for_service(
86+
self, service, private_port, check_server=check_url, timeout=30.0, pause=0.1
87+
):
88+
"""
89+
Waits for the given service to response to a http GET.
90+
91+
:param service: the service name as defined in the docker compose file
92+
:param private_port: the private port as defined in docker compose file
93+
:param check_server: optional function to check if the server is ready
94+
(default check method makes GET request to '/'
95+
of HTTP endpoint)
96+
:param timeout: maximum time to wait for the service in seconds
97+
:param pause: time in seconds to wait between retries
98+
99+
:return: the public port of the service exposed to host system if any
100+
"""
101+
public_port = self.port_for(service, private_port)
102+
self.wait_until_responsive(
103+
timeout=timeout,
104+
pause=pause,
105+
check=lambda: check_server(self.docker_ip, public_port),
106+
)
107+
return public_port
108+
109+
def shutdown(self):
110+
self._docker_compose.execute("down", "-v")
111+
112+
def port_for(self, service, port):
113+
"""Get the effective bind port for a service."""
114+
115+
# Lookup in the cache.
116+
cache = self._services.get(service, {}).get(port, None)
117+
if cache is not None:
118+
return cache
119+
120+
output = self._docker_compose.execute("port", service, str(port))
121+
endpoint = output.strip()
122+
if not endpoint:
123+
raise ValueError('Could not detect port for "%s:%d".' % (service, port))
124+
125+
# Usually, the IP address here is 0.0.0.0, so we don't use it.
126+
match = int(endpoint.split(":", 1)[1])
127+
128+
# Store it in cache in case we request it multiple times.
129+
self._services.setdefault(service, {})[port] = match
130+
131+
return match
132+
133+
@staticmethod
134+
def wait_until_responsive(check, timeout, pause, clock=timeit.default_timer):
135+
"""Wait until a service is responsive."""
136+
137+
ref = clock()
138+
now = ref
139+
while (now - ref) < timeout:
140+
if check():
141+
return
142+
time.sleep(pause)
143+
now = clock()
144+
145+
raise Exception("Timeout reached while waiting on service!")
146+
147+
148+
class DockerComposeExecutor(object):
149+
def __init__(self, compose_files, project_name):
150+
self._compose_files = compose_files
151+
self._project_name = project_name
152+
self.project_directory = os.path.dirname(os.path.realpath(compose_files[0]))
153+
154+
def execute(self, *subcommand):
155+
command = ["docker", "compose"]
156+
for compose_file in self._compose_files:
157+
command.append("-f")
158+
command.append(compose_file)
159+
command.append("-p")
160+
command.append(self._project_name)
161+
command += subcommand
162+
return execute(command)

pytest_splunk_addon/splunk.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
import json
2222
import pytest
2323
import requests
24+
import re
2425
import splunklib.client as client
2526
from splunksplwrapper.manager.jobs import Jobs
2627
from splunksplwrapper.splunk.cloud import CloudSplunk
2728
from splunksplwrapper.SearchUtil import SearchUtil
2829
from .standard_lib.event_ingestors import IngestorHelper
30+
from .docker_class import Services
2931
from .standard_lib.CIM_Models.datamodel_definition import datamodels
3032
import configparser
3133
from filelock import FileLock
@@ -323,6 +325,13 @@ def pytest_addoption(parser):
323325
help="Should execute test or not (True|False)",
324326
default="True",
325327
)
328+
group.addoption(
329+
"--keepalive",
330+
"-K",
331+
action="store_true",
332+
default=False,
333+
help="Keep docker containers alive",
334+
)
326335

327336

328337
@pytest.fixture(scope="session")
@@ -830,6 +839,56 @@ def update_recommended_fields(model, datasets, cim_version):
830839
return update_recommended_fields
831840

832841

842+
@pytest.fixture(scope="session")
843+
def docker_ip():
844+
"""Determine IP address for TCP connections to Docker containers."""
845+
846+
# When talking to the Docker daemon via a UNIX socket, route all TCP
847+
# traffic to docker containers via the TCP loopback interface.
848+
docker_host = os.environ.get("DOCKER_HOST", "").strip()
849+
if not docker_host:
850+
return "127.0.0.1"
851+
852+
match = re.match("^tcp://(.+?):\d+$", docker_host)
853+
if not match:
854+
raise ValueError('Invalid value for DOCKER_HOST: "%s".' % (docker_host,))
855+
return match.group(1)
856+
857+
858+
@pytest.fixture(scope="session")
859+
def docker_compose_files(pytestconfig):
860+
"""Get the docker-compose.yml absolute path.
861+
Override this fixture in your tests if you need a custom location.
862+
"""
863+
return [os.path.join(str(pytestconfig.rootdir), "tests", "docker-compose.yml")]
864+
865+
866+
@pytest.fixture(scope="session")
867+
def docker_services_project_name(pytestconfig):
868+
"""
869+
Create unique project name for docker compose based on the pytestconfig root directory.
870+
Characters prohibited by Docker compose project names are replaced with hyphens.
871+
"""
872+
slug = re.sub(r"[^a-z0-9]+", "-", str(pytestconfig.rootdir).lower())
873+
project_name = "pytest{}".format(slug)
874+
return project_name
875+
876+
877+
@pytest.fixture(scope="session")
878+
def docker_services(
879+
request, docker_compose_files, docker_ip, docker_services_project_name
880+
):
881+
"""Provide the docker services as a pytest fixture.
882+
883+
The services will be stopped after all tests are run.
884+
"""
885+
keep_alive = request.config.getoption("--keepalive", False)
886+
services = Services(docker_compose_files, docker_ip, docker_services_project_name)
887+
yield services
888+
if not keep_alive:
889+
services.shutdown()
890+
891+
833892
def is_responsive_uf(uf):
834893
"""
835894
Verify if the management port of Universal Forwarder is responsive or not

0 commit comments

Comments
 (0)