Skip to content

Commit 1784aa6

Browse files
authored
OP#10283: Replace MinIO with RustFS in S3 test fixtures
MinIO stopped publishing free images to Docker Hub and later removed the minio/minio repository entirely, breaking the acceptance and integration tests that spin one up via testcontainers. testcontainers-python has no dedicated RustFS module, so RustFsContainer wraps the official rustfs/rustfs image as a generic container the same way testcontainers.minio.MinioContainer wraps MinIO's; the minio Python client still works unchanged since RustFS speaks the same S3 API. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HKAcf8GCJDLgziwYz3uyC2
1 parent fdd8c11 commit 1784aa6

6 files changed

Lines changed: 104 additions & 38 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ repos:
4747
- interrogate==1.7.0
4848
- isort==6.0.1
4949
- matplotlib==3.10.0
50+
- minio==7.2.20
5051
- more-itertools==10.7.0
5152
- mypy==1.16.1
5253
- nicegui==2.24.1

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ dev = [
5454
"flake8==7.3.0",
5555
"interrogate==1.7.0",
5656
"isort==6.0.1",
57+
"minio==7.2.20",
5758
"mypy==1.16.1",
5859
"pandas-stubs==2.3.2.250926",
5960
"pre-commit==4.1.0",
@@ -67,7 +68,7 @@ dev = [
6768
"pytest-timeout==2.4.0",
6869
"PyYAML==6.0.2",
6970
"selenium==4.40.0",
70-
"testcontainers[minio]==4.13.1",
71+
"testcontainers==4.13.1",
7172
"types-aiobotocore-s3==2.26.0",
7273
"twine==6.2.0",
7374
"yamllint==1.35.1",

‎tests/acceptance/test_load_from_s3.py‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535

3636
BUCKET = "recordings"
3737
PREFIX = "project-0/site-0/camera-1"
38-
MINIO_IMAGE = "minio/minio:RELEASE.2025-09-07T16-13-09Z"
38+
# Pinned for reproducibility, and built for both amd64 and arm64.
39+
RUSTFS_IMAGE = "rustfs/rustfs:1.0.0-rc.6"
3940
CHUNKS = ["10-00-00", "10-15-00"]
4041

4142

@@ -52,16 +53,16 @@ def _docker_is_available() -> bool:
5253

5354

5455
pytestmark = pytest.mark.skipif(
55-
not _docker_is_available(), reason="needs Docker to run MinIO"
56+
not _docker_is_available(), reason="needs Docker to run RustFS"
5657
)
5758

5859

5960
@pytest.fixture(scope="module")
60-
def minio() -> Iterator[dict]:
61-
from testcontainers.minio import MinioContainer
61+
def rustfs() -> Iterator[dict]:
62+
from tests.utils.rustfs_container import RustFsContainer
6263

6364
os.environ.setdefault("TESTCONTAINERS_RYUK_DISABLED", "true")
64-
with MinioContainer(image=MINIO_IMAGE) as container:
65+
with RustFsContainer(image=RUSTFS_IMAGE) as container:
6566
client = container.get_client()
6667
client.make_bucket(BUCKET)
6768
for chunk in CHUNKS:
@@ -76,19 +77,19 @@ def minio() -> Iterator[dict]:
7677

7778

7879
@pytest.fixture
79-
def s3_app(minio: dict, tmp_path: Path) -> Iterator[Any]:
80-
"""The real application, started in s3 mode against MinIO.
80+
def s3_app(rustfs: dict, tmp_path: Path) -> Iterator[Any]:
81+
"""The real application, started in s3 mode against RustFS.
8182
8283
Configuration is environment only, so pointing the documented variables at a
83-
throwaway MinIO exercises the production path with no test-only hooks.
84+
throwaway RustFS exercises the production path with no test-only hooks.
8485
"""
8586
environment = dict(os.environ)
8687
environment.update(
8788
{
8889
ENV_DATA_TRANSFER_MODE: "s3",
89-
ENV_S3_ENDPOINT_URL: minio["endpoint_url"],
90-
ENV_S3_ACCESS_KEY: minio["access_key"],
91-
ENV_S3_SECRET_KEY: minio["secret_key"],
90+
ENV_S3_ENDPOINT_URL: rustfs["endpoint_url"],
91+
ENV_S3_ACCESS_KEY: rustfs["access_key"],
92+
ENV_S3_SECRET_KEY: rustfs["secret_key"],
9293
ENV_S3_BUCKET: BUCKET,
9394
ENV_S3_KEY_PREFIX: PREFIX,
9495
ENV_S3_USER_SOURCE: str(tmp_path / "user-source"),

tests/integration/OTAnalytics/plugin_s3/test_load_from_minio.py renamed to tests/integration/OTAnalytics/plugin_s3/test_load_from_rustfs.py

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,8 @@
4040
# a suffix swap would look for an object that is not there.
4141
VIDEO_TYPE = ".mkv"
4242

43-
# Pinned for reproducibility. testcontainers' own default is a 2022 build that is
44-
# amd64 only and will not start on an arm64 host.
45-
MINIO_IMAGE = "minio/minio:RELEASE.2025-09-07T16-13-09Z"
43+
# Pinned for reproducibility, and built for both amd64 and arm64.
44+
RUSTFS_IMAGE = "rustfs/rustfs:1.0.0-rc.6"
4645

4746

4847
def _docker_is_available() -> bool:
@@ -58,19 +57,19 @@ def _docker_is_available() -> bool:
5857

5958

6059
pytestmark = pytest.mark.skipif(
61-
not _docker_is_available(), reason="needs Docker to run MinIO"
60+
not _docker_is_available(), reason="needs Docker to run RustFS"
6261
)
6362

6463

6564
@pytest.fixture(scope="module")
66-
def minio() -> Iterator[dict]:
67-
from testcontainers.minio import MinioContainer
65+
def rustfs() -> Iterator[dict]:
66+
from tests.utils.rustfs_container import RustFsContainer
6867

6968
# The reaper container bind-mounts the Docker socket, which Docker Desktop
7069
# refuses on some hosts. The context manager below cleans up on a normal
7170
# exit; the reaper only matters if the test process is killed outright.
7271
os.environ.setdefault("TESTCONTAINERS_RYUK_DISABLED", "true")
73-
with MinioContainer(image=MINIO_IMAGE) as container:
72+
with RustFsContainer(image=RUSTFS_IMAGE) as container:
7473
client = container.get_client()
7574
client.make_bucket(BUCKET)
7675
for chunk in CHUNKS:
@@ -104,12 +103,12 @@ class Given:
104103
user_source: Path
105104

106105

107-
def create_given(minio: dict, tmp_path: Path, hours: float = 1) -> Given:
106+
def create_given(rustfs: dict, tmp_path: Path, hours: float = 1) -> Given:
108107
user_source = tmp_path / "user-source"
109108
config = S3Config(
110-
endpoint_url=minio["endpoint_url"],
111-
access_key=minio["access_key"],
112-
secret_key=minio["secret_key"],
109+
endpoint_url=rustfs["endpoint_url"],
110+
access_key=rustfs["access_key"],
111+
secret_key=rustfs["secret_key"],
113112
bucket=BUCKET,
114113
region=None,
115114
key_prefix=PREFIX,
@@ -151,14 +150,14 @@ def create_video_target(given: Given) -> S3VideoFileProvider:
151150
)
152151

153152

154-
class TestLoadFromMinio:
153+
class TestLoadFromRustFs:
155154
"""#Requirement https://openproject.platomo.de/wp/10283"""
156155

157156
async def test_an_over_long_selection_loads_only_up_to_the_cap(
158-
self, minio: dict, tmp_path: Path
157+
self, rustfs: dict, tmp_path: Path
159158
) -> None:
160159
"""10:00-11:00 clamps to 10:20, so only the 10:00 and 10:15 chunks load."""
161-
given = create_given(minio, tmp_path, hours=1)
160+
given = create_given(rustfs, tmp_path, hours=1)
162161
target = create_track_target(given)
163162

164163
provided = await target.provide()
@@ -170,9 +169,9 @@ async def test_an_over_long_selection_loads_only_up_to_the_cap(
170169
given.dialog.report_clamped.assert_called_once()
171170

172171
async def test_downloads_each_track_file_with_its_video(
173-
self, minio: dict, tmp_path: Path
172+
self, rustfs: dict, tmp_path: Path
174173
) -> None:
175-
given = create_given(minio, tmp_path, hours=1)
174+
given = create_given(rustfs, tmp_path, hours=1)
176175
target = create_track_target(given)
177176

178177
await target.provide()
@@ -187,9 +186,9 @@ async def test_downloads_each_track_file_with_its_video(
187186
assert staged.is_file(), f"{staged} was not downloaded"
188187

189188
async def test_stages_objects_under_paths_mirroring_their_keys(
190-
self, minio: dict, tmp_path: Path
189+
self, rustfs: dict, tmp_path: Path
191190
) -> None:
192-
given = create_given(minio, tmp_path, hours=1)
191+
given = create_given(rustfs, tmp_path, hours=1)
193192
target = create_track_target(given)
194193

195194
provided = await target.provide()
@@ -199,20 +198,20 @@ async def test_stages_objects_under_paths_mirroring_their_keys(
199198
)
200199

201200
async def test_reads_the_bytes_that_were_stored(
202-
self, minio: dict, tmp_path: Path
201+
self, rustfs: dict, tmp_path: Path
203202
) -> None:
204-
given = create_given(minio, tmp_path, hours=1)
203+
given = create_given(rustfs, tmp_path, hours=1)
205204
target = create_track_target(given)
206205

207206
provided = await target.provide()
208207

209208
assert bz2.decompress(provided[0].read_bytes()).startswith(b"{")
210209

211210
async def test_pairs_each_track_file_with_the_video_its_metadata_names(
212-
self, minio: dict, tmp_path: Path
211+
self, rustfs: dict, tmp_path: Path
213212
) -> None:
214213
"""A suffix swap would have looked for a .mp4 that does not exist."""
215-
given = create_given(minio, tmp_path, hours=1)
214+
given = create_given(rustfs, tmp_path, hours=1)
216215
target = create_track_target(given)
217216

218217
await target.provide()
@@ -221,8 +220,8 @@ async def test_pairs_each_track_file_with_the_video_its_metadata_names(
221220
assert (staged / f"OTCamera19_FR20_2023-05-24_10-00-00{VIDEO_TYPE}").is_file()
222221
assert not (staged / "OTCamera19_FR20_2023-05-24_10-00-00.mp4").exists()
223222

224-
async def test_videos_load_on_their_own(self, minio: dict, tmp_path: Path) -> None:
225-
given = create_given(minio, tmp_path, hours=1)
223+
async def test_videos_load_on_their_own(self, rustfs: dict, tmp_path: Path) -> None:
224+
given = create_given(rustfs, tmp_path, hours=1)
226225
target = create_video_target(given)
227226

228227
provided = await target.provide()

‎tests/utils/rustfs_container.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""A testcontainers wrapper for RustFS, an S3-compatible object store.
2+
3+
testcontainers-python ships a dedicated module for MinIO but not for RustFS (that
4+
only exists in the Rust bindings), so this wraps the official `rustfs/rustfs` image
5+
as a generic container the same way `testcontainers.minio.MinioContainer` wraps
6+
MinIO's. MinIO stopped publishing free images to Docker Hub in 2025 and later
7+
pulled the `minio/minio` repository entirely, which is why acceptance and
8+
integration tests were switched to RustFS.
9+
"""
10+
11+
from minio import Minio
12+
from testcontainers.core.container import DockerContainer
13+
from testcontainers.core.wait_strategies import HttpWaitStrategy
14+
15+
16+
class RustFsContainer(DockerContainer):
17+
"""Runs RustFS and exposes it through the `minio` Python client.
18+
19+
RustFS speaks the S3 API, so the same client used against MinIO works here
20+
unchanged; only how the container itself is started and its endpoint is
21+
obtained differs.
22+
"""
23+
24+
def __init__(
25+
self,
26+
image: str,
27+
port: int = 9000,
28+
access_key: str = "rustfsadmin",
29+
secret_key: str = "rustfsadmin",
30+
**kwargs: object,
31+
) -> None:
32+
super().__init__(image, **kwargs)
33+
self.port = port
34+
self.access_key = access_key
35+
self.secret_key = secret_key
36+
37+
self.with_exposed_ports(self.port)
38+
self.with_env("RUSTFS_ACCESS_KEY", self.access_key)
39+
self.with_env("RUSTFS_SECRET_KEY", self.secret_key)
40+
self.with_env("RUSTFS_ADDRESS", f"0.0.0.0:{self.port}")
41+
self.waiting_for(
42+
HttpWaitStrategy(self.port, "/health/ready").for_status_code(200)
43+
)
44+
45+
def get_client(self) -> Minio:
46+
"""Returns a `minio` Python client connected to this container."""
47+
return Minio(
48+
self.get_config()["endpoint"],
49+
access_key=self.access_key,
50+
secret_key=self.secret_key,
51+
secure=False,
52+
)
53+
54+
def get_config(self) -> dict:
55+
"""Returns the container's endpoint, access key, and secret key."""
56+
host_ip = self.get_container_host_ip()
57+
exposed_port = self.get_exposed_port(self.port)
58+
return {
59+
"endpoint": f"{host_ip}:{exposed_port}",
60+
"access_key": self.access_key,
61+
"secret_key": self.secret_key,
62+
}

‎uv.lock‎

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

0 commit comments

Comments
 (0)