Skip to content

Commit

Permalink
refactor: adding lovely-pytest-docker code (#816)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
harshilgajera-crest authored Apr 11, 2024
1 parent 533efe5 commit ddca79c
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 44 deletions.
10 changes: 5 additions & 5 deletions .github/workflows/build-test-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ jobs:
- name: Install and run tests
run: |
curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1
poetry install --with docs -E docker
poetry install --with docs
poetry run pytest -v -m doc tests/e2e
test-splunk-external:
Expand Down Expand Up @@ -143,12 +143,12 @@ jobs:
export SPLUNK_APP_ID=TA_fiction
export SPLUNK_VERSION=${{ matrix.splunk.version }}
echo $SPLUNK_VERSION
docker-compose -f "docker-compose-ci.yml" build
SPLUNK_PASSWORD=Chang3d! docker-compose -f docker-compose-ci.yml up -d splunk
docker compose -f "docker-compose-ci.yml" build
SPLUNK_PASSWORD=Chang3d! docker compose -f docker-compose-ci.yml up -d splunk
sleep 90
- name: Test
run: |
SPLUNK_PASSWORD=Chang3d! docker-compose -f docker-compose-ci.yml up --abort-on-container-exit
SPLUNK_PASSWORD=Chang3d! docker compose -f docker-compose-ci.yml up --abort-on-container-exit
docker volume ls
- name: Collect Results
run: |
Expand Down Expand Up @@ -200,7 +200,7 @@ jobs:
python-version: 3.7
- run: |
curl -sSL https://install.python-poetry.org | python3 - --version 1.5.1
poetry install -E docker
poetry install
poetry run pytest -v --splunk-version=${{ matrix.splunk.version }} -m docker -m ${{ matrix.test-marker }} tests/e2e
publish:
Expand Down
1 change: 1 addition & 0 deletions .licenserc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ header:
- "entrypoint.sh"
- "renovate.json"
- "pytest_splunk_addon/.ignore_splunk_internal_errors"
- "pytest_splunk_addon/docker_class.py"

comment: on-failure
2 changes: 0 additions & 2 deletions Dockerfile.splunk
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@
#
ARG SPLUNK_VERSION=latest
FROM splunk/splunk:$SPLUNK_VERSION
ARG SPLUNK_VERSION=latest
ARG SPLUNK_APP_ID=TA_UNKNOWN
ARG SPLUNK_APP_PACKAGE=package
RUN echo ${SPLUNK_VERSION} $SPLUNK_APP_PACKAGE
COPY ${SPLUNK_APP_PACKAGE} /opt/splunk/etc/apps/${SPLUNK_APP_ID}
COPY deps/apps /opt/splunk/etc/apps/
COPY deps/build/addonfactory_test_matrix_splunk/packages/all/common /opt/splunk/etc/apps/
Expand Down
4 changes: 2 additions & 2 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ export PATH="~/.pyenv/bin:$PATH"
eval "$(pyenv init -)"
pyenv install 3.7.8
pyenv local 3.7.8
curl -sSL https://install.python-poetry.org | python
curl -sSL https://install.python-poetry.org | python - --version 1.5.1
export PATH="/root/.local/bin:$PATH"
source ~/.poetry/env
sleep 15
poetry install -E docker
poetry install
exec poetry run pytest -vv $@
19 changes: 1 addition & 18 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ jsonschema = ">=4,<5"
pytest-xdist = ">=2.3.0"
filelock = "^3.0"
pytest-ordering = "~0.6"
lovely-pytest-docker = { version="^0", optional = true }
junitparser = "^2.2.0"
addonfactory-splunk-conf-parser-lib = "*"
defusedxml = "^0.7.1"
Expand All @@ -50,11 +49,8 @@ xmlschema = "^1.11.3"
splunksplwrapper = "^1.1.1"
urllib3 = "<2"

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

[tool.poetry.group.dev.dependencies]
lovely-pytest-docker = "~0.3.0"
pytest-cov = "^3.0.0"
requests-mock = "^1.8.0"
freezegun = "^1.2.1"
Expand Down
162 changes: 162 additions & 0 deletions pytest_splunk_addon/docker_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import functools
import os
from urllib.request import urlopen

import pytest
import re
import subprocess
import time
import timeit

from requests import HTTPError


def check_url(docker_ip, public_port, path="/"):
"""Check if a service is reachable.
Makes a simple GET request to path of the HTTP endpoint. Service is
available if returned status code is < 500.
"""
url = "http://{}:{}{}".format(docker_ip, public_port, path)
try:
r = urlopen(url)
return r.code < 500
except HTTPError as e:
# If service returns e.g. a 404 it's ok
return e.code < 500
except Exception:
# Possible service not yet started
return False


def execute(command, success_codes=(0,)):
"""Run a shell command."""
try:
output = subprocess.check_output(
command,
stderr=subprocess.STDOUT,
shell=False,
)
status = 0
except subprocess.CalledProcessError as error:
output = error.output or b""
status = error.returncode
command = error.cmd
output = output.decode("utf-8")
if status not in success_codes:
raise Exception('Command %r returned %d: """%s""".' % (command, status, output))
return output


class Services(object):
"""A class which encapsulates services from docker compose definition.
This code is partly taken from
https://github.com/AndreLouisCaron/pytest-docker
"""

def __init__(self, compose_files, docker_ip, project_name="pytest"):
self._docker_compose = DockerComposeExecutor(compose_files, project_name)
self._services = {}
self.docker_ip = docker_ip

def start(self, *services):
"""Ensures that the given services are started via docker compose.
:param services: the names of the services as defined in compose file
"""
self._docker_compose.execute("up", "--build", "-d", *services)

def stop(self, *services):
"""Ensures that the given services are stopped via docker compose.
:param services: the names of the services as defined in compose file
"""
self._docker_compose.execute("stop", *services)

def execute(self, service, *cmd):
"""Execute a command inside a docker container.
:param service: the name of the service as defined in compose file
:param cmd: list of command parts to execute
"""
return self._docker_compose.execute("exec", "-T", service, *cmd)

def wait_for_service(
self, service, private_port, check_server=check_url, timeout=30.0, pause=0.1
):
"""
Waits for the given service to response to a http GET.
:param service: the service name as defined in the docker compose file
:param private_port: the private port as defined in docker compose file
:param check_server: optional function to check if the server is ready
(default check method makes GET request to '/'
of HTTP endpoint)
:param timeout: maximum time to wait for the service in seconds
:param pause: time in seconds to wait between retries
:return: the public port of the service exposed to host system if any
"""
public_port = self.port_for(service, private_port)
self.wait_until_responsive(
timeout=timeout,
pause=pause,
check=lambda: check_server(self.docker_ip, public_port),
)
return public_port

def shutdown(self):
self._docker_compose.execute("down", "-v")

def port_for(self, service, port):
"""Get the effective bind port for a service."""

# Lookup in the cache.
cache = self._services.get(service, {}).get(port, None)
if cache is not None:
return cache

output = self._docker_compose.execute("port", service, str(port))
endpoint = output.strip()
if not endpoint:
raise ValueError('Could not detect port for "%s:%d".' % (service, port))

# Usually, the IP address here is 0.0.0.0, so we don't use it.
match = int(endpoint.split(":", 1)[1])

# Store it in cache in case we request it multiple times.
self._services.setdefault(service, {})[port] = match

return match

@staticmethod
def wait_until_responsive(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!")


class DockerComposeExecutor(object):
def __init__(self, compose_files, project_name):
self._compose_files = compose_files
self._project_name = project_name
self.project_directory = os.path.dirname(os.path.realpath(compose_files[0]))

def execute(self, *subcommand):
command = ["docker", "compose"]
for compose_file in self._compose_files:
command.append("-f")
command.append(compose_file)
command.append("-p")
command.append(self._project_name)
command += subcommand
return execute(command)
59 changes: 59 additions & 0 deletions pytest_splunk_addon/splunk.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
import json
import pytest
import requests
import re
import splunklib.client as client
from splunksplwrapper.manager.jobs import Jobs
from splunksplwrapper.splunk.cloud import CloudSplunk
from splunksplwrapper.SearchUtil import SearchUtil
from .standard_lib.event_ingestors import IngestorHelper
from .docker_class import Services
from .standard_lib.CIM_Models.datamodel_definition import datamodels
import configparser
from filelock import FileLock
Expand Down Expand Up @@ -323,6 +325,13 @@ def pytest_addoption(parser):
help="Should execute test or not (True|False)",
default="True",
)
group.addoption(
"--keepalive",
"-K",
action="store_true",
default=False,
help="Keep docker containers alive",
)


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


@pytest.fixture(scope="session")
def docker_ip():
"""Determine IP address for TCP connections to Docker containers."""

# When talking to the Docker daemon via a UNIX socket, route all TCP
# traffic to docker containers via the TCP loopback interface.
docker_host = os.environ.get("DOCKER_HOST", "").strip()
if not docker_host:
return "127.0.0.1"

match = re.match("^tcp://(.+?):\d+$", docker_host)
if not match:
raise ValueError('Invalid value for DOCKER_HOST: "%s".' % (docker_host,))
return match.group(1)


@pytest.fixture(scope="session")
def docker_compose_files(pytestconfig):
"""Get the docker-compose.yml absolute path.
Override this fixture in your tests if you need a custom location.
"""
return [os.path.join(str(pytestconfig.rootdir), "tests", "docker-compose.yml")]


@pytest.fixture(scope="session")
def docker_services_project_name(pytestconfig):
"""
Create unique project name for docker compose based on the pytestconfig root directory.
Characters prohibited by Docker compose project names are replaced with hyphens.
"""
slug = re.sub(r"[^a-z0-9]+", "-", str(pytestconfig.rootdir).lower())
project_name = "pytest{}".format(slug)
return project_name


@pytest.fixture(scope="session")
def docker_services(
request, docker_compose_files, docker_ip, docker_services_project_name
):
"""Provide the docker services as a pytest fixture.
The services will be stopped after all tests are run.
"""
keep_alive = request.config.getoption("--keepalive", False)
services = Services(docker_compose_files, docker_ip, docker_services_project_name)
yield services
if not keep_alive:
services.shutdown()


def is_responsive_uf(uf):
"""
Verify if the management port of Universal Forwarder is responsive or not
Expand Down
Loading

0 comments on commit ddca79c

Please sign in to comment.