ctao-bdms-clients 0.2.1__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.
- 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.0.dist-info}/METADATA +5 -1
- ctao_bdms_clients-0.3.0.dist-info/RECORD +23 -0
- ctao_bdms_clients-0.3.0.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.0.dist-info}/WHEEL +0 -0
- {ctao_bdms_clients-0.2.1.dist-info → ctao_bdms_clients-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {ctao_bdms_clients-0.2.1.dist-info → ctao_bdms_clients-0.3.0.dist-info}/top_level.txt +0 -0
bdms/_version.py
CHANGED
bdms/acada_ingest_cli.py
ADDED
@@ -0,0 +1,400 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""CLI tool for ACADA Ingestion - Uses IngestionClient and Ingest classes."""
|
3
|
+
|
4
|
+
import argparse
|
5
|
+
import logging
|
6
|
+
import os
|
7
|
+
import sys
|
8
|
+
from pathlib import Path
|
9
|
+
|
10
|
+
from prometheus_client import start_http_server
|
11
|
+
from ruamel.yaml import YAML
|
12
|
+
|
13
|
+
from bdms.acada_ingestion import Ingest, IngestionClient
|
14
|
+
|
15
|
+
log = logging.getLogger("acada_cli")
|
16
|
+
|
17
|
+
|
18
|
+
def validate_log_level(value):
|
19
|
+
"""Validate and convert log level to uppercase."""
|
20
|
+
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
21
|
+
upper_value = value.upper()
|
22
|
+
if upper_value not in valid_levels:
|
23
|
+
raise argparse.ArgumentTypeError(
|
24
|
+
f"Invalid log level. Choose from: {', '.join(valid_levels)}"
|
25
|
+
)
|
26
|
+
return upper_value
|
27
|
+
|
28
|
+
|
29
|
+
# validation functions
|
30
|
+
def num_workers_positive_int(value):
|
31
|
+
"""Validate positive integer for workers."""
|
32
|
+
try:
|
33
|
+
int_value = int(value)
|
34
|
+
if int_value <= 0:
|
35
|
+
raise argparse.ArgumentTypeError("Number of workers must be positive")
|
36
|
+
return int_value
|
37
|
+
except ValueError:
|
38
|
+
raise argparse.ArgumentTypeError(f"Workers must be an integer, got {value!r}")
|
39
|
+
|
40
|
+
|
41
|
+
def offsite_copies_non_negative_int(value):
|
42
|
+
"""Validate non-negative integer for offsite copies."""
|
43
|
+
try:
|
44
|
+
int_value = int(value)
|
45
|
+
if int_value < 0:
|
46
|
+
raise argparse.ArgumentTypeError(
|
47
|
+
"Number of offsite copies must be non-negative"
|
48
|
+
)
|
49
|
+
return int_value
|
50
|
+
except ValueError:
|
51
|
+
raise argparse.ArgumentTypeError(
|
52
|
+
f"Offsite copies must be an integer, got {value!r}"
|
53
|
+
)
|
54
|
+
|
55
|
+
|
56
|
+
def validate_metrics_port(value):
|
57
|
+
"""Validate metrics port range."""
|
58
|
+
try:
|
59
|
+
int_value = int(value)
|
60
|
+
if not (1024 <= int_value <= 65535):
|
61
|
+
raise argparse.ArgumentTypeError(
|
62
|
+
"Metrics port must be between 1024 and 65535"
|
63
|
+
)
|
64
|
+
return int_value
|
65
|
+
except ValueError:
|
66
|
+
raise argparse.ArgumentTypeError(
|
67
|
+
f"Metrics port must be an integer, got {value!r}"
|
68
|
+
)
|
69
|
+
|
70
|
+
|
71
|
+
def polling_interval_positive_float(value):
|
72
|
+
"""Validate polling interval."""
|
73
|
+
try:
|
74
|
+
float_value = float(value)
|
75
|
+
if float_value <= 0:
|
76
|
+
raise argparse.ArgumentTypeError("Polling interval must be positive")
|
77
|
+
return float_value
|
78
|
+
except ValueError:
|
79
|
+
raise argparse.ArgumentTypeError(
|
80
|
+
f"Polling interval must be a number, got {value!r}"
|
81
|
+
)
|
82
|
+
|
83
|
+
|
84
|
+
def check_interval_positive_float(value):
|
85
|
+
"""Validate check interval."""
|
86
|
+
try:
|
87
|
+
float_value = float(value)
|
88
|
+
if float_value <= 0:
|
89
|
+
raise argparse.ArgumentTypeError("Check interval must be positive")
|
90
|
+
return float_value
|
91
|
+
except ValueError:
|
92
|
+
raise argparse.ArgumentTypeError(
|
93
|
+
f"Check interval must be a number, got {value!r}"
|
94
|
+
)
|
95
|
+
|
96
|
+
|
97
|
+
def validate_data_path(value):
|
98
|
+
"""Validate data directory path."""
|
99
|
+
data_path = Path(value)
|
100
|
+
if not data_path.exists():
|
101
|
+
raise argparse.ArgumentTypeError(f"Data path does not exist: {value}")
|
102
|
+
if not data_path.is_dir():
|
103
|
+
raise argparse.ArgumentTypeError(f"Data path is not a directory: {value}")
|
104
|
+
return str(data_path)
|
105
|
+
|
106
|
+
|
107
|
+
def _create_parser():
|
108
|
+
"""Create the main argument parser."""
|
109
|
+
parser = argparse.ArgumentParser(
|
110
|
+
prog="acada-ingest",
|
111
|
+
description="ACADA Ingestion Tool - Process ACADA data products into BDMS using Rucio",
|
112
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
113
|
+
)
|
114
|
+
|
115
|
+
parser.add_argument(
|
116
|
+
"--config",
|
117
|
+
"-c",
|
118
|
+
type=argparse.FileType("r"),
|
119
|
+
help="Path to configuration file (can be overridden by command line arguments), yaml",
|
120
|
+
)
|
121
|
+
|
122
|
+
# Core ingestion
|
123
|
+
core_group = parser.add_argument_group("Core Ingestion")
|
124
|
+
core_group.add_argument(
|
125
|
+
"--data-path",
|
126
|
+
"-d",
|
127
|
+
type=validate_data_path,
|
128
|
+
help="Path to ACADA on-site data directory to monitor for trigger files",
|
129
|
+
)
|
130
|
+
core_group.add_argument(
|
131
|
+
"--workers",
|
132
|
+
"-w",
|
133
|
+
type=num_workers_positive_int,
|
134
|
+
default=os.cpu_count(),
|
135
|
+
help="Number of worker processes for parallel ingestion",
|
136
|
+
)
|
137
|
+
core_group.add_argument(
|
138
|
+
"--offsite-copies",
|
139
|
+
type=offsite_copies_non_negative_int,
|
140
|
+
default=2,
|
141
|
+
help="Number of offsite replica copies to create",
|
142
|
+
)
|
143
|
+
|
144
|
+
# Rucio
|
145
|
+
rucio_group = parser.add_argument_group("Rucio Configuration")
|
146
|
+
rucio_group.add_argument(
|
147
|
+
"--rse",
|
148
|
+
type=str,
|
149
|
+
help="Rucio Storage Element (RSE) name for onsite storage",
|
150
|
+
)
|
151
|
+
rucio_group.add_argument("--scope", type=str, default="acada", help="Rucio scope")
|
152
|
+
rucio_group.add_argument(
|
153
|
+
"--vo", type=str, default="ctao", help="Virtual organization name prefix"
|
154
|
+
)
|
155
|
+
|
156
|
+
# Monitoring
|
157
|
+
monitoring_group = parser.add_argument_group("Monitoring")
|
158
|
+
monitoring_group.add_argument(
|
159
|
+
"--metrics-port",
|
160
|
+
type=validate_metrics_port,
|
161
|
+
default=8000,
|
162
|
+
help="Port for Prometheus metrics server",
|
163
|
+
)
|
164
|
+
monitoring_group.add_argument(
|
165
|
+
"--disable-metrics",
|
166
|
+
action="store_true",
|
167
|
+
help="Disable Prometheus metrics server",
|
168
|
+
)
|
169
|
+
|
170
|
+
# Logging
|
171
|
+
logging_group = parser.add_argument_group("Logging")
|
172
|
+
logging_group.add_argument(
|
173
|
+
"--log-level",
|
174
|
+
type=validate_log_level,
|
175
|
+
default="INFO",
|
176
|
+
help="Logging level (case insensitive): DEBUG, INFO, WARNING, ERROR, CRITICAL",
|
177
|
+
)
|
178
|
+
logging_group.add_argument(
|
179
|
+
"--log-file",
|
180
|
+
type=str,
|
181
|
+
help="Path to log file (if not specified, logs to stdout)",
|
182
|
+
)
|
183
|
+
|
184
|
+
# Daemon
|
185
|
+
daemon_group = parser.add_argument_group("Daemon Options")
|
186
|
+
daemon_group.add_argument(
|
187
|
+
"--polling-interval",
|
188
|
+
type=polling_interval_positive_float,
|
189
|
+
default=1.0,
|
190
|
+
help="Interval in seconds for the polling observer to check for new trigger files",
|
191
|
+
)
|
192
|
+
daemon_group.add_argument(
|
193
|
+
"--check-interval",
|
194
|
+
type=check_interval_positive_float,
|
195
|
+
default=1.0,
|
196
|
+
help="Interval in seconds for result processing checks in the daemon main loop",
|
197
|
+
)
|
198
|
+
daemon_group.add_argument(
|
199
|
+
"--lock-file",
|
200
|
+
type=str,
|
201
|
+
help="Path to daemon lock file, prevents multiple instances",
|
202
|
+
)
|
203
|
+
daemon_group.add_argument(
|
204
|
+
"--dry-run",
|
205
|
+
action="store_true",
|
206
|
+
help="Validate configuration without starting daemon",
|
207
|
+
)
|
208
|
+
|
209
|
+
return parser
|
210
|
+
|
211
|
+
|
212
|
+
def setup_logging(log_level, log_file=None):
|
213
|
+
"""Configure structured logging for the daemon."""
|
214
|
+
# Validate and sanitize log file path
|
215
|
+
if log_file:
|
216
|
+
log_path = Path(log_file).resolve() # Resolve to absolute path
|
217
|
+
|
218
|
+
# Block ".." in file paths for security
|
219
|
+
if any(part == ".." for part in log_path.parts):
|
220
|
+
raise ValueError("Log file path contains directory traversal")
|
221
|
+
|
222
|
+
# Prevent writing to system-critical directories
|
223
|
+
forbidden_dirs = {"/etc", "/boot", "/sys", "/proc", "/dev"}
|
224
|
+
if any(str(log_path).startswith(forbidden) for forbidden in forbidden_dirs):
|
225
|
+
raise ValueError("Log file path not allowed in system directories")
|
226
|
+
|
227
|
+
# Ensure we can write safely
|
228
|
+
try:
|
229
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
230
|
+
if log_path.exists() and not os.access(log_path, os.W_OK):
|
231
|
+
raise PermissionError(f"Cannot write to log file: {log_file}")
|
232
|
+
except OSError as e:
|
233
|
+
raise ValueError(f"Cannot use log file '{log_file}': {e}") from e
|
234
|
+
|
235
|
+
log_format = (
|
236
|
+
"%(asctime)s - %(name)s - %(levelname)s - [PID:%(process)d] - %(message)s"
|
237
|
+
)
|
238
|
+
|
239
|
+
# Use validated log level
|
240
|
+
log_level_obj = getattr(logging, log_level)
|
241
|
+
|
242
|
+
logging.basicConfig(level=log_level_obj, format=log_format, filename=log_file)
|
243
|
+
|
244
|
+
# Set specific log levels for different components
|
245
|
+
logging.getLogger("bdms.acada_ingestion").setLevel(log_level_obj)
|
246
|
+
logging.getLogger("acada_cli").setLevel(log_level_obj)
|
247
|
+
|
248
|
+
if log_level == "DEBUG":
|
249
|
+
# In debug mode, show more Rucio details
|
250
|
+
logging.getLogger("rucio").setLevel(logging.INFO)
|
251
|
+
else:
|
252
|
+
# Normal operation
|
253
|
+
logging.getLogger("rucio").setLevel(logging.WARNING)
|
254
|
+
|
255
|
+
# Reduce noise from external libraries
|
256
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
257
|
+
logging.getLogger("requests").setLevel(logging.WARNING)
|
258
|
+
logging.getLogger("watchdog").setLevel(logging.WARNING)
|
259
|
+
|
260
|
+
|
261
|
+
def create_ingestion_client(args) -> IngestionClient:
|
262
|
+
"""Create and validate IngestionClient with provided arguments."""
|
263
|
+
try:
|
264
|
+
client = IngestionClient(
|
265
|
+
data_path=args.data_path,
|
266
|
+
rse=args.rse,
|
267
|
+
vo=args.vo,
|
268
|
+
scope=args.scope,
|
269
|
+
logger=log.getChild("IngestionClient"),
|
270
|
+
)
|
271
|
+
log.info(
|
272
|
+
"Successfully created IngestionClient for RSE '%s' with scope '%s'",
|
273
|
+
args.rse,
|
274
|
+
args.scope,
|
275
|
+
)
|
276
|
+
return client
|
277
|
+
except Exception:
|
278
|
+
log.exception("Failed to create IngestionClient")
|
279
|
+
raise
|
280
|
+
|
281
|
+
|
282
|
+
def create_ingest_daemon(client: IngestionClient, args) -> Ingest:
|
283
|
+
"""Create Ingest daemon with provided arguments."""
|
284
|
+
try:
|
285
|
+
daemon = Ingest(
|
286
|
+
client=client,
|
287
|
+
top_dir=args.data_path,
|
288
|
+
num_workers=args.workers,
|
289
|
+
lock_file_path=args.lock_file,
|
290
|
+
polling_interval=args.polling_interval,
|
291
|
+
check_interval=args.check_interval,
|
292
|
+
offsite_copies=args.offsite_copies,
|
293
|
+
)
|
294
|
+
log.info(
|
295
|
+
"Successfully created Ingest daemon for directory '%s'", args.data_path
|
296
|
+
)
|
297
|
+
return daemon
|
298
|
+
except Exception:
|
299
|
+
log.exception("Failed to create Ingest daemon")
|
300
|
+
raise
|
301
|
+
|
302
|
+
|
303
|
+
# parser defined as a module level variable
|
304
|
+
parser = _create_parser()
|
305
|
+
|
306
|
+
|
307
|
+
def parse_args_and_config(args: list) -> argparse.Namespace:
|
308
|
+
"""Parse command line arguments and configuration file. Config file acts as defaults for CLI."""
|
309
|
+
parsed_args = parser.parse_args(args)
|
310
|
+
|
311
|
+
if parsed_args.config:
|
312
|
+
yaml = YAML(typ="safe")
|
313
|
+
try:
|
314
|
+
config_dict = yaml.load(parsed_args.config)
|
315
|
+
finally:
|
316
|
+
print(f"Closing file: {parsed_args.config}")
|
317
|
+
parsed_args.config.close()
|
318
|
+
|
319
|
+
parser.set_defaults(**config_dict)
|
320
|
+
|
321
|
+
parsed_args = parser.parse_args(args)
|
322
|
+
if parsed_args.config is not None:
|
323
|
+
parsed_args.config.close()
|
324
|
+
return parsed_args
|
325
|
+
|
326
|
+
|
327
|
+
def main(args=None):
|
328
|
+
"""Run the main CLI entry point."""
|
329
|
+
args = parse_args_and_config(args)
|
330
|
+
|
331
|
+
try:
|
332
|
+
# Setup logging with error handling
|
333
|
+
try:
|
334
|
+
setup_logging(args.log_level, args.log_file)
|
335
|
+
except ValueError as e:
|
336
|
+
print(f"Logging configuration error: {e}", file=sys.stderr)
|
337
|
+
raise
|
338
|
+
|
339
|
+
log.info("Starting ACADA ingestion daemon with file system monitoring")
|
340
|
+
log.info(
|
341
|
+
"Configuration: data_path=%s, rse=%s, workers=%d",
|
342
|
+
args.data_path,
|
343
|
+
args.rse,
|
344
|
+
args.workers,
|
345
|
+
)
|
346
|
+
log.info(
|
347
|
+
"Monitoring: polling_interval=%ss, check_interval=%ss",
|
348
|
+
args.polling_interval,
|
349
|
+
args.check_interval,
|
350
|
+
)
|
351
|
+
log.info("Replication: offsite_copies=%d", args.offsite_copies)
|
352
|
+
log.info("Process ID: %d", os.getpid())
|
353
|
+
|
354
|
+
if args.dry_run:
|
355
|
+
log.warning(
|
356
|
+
"Dry Run - Validating configuration only, daemon will not start"
|
357
|
+
)
|
358
|
+
|
359
|
+
# Validate metrics setup
|
360
|
+
if not args.disable_metrics:
|
361
|
+
start_http_server(args.metrics_port)
|
362
|
+
log.info("Metrics server started on port %d", args.metrics_port)
|
363
|
+
|
364
|
+
# Validate client creation
|
365
|
+
client = create_ingestion_client(args)
|
366
|
+
|
367
|
+
log.info("Configuration validation successful - dry run completed")
|
368
|
+
return
|
369
|
+
|
370
|
+
# Normal execution: Start metrics server (if enabled)
|
371
|
+
if not args.disable_metrics:
|
372
|
+
start_http_server(args.metrics_port)
|
373
|
+
log.info("Metrics server started on port %d", args.metrics_port)
|
374
|
+
|
375
|
+
# Create IngestionClient
|
376
|
+
client = create_ingestion_client(args)
|
377
|
+
|
378
|
+
# Create and run Ingest daemon
|
379
|
+
daemon = create_ingest_daemon(client, args)
|
380
|
+
|
381
|
+
log.info("Starting ACADA ingestion daemon with file system monitoring...")
|
382
|
+
log.info(
|
383
|
+
"The daemon will monitor for .trigger files and process corresponding data files"
|
384
|
+
)
|
385
|
+
log.info("Use Ctrl+C to stop the daemon gracefully")
|
386
|
+
|
387
|
+
# Run the daemon (this blocks until shutdown)
|
388
|
+
daemon.run()
|
389
|
+
|
390
|
+
except KeyboardInterrupt:
|
391
|
+
log.info("Interrupted by user")
|
392
|
+
sys.exit(130) # Standard exit code for SIGINT
|
393
|
+
|
394
|
+
except Exception:
|
395
|
+
log.exception("Fatal error occurred")
|
396
|
+
sys.exit(1)
|
397
|
+
|
398
|
+
|
399
|
+
if __name__ == "__main__":
|
400
|
+
main()
|