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/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
- sp.run(
45
- [
46
- "voms-proxy-init",
47
- "-valid",
48
- "9999:00",
49
- "-cert",
50
- USER_CERT,
51
- "-key",
52
- USER_KEY,
53
- ],
54
- check=True,
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