ctao-bdms-clients 0.2.1__py3-none-any.whl → 0.3.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bdms/_version.py +2 -2
- bdms/acada_ingest_cli.py +400 -0
- bdms/acada_ingestion.py +480 -13
- bdms/tests/conftest.py +132 -12
- bdms/tests/test_acada_ingest_cli.py +279 -0
- bdms/tests/test_acada_ingestion.py +1242 -50
- bdms/tests/test_dpps_rel_0_0.py +6 -0
- bdms/tests/utils.py +11 -1
- {ctao_bdms_clients-0.2.1.dist-info → ctao_bdms_clients-0.3.0rc1.dist-info}/METADATA +5 -1
- ctao_bdms_clients-0.3.0rc1.dist-info/RECORD +23 -0
- ctao_bdms_clients-0.3.0rc1.dist-info/entry_points.txt +2 -0
- ctao_bdms_clients-0.2.1.dist-info/RECORD +0 -20
- {ctao_bdms_clients-0.2.1.dist-info → ctao_bdms_clients-0.3.0rc1.dist-info}/WHEEL +0 -0
- {ctao_bdms_clients-0.2.1.dist-info → ctao_bdms_clients-0.3.0rc1.dist-info}/licenses/LICENSE +0 -0
- {ctao_bdms_clients-0.2.1.dist-info → ctao_bdms_clients-0.3.0rc1.dist-info}/top_level.txt +0 -0
bdms/tests/conftest.py
CHANGED
@@ -1,11 +1,14 @@
|
|
1
|
+
import json
|
1
2
|
import logging
|
2
3
|
import os
|
3
4
|
import subprocess as sp
|
5
|
+
import time
|
4
6
|
from datetime import datetime
|
5
7
|
from pathlib import Path
|
6
8
|
from secrets import token_hex
|
7
9
|
|
8
10
|
import pytest
|
11
|
+
from filelock import FileLock
|
9
12
|
from rucio.client.scopeclient import ScopeClient
|
10
13
|
|
11
14
|
from bdms.tests.utils import download_test_file, reset_xrootd_permissions
|
@@ -41,18 +44,26 @@ def _auth_proxy(tmp_path_factory):
|
|
41
44
|
# Key has to have 0o600 permissions, but due to the way we
|
42
45
|
# we create and mount it, it does not. We copy to a tmp file
|
43
46
|
# set correct permissions and then create the proxy
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
47
|
+
|
48
|
+
try:
|
49
|
+
sp.run(
|
50
|
+
[
|
51
|
+
"voms-proxy-init",
|
52
|
+
"-valid",
|
53
|
+
"9999:00",
|
54
|
+
"-cert",
|
55
|
+
USER_CERT,
|
56
|
+
"-key",
|
57
|
+
USER_KEY,
|
58
|
+
],
|
59
|
+
check=True,
|
60
|
+
capture_output=True,
|
61
|
+
text=True,
|
62
|
+
)
|
63
|
+
|
64
|
+
except sp.CalledProcessError as e:
|
65
|
+
error_msg = e.stderr.strip() if e.stderr else str(e)
|
66
|
+
raise pytest.fail(f"VOMS proxy failed: {error_msg}")
|
56
67
|
|
57
68
|
|
58
69
|
@pytest.fixture(scope="session")
|
@@ -115,3 +126,112 @@ def onsite_test_file(
|
|
115
126
|
reset_xrootd_permissions(storage_mount_path)
|
116
127
|
|
117
128
|
return test_file_path, test_file_content
|
129
|
+
|
130
|
+
|
131
|
+
def run_kubectl(args: list[str]) -> str:
|
132
|
+
"""Run a kubectl command with the given arguments and return the output."""
|
133
|
+
result = sp.run(
|
134
|
+
["./kubectl"] + args,
|
135
|
+
check=True,
|
136
|
+
capture_output=True,
|
137
|
+
text=True,
|
138
|
+
)
|
139
|
+
if result.returncode != 0:
|
140
|
+
raise RuntimeError(f"kubectl command failed: {result.stderr}")
|
141
|
+
|
142
|
+
return result.stdout.strip()
|
143
|
+
|
144
|
+
|
145
|
+
def wait_for_deployment_ready(deployment_name, replicas):
|
146
|
+
"""Wait for a deployment to be ready with the specified number of replicas."""
|
147
|
+
|
148
|
+
timeout_stop_at = time.time() + 300
|
149
|
+
while True:
|
150
|
+
result = run_kubectl(["get", deployment_name, "-o", "json"])
|
151
|
+
ready_replicas = json.loads(result)["status"].get("readyReplicas", 0)
|
152
|
+
|
153
|
+
if ready_replicas >= replicas:
|
154
|
+
logging.info(
|
155
|
+
"%s deployment is ready with %s replicas.",
|
156
|
+
deployment_name,
|
157
|
+
ready_replicas,
|
158
|
+
)
|
159
|
+
break
|
160
|
+
|
161
|
+
if time.time() > timeout_stop_at:
|
162
|
+
raise TimeoutError(
|
163
|
+
f"Timeout while waiting for {deployment_name} deployment to be ready."
|
164
|
+
)
|
165
|
+
|
166
|
+
logging.info(
|
167
|
+
"Waiting for %s deployment to be ready. Current ready replicas: %s, expected: %s till timeout in %s s",
|
168
|
+
deployment_name,
|
169
|
+
ready_replicas,
|
170
|
+
replicas,
|
171
|
+
int(timeout_stop_at - time.time()),
|
172
|
+
)
|
173
|
+
|
174
|
+
time.sleep(1)
|
175
|
+
|
176
|
+
|
177
|
+
def deployment_scale(daemon_name: str, replicas: int = 1) -> None:
|
178
|
+
"""Scale a deployment to a specific number of replicas."""
|
179
|
+
|
180
|
+
deployment_name = "deployment/bdms-" + daemon_name
|
181
|
+
|
182
|
+
run_kubectl(
|
183
|
+
[
|
184
|
+
"scale",
|
185
|
+
deployment_name,
|
186
|
+
f"--replicas={replicas}",
|
187
|
+
]
|
188
|
+
)
|
189
|
+
|
190
|
+
if replicas > 0:
|
191
|
+
wait_for_deployment_ready(deployment_name, replicas)
|
192
|
+
|
193
|
+
# there is a delay between demon writing lock file and the daemon starting to process trigger files
|
194
|
+
time.sleep(3)
|
195
|
+
|
196
|
+
# wait for any terminating pods to finish.
|
197
|
+
# they tend to linger around and while they do not count as replicas, they may still interfere with tests by modifying the trigger files.
|
198
|
+
while True:
|
199
|
+
result = run_kubectl(["get", "pods"])
|
200
|
+
|
201
|
+
if "Terminating" not in result:
|
202
|
+
break
|
203
|
+
|
204
|
+
logging.info("Waiting for any Terminating pods to disappear...")
|
205
|
+
time.sleep(5)
|
206
|
+
|
207
|
+
|
208
|
+
@pytest.fixture
|
209
|
+
def enable_repearer_daemon():
|
210
|
+
"""Fixture to enable the repeater daemon during tests."""
|
211
|
+
|
212
|
+
deployment_scale("judge-repairer", 1)
|
213
|
+
yield
|
214
|
+
deployment_scale("judge-repairer", 0)
|
215
|
+
|
216
|
+
|
217
|
+
@pytest.fixture
|
218
|
+
def enable_ingestion_daemon():
|
219
|
+
"""Fixture to enable the ingestion daemon during tests."""
|
220
|
+
|
221
|
+
deployment_scale("ingestion-daemon", 1)
|
222
|
+
yield
|
223
|
+
deployment_scale("ingestion-daemon", 0)
|
224
|
+
|
225
|
+
|
226
|
+
@pytest.fixture
|
227
|
+
def disable_ingestion_daemon():
|
228
|
+
"""Fixture to suspend the ingestion daemon during tests."""
|
229
|
+
deployment_scale("ingestion-daemon", 0)
|
230
|
+
|
231
|
+
|
232
|
+
@pytest.fixture
|
233
|
+
def lock_for_ingestion_daemon():
|
234
|
+
"""Fixture to prevent daemon tests from running simultaneously."""
|
235
|
+
|
236
|
+
with FileLock(STORAGE_MOUNT_PATH / "ingestion_daemon.lock"):
|
237
|
+
yield
|
@@ -0,0 +1,279 @@
|
|
1
|
+
import threading
|
2
|
+
import time
|
3
|
+
from pathlib import Path
|
4
|
+
from shutil import copy2
|
5
|
+
|
6
|
+
import numpy as np
|
7
|
+
import pytest
|
8
|
+
import yaml
|
9
|
+
from astropy.io import fits
|
10
|
+
from rucio.client.downloadclient import DownloadClient
|
11
|
+
from rucio.client.replicaclient import ReplicaClient
|
12
|
+
from rucio.common.utils import adler32
|
13
|
+
|
14
|
+
from bdms.acada_ingest_cli import main as ingest_cli
|
15
|
+
from bdms.acada_ingest_cli import parse_args_and_config
|
16
|
+
from bdms.tests.utils import reset_xrootd_permissions
|
17
|
+
|
18
|
+
ONSITE_RSE = "STORAGE-1"
|
19
|
+
|
20
|
+
|
21
|
+
@pytest.mark.usefixtures("_auth_proxy", "lock_for_ingestion_daemon")
|
22
|
+
@pytest.mark.parametrize("dry_run", [True, False], ids=["dry_run", "no_dry_run"])
|
23
|
+
def test_cli_ingestion(
|
24
|
+
storage_mount_path, test_vo, test_scope, subarray_test_file, tmp_path, dry_run
|
25
|
+
):
|
26
|
+
"""
|
27
|
+
Test CLI ACADA ingestion.
|
28
|
+
"""
|
29
|
+
filename = Path(subarray_test_file).name
|
30
|
+
acada_path = (
|
31
|
+
storage_mount_path / test_vo / test_scope / "test_cli_ingestion" / filename
|
32
|
+
)
|
33
|
+
acada_path.parent.mkdir(parents=True, exist_ok=True)
|
34
|
+
copy2(subarray_test_file, str(acada_path))
|
35
|
+
reset_xrootd_permissions(storage_mount_path)
|
36
|
+
|
37
|
+
expected_lfn = f"/{acada_path.relative_to(storage_mount_path)}"
|
38
|
+
lock_file = tmp_path / "cli_test.lock"
|
39
|
+
|
40
|
+
def run_daemon():
|
41
|
+
args = [
|
42
|
+
f"--data-path={storage_mount_path}",
|
43
|
+
f"--rse={ONSITE_RSE}",
|
44
|
+
f"--vo={test_vo}",
|
45
|
+
f"--scope={test_scope}",
|
46
|
+
"--workers=1",
|
47
|
+
f"--lock-file={lock_file}",
|
48
|
+
"--polling-interval=0.5",
|
49
|
+
"--disable-metrics",
|
50
|
+
f"--log-file={tmp_path / 'daemon.log'}",
|
51
|
+
]
|
52
|
+
|
53
|
+
if dry_run:
|
54
|
+
args.append("--dry-run")
|
55
|
+
|
56
|
+
ingest_cli(args=args)
|
57
|
+
|
58
|
+
# Start daemon
|
59
|
+
daemon_thread = threading.Thread(target=run_daemon, daemon=True)
|
60
|
+
daemon_thread.start()
|
61
|
+
time.sleep(1.0) # time for daemon to initialize
|
62
|
+
|
63
|
+
if not dry_run:
|
64
|
+
trigger_file = Path(str(acada_path) + ".trigger")
|
65
|
+
trigger_file.symlink_to(acada_path.relative_to(acada_path.parent))
|
66
|
+
|
67
|
+
# Wait for ingestion to complete
|
68
|
+
replica_client = ReplicaClient()
|
69
|
+
for _ in range(30):
|
70
|
+
try:
|
71
|
+
replicas = list(
|
72
|
+
replica_client.list_replicas(
|
73
|
+
dids=[{"scope": test_scope, "name": expected_lfn}]
|
74
|
+
)
|
75
|
+
)
|
76
|
+
if replicas:
|
77
|
+
break
|
78
|
+
except Exception:
|
79
|
+
pass
|
80
|
+
time.sleep(1.0)
|
81
|
+
else:
|
82
|
+
pytest.fail(f"No replica found for {expected_lfn}")
|
83
|
+
|
84
|
+
for _ in range(10):
|
85
|
+
if not trigger_file.exists():
|
86
|
+
break
|
87
|
+
time.sleep(1.0)
|
88
|
+
|
89
|
+
# lock file cleanup
|
90
|
+
if lock_file.exists():
|
91
|
+
lock_file.unlink()
|
92
|
+
|
93
|
+
# Clean up filelock file
|
94
|
+
filelock_file = Path(str(lock_file) + ".lock")
|
95
|
+
if filelock_file.exists():
|
96
|
+
filelock_file.unlink()
|
97
|
+
|
98
|
+
# verify download
|
99
|
+
download_spec = {
|
100
|
+
"did": f"{test_scope}:{expected_lfn}",
|
101
|
+
"base_dir": str(tmp_path),
|
102
|
+
"no_subdir": True,
|
103
|
+
}
|
104
|
+
|
105
|
+
download_client = DownloadClient()
|
106
|
+
download_client.download_dids([download_spec])
|
107
|
+
|
108
|
+
download_path = tmp_path / expected_lfn.lstrip("/")
|
109
|
+
assert download_path.is_file(), f"Download failed at {download_path}"
|
110
|
+
assert adler32(str(download_path)) == adler32(
|
111
|
+
str(subarray_test_file)
|
112
|
+
), "Downloaded file content does not match the original."
|
113
|
+
|
114
|
+
|
115
|
+
def parse_args_and_check_error(args, error_message):
|
116
|
+
"""
|
117
|
+
Helper function to run the CLI and check for expected errors.
|
118
|
+
"""
|
119
|
+
if error_message:
|
120
|
+
with pytest.raises(SystemExit) as e:
|
121
|
+
parse_args_and_config(args=args)
|
122
|
+
assert error_message in e.value.__context__.message
|
123
|
+
else:
|
124
|
+
# Run without exceptions
|
125
|
+
return parse_args_and_config(args=args)
|
126
|
+
|
127
|
+
|
128
|
+
@pytest.mark.parametrize(
|
129
|
+
("port", "error_message"),
|
130
|
+
[
|
131
|
+
(1234, None),
|
132
|
+
(80, "Metrics port must be between 1024"),
|
133
|
+
("invalid_metrics", "Metrics port must be an integer"),
|
134
|
+
],
|
135
|
+
ids=["valid_port", "low_port", "invalid_port"],
|
136
|
+
)
|
137
|
+
def test_cli_metrics_port_validation(port, error_message):
|
138
|
+
"""
|
139
|
+
Test CLI ACADA ingestion exceptions.
|
140
|
+
"""
|
141
|
+
|
142
|
+
parse_args_and_check_error(
|
143
|
+
[
|
144
|
+
f"--metrics-port={port}",
|
145
|
+
],
|
146
|
+
error_message,
|
147
|
+
)
|
148
|
+
|
149
|
+
|
150
|
+
@pytest.mark.parametrize(
|
151
|
+
("polling_interval", "error_message"),
|
152
|
+
[
|
153
|
+
(1, None),
|
154
|
+
(0, "Polling interval must be positive"),
|
155
|
+
("invalid", "Polling interval must be a number, got"),
|
156
|
+
],
|
157
|
+
ids=["valid_offsite", "negative_offsite", "invalid_offsite"],
|
158
|
+
)
|
159
|
+
def test_cli_polling_interval(polling_interval, error_message):
|
160
|
+
"""
|
161
|
+
Test CLI ACADA ingestion with offsite copies.
|
162
|
+
"""
|
163
|
+
parse_args_and_check_error(
|
164
|
+
[
|
165
|
+
f"--polling-interval={polling_interval}",
|
166
|
+
],
|
167
|
+
error_message,
|
168
|
+
)
|
169
|
+
|
170
|
+
|
171
|
+
@pytest.mark.parametrize(
|
172
|
+
("check_interval", "error_message"),
|
173
|
+
[
|
174
|
+
(1.0, None),
|
175
|
+
(0.0, "Check interval must be positive"),
|
176
|
+
("invalid", "Check interval must be a number, got "),
|
177
|
+
],
|
178
|
+
ids=["valid_check_interval", "zero_check_interval", "invalid_check_interval"],
|
179
|
+
)
|
180
|
+
def test_cli_check_interval_validation(check_interval, error_message):
|
181
|
+
"""
|
182
|
+
Test CLI ACADA ingestion with check interval validation.
|
183
|
+
"""
|
184
|
+
|
185
|
+
parse_args_and_check_error(
|
186
|
+
[
|
187
|
+
f"--check-interval={check_interval}",
|
188
|
+
],
|
189
|
+
error_message,
|
190
|
+
)
|
191
|
+
|
192
|
+
|
193
|
+
@pytest.mark.usefixtures("_auth_proxy", "lock_for_ingestion_daemon")
|
194
|
+
def test_cli_ingestion_parallel(storage_mount_path, test_vo, test_scope, tmp_path):
|
195
|
+
"""Test CLI with 7 files and 4 workers for parallel ingestion."""
|
196
|
+
|
197
|
+
test_dir = storage_mount_path / test_vo / test_scope
|
198
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
199
|
+
|
200
|
+
test_files = []
|
201
|
+
rng = np.random.default_rng()
|
202
|
+
for i in range(7):
|
203
|
+
test_file = test_dir / f"testfile_{i}_20250609.fits"
|
204
|
+
hdu = fits.PrimaryHDU(rng.random((50, 50)))
|
205
|
+
hdu.writeto(test_file, overwrite=True, checksum=True)
|
206
|
+
test_files.append(test_file)
|
207
|
+
|
208
|
+
reset_xrootd_permissions(storage_mount_path)
|
209
|
+
|
210
|
+
lock_file = tmp_path / "ingestion_queue_test.lock"
|
211
|
+
|
212
|
+
def run_daemon():
|
213
|
+
ingest_cli(
|
214
|
+
args=[
|
215
|
+
f"--data-path={storage_mount_path}",
|
216
|
+
f"--rse={ONSITE_RSE}",
|
217
|
+
f"--vo={test_vo}",
|
218
|
+
f"--scope={test_scope}",
|
219
|
+
"--workers=4",
|
220
|
+
f"--lock-file={lock_file}",
|
221
|
+
"--polling-interval=0.5",
|
222
|
+
"--disable-metrics",
|
223
|
+
]
|
224
|
+
)
|
225
|
+
|
226
|
+
# Start daemon
|
227
|
+
daemon_thread = threading.Thread(target=run_daemon, daemon=True)
|
228
|
+
daemon_thread.start()
|
229
|
+
time.sleep(1.0)
|
230
|
+
|
231
|
+
for test_file in test_files:
|
232
|
+
trigger_file = Path(str(test_file) + ".trigger")
|
233
|
+
trigger_file.symlink_to(test_file.relative_to(test_file.parent))
|
234
|
+
|
235
|
+
# Wait for all files to be processed, ingestion done
|
236
|
+
replica_client = ReplicaClient()
|
237
|
+
for _ in range(30):
|
238
|
+
processed = 0
|
239
|
+
for test_file in test_files:
|
240
|
+
lfn = f"/{test_file.relative_to(storage_mount_path)}"
|
241
|
+
try:
|
242
|
+
replicas = list(
|
243
|
+
replica_client.list_replicas(
|
244
|
+
dids=[{"scope": test_scope, "name": lfn}]
|
245
|
+
)
|
246
|
+
)
|
247
|
+
if replicas:
|
248
|
+
processed += 1
|
249
|
+
except Exception:
|
250
|
+
pass
|
251
|
+
|
252
|
+
if processed == 7:
|
253
|
+
break
|
254
|
+
time.sleep(1.0)
|
255
|
+
else:
|
256
|
+
pytest.fail("Not all files were processed")
|
257
|
+
|
258
|
+
# Cleanup
|
259
|
+
for test_file in test_files:
|
260
|
+
test_file.unlink()
|
261
|
+
|
262
|
+
if lock_file.exists():
|
263
|
+
lock_file.unlink()
|
264
|
+
|
265
|
+
filelock_file = Path(str(lock_file) + ".lock")
|
266
|
+
if filelock_file.exists():
|
267
|
+
filelock_file.unlink()
|
268
|
+
|
269
|
+
|
270
|
+
def test_parse_config(tmp_path):
|
271
|
+
config_path = tmp_path / "config.yaml"
|
272
|
+
with config_path.open("w") as f:
|
273
|
+
yaml.dump({"workers": 12, "polling_interval": 60.0}, f)
|
274
|
+
|
275
|
+
args = parse_args_and_config([f"--config={config_path}", "--polling-interval=30.0"])
|
276
|
+
# config is parsed
|
277
|
+
assert args.workers == 12
|
278
|
+
# but cli args override config
|
279
|
+
assert args.polling_interval == 30.0
|