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.
bdms/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.2.0rc1'
21
- __version_tuple__ = version_tuple = (0, 2, 0, 'rc1')
20
+ __version__ = version = '0.3.0'
21
+ __version_tuple__ = version_tuple = (0, 3, 0)
@@ -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()