Skip to content

Commit b7c937e

Browse files
committed
RHOAIENG-17695: chore(ci): create a test for calling oc version in the test, which can be run with ci testing
1 parent 1a2669c commit b7c937e

File tree

8 files changed

+721
-5
lines changed

8 files changed

+721
-5
lines changed

.github/workflows/build-notebooks-TEMPLATE.yaml

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ name: Build & Publish Notebook Servers (TEMPLATE)
1818

1919
jobs:
2020
build:
21-
runs-on: ubuntu-22.04
21+
strategy:
22+
matrix:
23+
os: [ubuntu-22.04]
24+
runs-on: ${{matrix.os}}
2225
env:
2326
# Some pieces of code (image pulls for example) in podman consult TMPDIR or default to /var/tmp
2427
TMPDIR: /home/runner/.local/share/containers/tmpdir
@@ -34,6 +37,8 @@ jobs:
3437
TRIVY_VULNDB: "/home/runner/.local/share/containers/trivy_db"
3538
# Targets (and their folder) that should be scanned using FS instead of IMAGE scan due to resource constraints
3639
TRIVY_SCAN_FS_JSON: '{}'
40+
# Poetry version for use in running tests
41+
POETRY_VERSION: '2.0.0'
3742

3843
steps:
3944

@@ -258,6 +263,47 @@ jobs:
258263

259264
# endregion
260265

266+
# region Pytest image tests
267+
268+
- name: Install poetry
269+
if: steps.cache-poetry-restore.outputs.cache-hit != 'true'
270+
run: pipx install poetry==${{ env.POETRY_VERSION }}
271+
env:
272+
PIPX_HOME: /home/runner/.local/pipx
273+
PIPX_BIN_DIR: /home/runner/.local/bin
274+
275+
- name: Check poetry is installed correctly
276+
run: poetry env info
277+
278+
- name: Set up Python
279+
id: setup-python
280+
uses: actions/setup-python@v5
281+
with:
282+
python-version: '3.12'
283+
cache: 'poetry'
284+
285+
- name: Configure poetry
286+
run: poetry env use "${{ steps.setup-python.outputs.python-path }}"
287+
288+
- name: Install deps
289+
run: poetry install --sync
290+
291+
- name: Run container tests (in PyTest)
292+
run: |
293+
set -Eeuxo pipefail
294+
# retry to increase CI reliability
295+
for i in {1..5}; do
296+
if podman pull --retry 10 "${RYUK_CONTAINER_IMAGE}"; then break; fi
297+
done
298+
# now run the tests
299+
poetry run pytest tests/containers --image="${{ steps.calculated_vars.outputs.OUTPUT_IMAGE }}"
300+
env:
301+
DOCKER_HOST: "unix:///var/run/podman/podman.sock"
302+
TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: "/var/run/podman/podman.sock"
303+
RYUK_CONTAINER_IMAGE: "testcontainers/ryuk:0.8.1"
304+
305+
# endregion Pytest image tests
306+
261307
# region Makefile image tests
262308

263309
- name: "Check if we have tests or not"

poetry.lock

Lines changed: 380 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ python = "~3.12"
1414
pytest = "^8.2.2"
1515
pytest-subtests = "^0.12.1"
1616
pyfakefs = "^5.7.2"
17+
testcontainers = "^4.9.0"
18+
docker = "^7.1.0"
1719

1820
[build-system]
1921
requires = ["poetry-core"]

tests/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import pathlib
2+
3+
PROJECT_ROOT = pathlib.Path(__file__).parent.parent
4+
5+
__all__ = [
6+
PROJECT_ROOT,
7+
]

tests/containers/base_image_test.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import pathlib
5+
import tempfile
6+
from typing import TYPE_CHECKING
7+
8+
import testcontainers.core.container
9+
import testcontainers.core.waiting_utils
10+
11+
from tests.containers import docker_utils
12+
13+
logging.basicConfig(level=logging.DEBUG)
14+
LOGGER = logging.getLogger(__name__)
15+
16+
if TYPE_CHECKING:
17+
import pytest_subtests
18+
19+
20+
class TestBaseImage:
21+
"""Tests that are applicable for all images we have in this repository."""
22+
23+
def test_oc_command_runs(self, image: str):
24+
container = testcontainers.core.container.DockerContainer(image=image, user=123456, group_add=[0])
25+
container.with_command("/bin/sh -c 'sleep infinity'")
26+
try:
27+
container.start()
28+
ecode, output = container.exec(["/bin/sh", "-c", "oc version"])
29+
finally:
30+
docker_utils.NotebookContainer(container).stop(timeout=0)
31+
32+
logging.debug(output.decode())
33+
assert ecode == 0
34+
35+
def test_oc_command_runs_fake_fips(self, image: str, subtests: pytest_subtests.SubTests):
36+
"""Establishes a best-effort fake FIPS environment and attempts to execute `oc` binary in it.
37+
38+
Related issue: RHOAIENG-4350 In workbench the oc CLI tool cannot be used on FIPS enabled cluster"""
39+
with tempfile.TemporaryDirectory() as tmp_crypto:
40+
# Ubuntu does not even have /proc/sys/crypto directory, unless FIPS is activated and machine
41+
# is rebooted, see https://ubuntu.com/security/certifications/docs/fips-enablement
42+
# NOTE: mounting a temp file as `/proc/sys/crypto/fips_enabled` is further discussed in
43+
# * https://issues.redhat.com/browse/RHOAIENG-4350
44+
# * https://github.com/junaruga/fips-mode-user-space/blob/main/fips-mode-user-space-setup
45+
tmp_crypto = pathlib.Path(tmp_crypto)
46+
(tmp_crypto / 'crypto').mkdir()
47+
(tmp_crypto / 'crypto' / 'fips_enabled').write_text("1\n")
48+
(tmp_crypto / 'crypto' / 'fips_name').write_text("Linux Kernel Cryptographic API\n")
49+
(tmp_crypto / 'crypto' / 'fips_version').write_text("6.10.10-200.fc40.aarch64\n")
50+
# tmpdir is by-default created with perms restricting access to user only
51+
tmp_crypto.chmod(0o777)
52+
53+
container = testcontainers.core.container.DockerContainer(image=image, user=654321, group_add=[0])
54+
container.with_volume_mapping(str(tmp_crypto), "/proc/sys")
55+
container.with_command("/bin/sh -c 'sleep infinity'")
56+
57+
try:
58+
container.start()
59+
60+
with subtests.test("/proc/sys/crypto/fips_enabled is 1"):
61+
ecode, output = container.exec(["/bin/sh", "-c", "sysctl crypto.fips_enabled"])
62+
assert ecode == 0, output.decode()
63+
assert "crypto.fips_enabled = 1\n" == output.decode(), output.decode()
64+
65+
# 0: enabled, 1: partial success, 2: not enabled
66+
with subtests.test("/fips-mode-setup --is-enabled reports 1"):
67+
ecode, output = container.exec(["/bin/sh", "-c", "fips-mode-setup --is-enabled"])
68+
assert ecode == 1, output.decode()
69+
70+
with subtests.test("/fips-mode-setup --check reports partial success"):
71+
ecode, output = container.exec(["/bin/sh", "-c", "fips-mode-setup --check"])
72+
assert ecode == 1, output.decode()
73+
assert "FIPS mode is enabled.\n" in output.decode(), output.decode()
74+
assert "Inconsistent state detected.\n" in output.decode(), output.decode()
75+
76+
with subtests.test("oc version command runs"):
77+
ecode, output = container.exec(["/bin/sh", "-c", "oc version"])
78+
assert ecode == 0, output.decode()
79+
finally:
80+
docker_utils.NotebookContainer(container).stop(timeout=0)

tests/containers/conftest.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import testcontainers.core.config
6+
import testcontainers.core.container
7+
import testcontainers.core.docker_client
8+
9+
import pytest
10+
11+
if TYPE_CHECKING:
12+
from pytest import ExitCode, Session, Parser, Metafunc
13+
14+
SHUTDOWN_RYUK = False
15+
16+
# NOTE: Configure Testcontainers through `testcontainers.core.config` and not through env variables.
17+
# Importing `testcontainers` above has already read out env variables, and so at this point, setting
18+
# * DOCKER_HOST
19+
# * TESTCONTAINERS_RYUK_DISABLED
20+
# * TESTCONTAINERS_RYUK_PRIVILEGED
21+
# * TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE
22+
# would have no effect.
23+
24+
# We'd get selinux violations with podman otherwise, so either ryuk must be privileged, or we need to disable selinux.
25+
# https://github.com/testcontainers/testcontainers-java/issues/2088#issuecomment-1169830358
26+
testcontainers.core.config.testcontainers_config.ryuk_privileged = True
27+
28+
29+
def pytest_addoption(parser: Parser) -> None:
30+
parser.addoption("--image", action="append", default=[],
31+
help="Image to use, can be specified multiple times")
32+
33+
34+
def pytest_generate_tests(metafunc: Metafunc) -> None:
35+
if image.__name__ in metafunc.fixturenames:
36+
metafunc.parametrize(image.__name__, metafunc.config.getoption("--image"))
37+
38+
39+
# https://docs.pytest.org/en/stable/how-to/fixtures.html#parametrizing-fixtures
40+
# indirect parametrization https://stackoverflow.com/questions/18011902/how-to-pass-a-parameter-to-a-fixture-function-in-pytest
41+
@pytest.fixture(scope="session")
42+
def image(request):
43+
yield request.param
44+
45+
46+
def pytest_sessionstart(session: Session) -> None:
47+
# first preflight check: ping the Docker API
48+
client = testcontainers.core.docker_client.DockerClient()
49+
assert client.client.ping(), "Failed to connect to Docker"
50+
51+
# second preflight check: start the Reaper container
52+
assert testcontainers.core.container.Reaper.get_instance() is not None, "Failed to start Reaper container"
53+
54+
55+
# https://docs.pytest.org/en/latest/reference/reference.html#pytest.hookspec.pytest_sessionfinish
56+
def pytest_sessionfinish(session: Session, exitstatus: int | ExitCode) -> None:
57+
# resolves a shutdown resource leak warning that would be otherwise reported
58+
if SHUTDOWN_RYUK:
59+
testcontainers.core.container.Reaper.delete_instance()

tests/containers/docker_utils.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import logging
5+
import os.path
6+
import sys
7+
import tarfile
8+
import time
9+
from typing import TYPE_CHECKING
10+
11+
import testcontainers.core.container
12+
13+
if TYPE_CHECKING:
14+
from docker.models.containers import Container
15+
16+
17+
class NotebookContainer:
18+
@classmethod
19+
def wrap(cls, container: testcontainers.core.container.DockerContainer):
20+
return NotebookContainer(container)
21+
22+
def __init__(self, container: testcontainers.core.container.DockerContainer) -> None:
23+
self.testcontainer = container
24+
25+
def stop(self, timeout: int = 10):
26+
"""Stop container with customizable timeout.
27+
28+
DockerContainer.stop() has unchangeable 10s timeout between SIGSTOP and SIGKILL."""
29+
self.testcontainer.get_wrapped_container().stop(timeout=timeout)
30+
self.testcontainer.stop()
31+
32+
def wait_for_exit(self) -> int:
33+
container = self.testcontainer.get_wrapped_container()
34+
container.reload()
35+
while container.status != "exited":
36+
time.sleep(0.2)
37+
container.reload()
38+
return container.attrs["State"]["ExitCode"]
39+
40+
41+
def container_cp(container: Container, src: str, dst: str,
42+
user: int | None = None, group: int | None = None) -> None:
43+
"""
44+
Copies a directory into a container
45+
From https://stackoverflow.com/questions/46390309/how-to-copy-a-file-from-host-to-container-using-docker-py-docker-sdk
46+
"""
47+
fh = io.BytesIO()
48+
tar = tarfile.open(fileobj=fh, mode="w:gz")
49+
50+
tar_filter = None
51+
if user or group:
52+
def tar_filter(f: tarfile.TarInfo) -> tarfile.TarInfo:
53+
if user:
54+
f.uid = user
55+
if group:
56+
f.gid = group
57+
return f
58+
59+
logging.debug(f"Adding {src=} to archive {dst=}")
60+
try:
61+
tar.add(src, arcname=os.path.basename(src), filter=tar_filter)
62+
finally:
63+
tar.close()
64+
65+
fh.seek(0)
66+
container.put_archive(dst, fh)
67+
68+
69+
def container_exec(
70+
container: Container,
71+
cmd: str | list[str],
72+
stdout: bool = True,
73+
stderr: bool = True,
74+
stdin: bool = False,
75+
tty: bool = False,
76+
privileged: bool = False,
77+
user: str = "",
78+
detach: bool = False,
79+
stream: bool = False,
80+
socket: bool = False,
81+
environment: dict[str, str] | None = None,
82+
workdir: str | None = None,
83+
) -> ContainerExec:
84+
"""
85+
An enhanced version of #docker.Container.exec_run() which returns an object
86+
that can be properly inspected for the status of the executed commands.
87+
Usage example:
88+
result = tools.container_exec(container, cmd, stream=True, **kwargs)
89+
res = result.communicate(line_prefix=b'--> ')
90+
if res != 0:
91+
error('exit code {!r}'.format(res))
92+
From https://github.com/docker/docker-py/issues/1989
93+
"""
94+
95+
exec_id = container.client.api.exec_create(
96+
container.id,
97+
cmd,
98+
stdout=stdout,
99+
stderr=stderr,
100+
stdin=stdin,
101+
tty=tty,
102+
privileged=privileged,
103+
user=user,
104+
environment=environment,
105+
workdir=workdir,
106+
)["Id"]
107+
108+
output = container.client.api.exec_start(exec_id, detach=detach, tty=tty, stream=stream, socket=socket)
109+
110+
return ContainerExec(container.client, exec_id, output)
111+
112+
113+
class ContainerExec:
114+
def __init__(self, client, id, output: list[int] | list[str]):
115+
self.client = client
116+
self.id = id
117+
self.output = output
118+
119+
def inspect(self):
120+
return self.client.api.exec_inspect(self.id)
121+
122+
def poll(self):
123+
return self.inspect()["ExitCode"]
124+
125+
def communicate(self, line_prefix=b""):
126+
for data in self.output:
127+
if not data:
128+
continue
129+
offset = 0
130+
while offset < len(data):
131+
sys.stdout.buffer.write(line_prefix)
132+
nl = data.find(b"\n", offset)
133+
if nl >= 0:
134+
slice = data[offset: nl + 1]
135+
offset = nl + 1
136+
else:
137+
slice = data[offset:]
138+
offset += len(slice)
139+
sys.stdout.buffer.write(slice)
140+
sys.stdout.flush()
141+
while self.poll() is None:
142+
raise RuntimeError("Hm could that really happen?")
143+
return self.poll()

tests/test_main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22

33
import os
44
import logging
5-
import pathlib
65
import shutil
76
import subprocess
87
import tomllib
98
from typing import TYPE_CHECKING
109

10+
from tests import PROJECT_ROOT
11+
1112
if TYPE_CHECKING:
1213
import pytest_subtests
1314

14-
PROJECT_ROOT = pathlib.Path(__file__).parent.parent
1515
MAKE = shutil.which("gmake") or shutil.which("make")
1616

17+
1718
def test_image_pipfiles(subtests: pytest_subtests.plugin.SubTests):
1819
for file in PROJECT_ROOT.glob("**/Pipfile"):
1920
with subtests.test(msg="checking Pipfile", pipfile=file):

0 commit comments

Comments
 (0)