ctao-bdms-clients 0.2.0rc1__py3-none-any.whl → 0.3.0__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.
@@ -0,0 +1,134 @@
1
+ """Functions to extract metadata from input files."""
2
+
3
+ import logging
4
+
5
+ import numpy as np
6
+ from protozfits import File
7
+
8
+ # Configure logger
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # COMMON HEADER
12
+ start_time = "DataStream.DATE"
13
+
14
+ # COMMON DATA
15
+ origin = "DataStream.ORIGIN"
16
+ sb_id = "DataStream.sb_id"
17
+ obs_id = "DataStream.obs_id"
18
+
19
+ # -- FOR TEL_TRIG
20
+ tel_ids = "DataStream.tel_ids"
21
+
22
+ # -- FOR TEL_SUB
23
+ subarray_id = "DataStream.subarray_id"
24
+
25
+ METADATA_TEL = {
26
+ "HEADER": {
27
+ "observatory": origin,
28
+ "start_time": start_time,
29
+ "end_time": "Events.DATEEND",
30
+ },
31
+ "PAYLOAD": {
32
+ "sb_id": sb_id,
33
+ "obs_id": obs_id,
34
+ },
35
+ }
36
+
37
+ METADATA_SUB = {
38
+ "HEADER": {
39
+ "observatory": origin,
40
+ "start_time": start_time,
41
+ "end_time": "SubarrayEvents.DATEEND",
42
+ },
43
+ "PAYLOAD": {
44
+ "subarray_id": subarray_id,
45
+ "sb_id": sb_id,
46
+ "obs_id": obs_id,
47
+ },
48
+ }
49
+
50
+ METADATA_TRIG = {
51
+ "HEADER": {
52
+ "observatory": origin,
53
+ "start_time": start_time,
54
+ "end_time": "Triggers.DATEEND",
55
+ },
56
+ "PAYLOAD": {
57
+ "tel_ids": tel_ids,
58
+ "sb_id": sb_id,
59
+ "obs_id": obs_id,
60
+ },
61
+ }
62
+
63
+ #: Mapping from DataStream.PBFHEAD to the metadata items we want to collect
64
+ METADATA_SCHEMAS = {
65
+ "DL0v1.Trigger.DataStream": METADATA_TRIG,
66
+ "DL0v1.Subarray.DataStream": METADATA_SUB,
67
+ "DL0v1.Telescope.DataStream": METADATA_TEL,
68
+ }
69
+
70
+
71
+ def extract_metadata_from_headers(hdul):
72
+ """Extract metadata from FITS headers of hdul."""
73
+ all_headers = {}
74
+ for hdu in hdul:
75
+ if hdu.is_image:
76
+ continue
77
+ all_headers[hdu.name] = dict(hdu.header)
78
+
79
+ try:
80
+ all_headers["DataStream"]
81
+ except KeyError:
82
+ logger.error("No DataStream HDU found in the FITS file.")
83
+ return {}
84
+
85
+ pbfhead = all_headers["DataStream"]["PBFHEAD"]
86
+ schema = METADATA_SCHEMAS.get(pbfhead)
87
+ if schema is None:
88
+ logger.error(
89
+ "The PBFHEAD %r does not correspond to any known FITS type.", pbfhead
90
+ )
91
+ return {}
92
+
93
+ logger.debug("Headers extracted: %s", all_headers.keys())
94
+
95
+ metadata = {}
96
+ for value_name, metadata_path in schema["HEADER"].items():
97
+ extname, header_key = metadata_path.split(".")
98
+ table = all_headers[extname][header_key]
99
+ metadata[value_name] = table
100
+
101
+ return metadata
102
+
103
+
104
+ def extract_metadata_from_data(path):
105
+ """Extract metadata from zFITS payload in path."""
106
+ with File(path) as f:
107
+ if not hasattr(f, "DataStream"):
108
+ return {}
109
+
110
+ pbfhead = f.DataStream.header["PBFHEAD"]
111
+ schema = METADATA_SCHEMAS.get(pbfhead)
112
+ if schema is None:
113
+ logger.error(
114
+ "The PBFHEAD %r does not correspond to any known FITS type.", pbfhead
115
+ )
116
+ return {}
117
+
118
+ metadata = {}
119
+ for value_name, metadata_path in schema["PAYLOAD"].items():
120
+ hdu, column = metadata_path.split(".")
121
+ row = getattr(f, hdu)[0]
122
+ metadata[value_name] = getattr(row, column)
123
+
124
+ if isinstance(metadata[value_name], np.ndarray):
125
+ # Convert numpy array to a Python list
126
+ metadata[value_name] = metadata[value_name].tolist()
127
+
128
+ logger.debug(
129
+ "Value '%s' from '%s' extracted. (renamed as '%s')",
130
+ column,
131
+ hdu,
132
+ value_name,
133
+ )
134
+ return metadata
bdms/tests/conftest.py CHANGED
@@ -1,14 +1,17 @@
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
- from bdms.tests.utils import download_test_file
14
+ from bdms.tests.utils import download_test_file, reset_xrootd_permissions
12
15
 
13
16
  USER_CERT = os.getenv("RUCIO_CFG_CLIENT_CERT", "/opt/rucio/etc/usercert.pem")
14
17
  USER_KEY = os.getenv("RUCIO_CFG_CLIENT_KEY", "/opt/rucio/etc/userkey.pem")
@@ -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")
@@ -90,5 +101,137 @@ def tel_trigger_test_file():
90
101
  @pytest.fixture(scope="session")
91
102
  def tel_events_test_file():
92
103
  """Fixture to download a telescope events test file"""
93
- path = "acada-small/DL0/LSTN-01/ctao-n-acada/acada-adh/events/2025/02/04/TEL001_SDH0000_20250204T213354_SBID0000000002000000066_OBSID0000000002000000200_CHUNK000.fits.fz"
104
+ path = "acada-small/DL0/LSTN-01/ctao-n-acada/acada-adh/events/2025/02/04/TEL001_SDH0000_20250204T213354_SBID0000000002000000066_OBSID0000000002000000200_CHUNK001.fits.fz"
94
105
  return download_test_file(path)
106
+
107
+
108
+ @pytest.fixture
109
+ def onsite_test_file(
110
+ storage_mount_path: Path, test_scope: str, test_vo: str
111
+ ) -> tuple[Path, str]:
112
+ """Create a dummy file in the shared storage for testing."""
113
+
114
+ unique_id = f"{datetime.now():%Y%m%d_%H%M%S}_{token_hex(8)}"
115
+ filename = f"testfile_{unique_id}.txt"
116
+
117
+ test_file_path = storage_mount_path / test_vo / test_scope / filename
118
+ test_file_path.parent.mkdir(parents=True, exist_ok=True)
119
+
120
+ # Write a small test content (simulating a .fits.fz file with minimal content for testing)
121
+ test_file_content = f"Test file with random content: {unique_id}"
122
+ test_file_path.write_text(test_file_content)
123
+
124
+ # need to change file permissions of created directories so that
125
+ # the xrootd still can read and write there
126
+ reset_xrootd_permissions(storage_mount_path)
127
+
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