Skip to content

Commit 43cab12

Browse files
authored
tests(containers): add e2e test for MySQL with user auth (#1405)
1 parent 4fa83f9 commit 43cab12

File tree

4 files changed

+240
-1
lines changed

4 files changed

+240
-1
lines changed

tests/containers/conftest.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
import testcontainers.core.docker_client
1616

1717
from tests.containers import docker_utils, skopeo_utils, utils
18+
from tests.containers.kubernetes_utils import TestFrame
1819

1920
if TYPE_CHECKING:
20-
from collections.abc import Callable
21+
from collections.abc import Callable, Generator
2122

2223
from pytest import ExitCode, Metafunc, Parser, Session
2324

@@ -108,6 +109,12 @@ def skip_if_not_rocm_image(image: str) -> Image:
108109
return image_metadata
109110

110111

112+
@pytest.fixture(scope="function")
113+
def tf() -> Generator[TestFrame[Any], None, None]:
114+
with TestFrame() as tf:
115+
yield tf
116+
117+
111118
# https://docs.pytest.org/en/stable/how-to/fixtures.html#parametrizing-fixtures
112119
# indirect parametrization https://stackoverflow.com/questions/18011902/how-to-pass-a-parameter-to-a-fixture-function-in-pytest
113120
@pytest.fixture(scope="session")
@@ -172,6 +179,17 @@ def jupyterlab_trustyai_image(jupyterlab_image: Image) -> Image:
172179
return jupyterlab_image
173180

174181

182+
@pytest.fixture(scope="function")
183+
def datascience_image(image: str) -> Image:
184+
image_metadata = get_image_metadata(image)
185+
if "-minimal-" in image_metadata.labels["name"]:
186+
pytest.skip(
187+
f"Image {image_metadata.name} is not datascience image because it has '-minimal-' in {image_metadata.labels['name']=}'"
188+
)
189+
190+
return image_metadata
191+
192+
175193
@pytest.fixture(scope="function")
176194
def rstudio_image(image: str) -> Image:
177195
image_metadata = skip_if_not_workbench_image(image)

tests/containers/docker_utils.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io
44
import logging
55
import os.path
6+
import socket as pysocket
67
import sys
78
import tarfile
89
import time
@@ -163,6 +164,105 @@ def communicate(self, line_prefix=b"") -> int:
163164
return self.poll()
164165

165166

167+
def container_exec_with_stdin(
168+
container: Container,
169+
cmd: str | list[str],
170+
stdin_data: str | bytes,
171+
read_timeout: float = 1.0,
172+
) -> tuple[int, bytes]:
173+
"""
174+
Executes a command in a container, writing stdin_data to its stdin.
175+
176+
:param container: The container to execute the command in.
177+
:param cmd: The command to execute.
178+
:param stdin_data: The string or bytes to send to the command's stdin.
179+
:return: A tuple of (exit_code, output_bytes).
180+
"""
181+
if isinstance(stdin_data, str):
182+
stdin_data = stdin_data.encode("utf-8")
183+
184+
# Using the low-level API for precise control over the socket.
185+
exec_id = container.client.api.exec_create(
186+
container=container.id,
187+
cmd=cmd,
188+
stdin=True,
189+
stdout=True,
190+
stderr=True,
191+
tty=True,
192+
)
193+
194+
# When using a podman client, exec_start(socket=True) returns a file-like
195+
# object (a wrapper around SocketIO), not a raw socket. We must use
196+
# file-like methods (write, read) instead of raw socket methods.
197+
stream = container.client.api.exec_start(exec_id, socket=True, tty=True)
198+
199+
# The stream object can be a raw socket or a file-like wrapper which might
200+
# be incorrectly marked as read-only. We need to find the underlying raw
201+
# socket to reliably write to stdin.
202+
raw_sock = None
203+
if isinstance(stream, pysocket.socket):
204+
raw_sock = stream
205+
else:
206+
# Try to unwrap a file-like object (e.g., BufferedReader -> SocketIO -> socket)
207+
raw_io = getattr(stream, "raw", stream)
208+
if hasattr(raw_io, "_sock"):
209+
raw_sock = raw_io._sock
210+
211+
if raw_sock:
212+
raw_sock.sendall(stdin_data)
213+
else:
214+
# Fallback to stream.write() if no raw socket found. This may fail.
215+
try:
216+
stream.write(stdin_data)
217+
stream.flush()
218+
except (OSError, io.UnsupportedOperation) as e:
219+
raise OSError(f"Could not write to container exec stdin using stream of type {type(stream)}") from e
220+
221+
# Shut down the write-half of the connection to signal EOF to the process.
222+
try:
223+
if raw_sock:
224+
raw_sock.shutdown(pysocket.SHUT_WR)
225+
else:
226+
# Fallback for stream objects that have a shutdown method.
227+
raw_io = getattr(stream, "raw", stream)
228+
if hasattr(raw_io, "_sock"):
229+
raw_io._sock.shutdown(pysocket.SHUT_WR)
230+
else:
231+
stream.shutdown(pysocket.SHUT_WR)
232+
except (OSError, AttributeError):
233+
# This is expected if the remote process closes the connection first.
234+
pass
235+
236+
if raw_sock:
237+
# If we unwrapped and used the raw socket, we must continue using it
238+
# for reading to avoid state inconsistencies with the wrapper object.
239+
output_chunks = []
240+
while True:
241+
# we set the timeout in order not to be blocked afterwards with blocking read
242+
raw_sock.settimeout(read_timeout)
243+
# Reading in a loop is the standard way to consume a socket's content.
244+
try:
245+
chunk = raw_sock.recv(4096)
246+
except TimeoutError:
247+
break
248+
if not chunk:
249+
# An empty chunk signifies that the remote end has closed the connection.
250+
break
251+
output_chunks.append(chunk)
252+
output = b"".join(output_chunks)
253+
raw_sock.close()
254+
else:
255+
# Fallback to stream.read() if we couldn't get a raw socket.
256+
# This may hang if the shutdown logic above also failed.
257+
output = stream.read()
258+
stream.close()
259+
260+
# Get the exit code of the process.
261+
exit_code = container.client.api.exec_inspect(exec_id)["ExitCode"]
262+
263+
return exit_code, output
264+
265+
166266
def get_socket_path(client: docker.client.DockerClient) -> str:
167267
"""Determine the local socket path.
168268
This works even when `podman machine` with its own host-mounts is involved

tests/containers/kubernetes_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def test_get_username(self):
9090

9191

9292
class TestFrame[S]:
93+
__test__ = False
94+
9395
def __init__(self):
9496
self.stack: list[tuple[S, Callable[[S], None] | None]] = []
9597

@@ -102,6 +104,7 @@ def defer_resource[T: ocp_resources.resource.Resource](
102104

103105
def defer[T](self, obj: T, destructor: Callable[[T], None] | None = None) -> T:
104106
self.stack.append((obj, destructor))
107+
return obj
105108

106109
def destroy(self, wait=False):
107110
while self.stack:

tests/containers/workbenches/jupyterlab/jupyterlab_datascience_test.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@
22

33
import pathlib
44
import tempfile
5+
import typing
56

67
import allure
8+
import pytest
9+
import testcontainers.core.network
10+
from testcontainers.core.waiting_utils import wait_for_logs
11+
from testcontainers.mysql import MySqlContainer
712

813
from tests.containers import conftest, docker_utils
914
from tests.containers.workbenches.workbench_image_test import WorkbenchContainer
1015

16+
if typing.TYPE_CHECKING:
17+
from tests.containers.conftest import Image
18+
from tests.containers.kubernetes_utils import TestFrame
19+
1120

1221
class TestJupyterLabDatascienceImage:
1322
"""Tests for JupyterLab Workbench images in this repository that are not -minimal-."""
@@ -70,3 +79,112 @@ def test_sklearn_smoke(self, jupyterlab_datascience_image: conftest.Image) -> No
7079

7180
finally:
7281
docker_utils.NotebookContainer(container).stop(timeout=0)
82+
83+
@allure.description("Check that mysql client functionality is working with SASL plain auth.")
84+
def test_mysql_connection(self, tf: TestFrame, datascience_image: Image, subtests):
85+
network = testcontainers.core.network.Network()
86+
tf.defer(network.create())
87+
88+
mysql_container = (
89+
MySqlContainer("docker.io/library/mysql:9.3.0").with_network(network).with_network_aliases("mysql")
90+
)
91+
tf.defer(mysql_container.start())
92+
93+
try:
94+
wait_for_logs(mysql_container, r"mysqld: ready for connections.", timeout=30)
95+
except TimeoutError:
96+
print("Container is not ready.")
97+
print(mysql_container.get_wrapped_container().logs(stdout=True, stderr=True))
98+
raise
99+
print("Container is ready. Setting up test user...")
100+
101+
host = "mysql"
102+
port = 3306
103+
104+
# language=Python
105+
setup_mysql_user = f"""
106+
import mysql.connector
107+
108+
conn = mysql.connector.connect(
109+
user='root',
110+
password='{mysql_container.root_password}',
111+
host = "{host}",
112+
port = {port},
113+
)
114+
cursor = conn.cursor()
115+
print("Creating test users...")
116+
117+
cursor.execute(
118+
# language=mysql
119+
'''
120+
CREATE USER 'clearpassuser'@'%' IDENTIFIED WITH caching_sha2_password BY 'clearpassword';
121+
GRANT ALL PRIVILEGES ON *.* TO 'clearpassuser'@'%';
122+
123+
FLUSH PRIVILEGES;
124+
''')
125+
cursor.close()
126+
conn.close()
127+
print("Test users created successfully.")
128+
"""
129+
130+
# language=Python
131+
clearpassuser = f"""
132+
import mysql.connector
133+
134+
try:
135+
cnx = mysql.connector.connect(
136+
user='clearpassuser',
137+
password='clearpassword',
138+
host='{host}',
139+
port={port},
140+
auth_plugin='mysql_clear_password',
141+
)
142+
cursor = cnx.cursor()
143+
cursor.execute("SELECT 1")
144+
result = cursor.fetchone()
145+
if result == (1,):
146+
print("MySQL connection successful!")
147+
else:
148+
print("MySQL connection failed!")
149+
cnx.close()
150+
except Exception as e:
151+
print(f"An error occurred: {{e}}")
152+
raise
153+
"""
154+
155+
container = WorkbenchContainer(image=datascience_image.name, user=4321, group_add=[0])
156+
(container.with_network(network).with_command("/bin/sh -c 'sleep infinity'"))
157+
try:
158+
container.start(wait_for_readiness=False)
159+
160+
# RHOAIENG-140: code-server image users are expected to install their own db clients
161+
if "-code-server-" in datascience_image.labels["name"]:
162+
exit_code, output = container.exec(["python", "-m", "pip", "install", "mysql-connector-python==9.3.0"])
163+
output_str = output.decode()
164+
print(output_str)
165+
166+
assert exit_code == 0, f"Failed to install mysql-connector-python: {output_str}"
167+
elif "-rstudio-" in datascience_image.labels["name"]:
168+
pytest.skip(
169+
f"Image {datascience_image.name} does have -rstudio- in {datascience_image.labels['name']=}'"
170+
)
171+
172+
with subtests.test("Setting the user..."):
173+
exit_code, output = container.exec(["python", "-c", setup_mysql_user])
174+
output_str = output.decode()
175+
176+
print(output_str)
177+
178+
assert "Test users created successfully." in output_str
179+
assert exit_code == 0
180+
181+
with subtests.test("Checking the output of the clearpassuser script..."):
182+
exit_code, output = container.exec(["python", "-c", clearpassuser])
183+
output_str = output.decode()
184+
185+
print(output_str)
186+
187+
assert "MySQL connection successful!" in output_str
188+
assert exit_code == 0
189+
finally:
190+
docker_utils.NotebookContainer(container).stop(timeout=0)

0 commit comments

Comments
 (0)