Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:22

0001 #!/usr/bin/env python3
0002 """content-accept.py — accept a sample's content as one operator action.
0003 
0004 The production-ops agent's doer for content-validation acceptance
0005 (swf-epicprod docs/EPICPROD_VALIDATION.md, Content validation). The
0006 production record states which file is the delivered output of each work
0007 unit and how many events it carries; the JLab dataset states what it
0008 holds. Acceptance applies the reconciliation's corrections to one dataset:
0009 files that do not belong are detached from the dataset, the delivered
0010 output of each work unit is affirmed, and the sample's delivered event
0011 count becomes the sum of recorded counts. Acceptance never deletes: a
0012 detached file keeps its replica and expires under its own lifetime, so a
0013 mistaken acceptance costs storage and not data.
0014 
0015 One run, one dataset:
0016 
0017 1. Reconcile live, so a stale finding never drives a detach.
0018 2. Refuse the two cases the reconciliation refuses: a file whose event
0019    count was never recorded, and a work unit with more than one file in
0020    delivered status. Refusals come back with their reasons (exit 3).
0021 3. Detach the files that do not belong, through the production account's
0022    client — the identity every epicprod registration path uses.
0023 4. Verify the dataset listing no longer holds them.
0024 5. Post the acceptance to PCS, the single writer of the record, as the
0025    operator who asked; then store the fresh finding where the page reads it.
0026 
0027 Django-bootstrap standalone script — also usable by hand::
0028 
0029     cd /data/wenauseic/github/swf-monitor/src
0030     ../../swf-testbed/.venv/bin/python ../scripts/content-accept.py \
0031         --task <composed name> --dataset <dataset name> [--owner <user>] [--dry-run]
0032 
0033 The last stdout line is a JSON summary; progress goes to stderr.
0034 Exit codes: 0 ok · 2 no such task or dataset · 3 refused · 4 the record
0035 write failed · 5 proxy unusable · 6 Rucio unreachable · 7 detach not verified.
0036 """
0037 import argparse
0038 import importlib.util
0039 import json
0040 import os
0041 import sys
0042 import urllib.parse
0043 import urllib.request
0044 
0045 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0046 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0047 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0048 
0049 import django  # noqa: E402
0050 django.setup()
0051 
0052 # The credential and the client are the EVGEN registration doer's, as the
0053 # registrar's are, so every writer into the JLab catalog shares one identity.
0054 _spec = importlib.util.spec_from_file_location(
0055     'register_evgen_rucio', os.path.join(THIS_DIR, 'register-evgen-rucio.py'))
0056 _evgen = importlib.util.module_from_spec(_spec)
0057 _spec.loader.exec_module(_evgen)
0058 # The finding's store is the validation doer's, so the page reads one key.
0059 _spec_cv = importlib.util.spec_from_file_location(
0060     'content_validate', os.path.join(THIS_DIR, 'content-validate.py'))
0061 _cv = importlib.util.module_from_spec(_spec_cv)
0062 _spec_cv.loader.exec_module(_cv)
0063 
0064 RUCIO_SCOPE = _evgen.RUCIO_SCOPE
0065 EXIT_NOT_FOUND, EXIT_REFUSED, EXIT_RECORD, EXIT_PROXY, EXIT_RUCIO, EXIT_VERIFY = 2, 3, 4, 5, 6, 7
0066 DETACH_BATCH = 100
0067 
0068 
0069 def _log(msg):
0070     print(msg, file=sys.stderr, flush=True)
0071 
0072 
0073 def _record_acceptance(base, owner, token, task_name, body):
0074     """POST the acceptance to PCS as the operator; the endpoint is the one
0075     writer of the record (record-content-acceptance)."""
0076     query = urllib.parse.urlencode({'name': task_name})
0077     url = f"{base.rstrip('/')}/pcs/api/prod-tasks/record-content-acceptance/?{query}"
0078     req = urllib.request.Request(url, data=json.dumps(body).encode(), method='POST')
0079     req.add_header('Content-Type', 'application/json')
0080     if owner:
0081         req.add_header('X-Remote-User', owner)
0082     if token:
0083         req.add_header('Authorization', f'Token {token}')
0084     with urllib.request.urlopen(req, timeout=60) as r:
0085         return r.read().decode()
0086 
0087 
0088 def main():
0089     parser = argparse.ArgumentParser()
0090     parser.add_argument('--task', required=True, help='the task by composed name')
0091     parser.add_argument('--dataset', required=True,
0092                         help='the dataset name the finding named (scope optional)')
0093     parser.add_argument('--owner', default='',
0094                         help="the task's owner; sent as X-Remote-User for the "
0095                              "owner-gated record write")
0096     parser.add_argument('--requested-by', default='',
0097                         help='the operator who accepted, recorded as such')
0098     parser.add_argument('--swf-monitor-url',
0099                         default=os.environ.get('SWF_MONITOR_URL', '').rstrip('/'))
0100     parser.add_argument('--token',
0101                         default=os.environ.get('SWFMON_TOKEN')
0102                         or os.environ.get('SWF_MONITOR_TOKEN', ''))
0103     parser.add_argument('--dry-run', action='store_true',
0104                         help='reconcile and plan; detach nothing, record nothing')
0105     args = parser.parse_args()
0106 
0107     from pcs import content_validation
0108 
0109     summary = {'task': args.task, 'dataset': args.dataset, 'dry_run': bool(args.dry_run),
0110                'refusals': [], 'detached': [], 'affirmed': 0, 'delivered_events': 0}
0111 
0112     tasks = _cv.tasks_to_check(None, name=args.task)
0113     if not tasks:
0114         summary['error'] = f'no task named {args.task}'
0115         print(json.dumps(summary))
0116         return EXIT_NOT_FOUND
0117     task = tasks[0]
0118     dataset = args.dataset.split(':', 1)[-1]
0119     if dataset not in content_validation.datasets_of(task):
0120         summary['error'] = f'the record names no dataset {dataset} for this task'
0121         print(json.dumps(summary))
0122         return EXIT_NOT_FOUND
0123 
0124     finding = content_validation.reconcile(task, dataset_did=dataset)
0125     plan = content_validation.acceptance_plan(finding)
0126     summary['refusals'] = plan['refusals']
0127     summary['affirmed'] = plan['affirm']
0128     summary['delivered_events'] = plan['delivered_events']
0129     summary['checked_at'] = finding.get('checked_at', '')
0130     if not plan['acceptable']:
0131         for reason in plan['refusals']:
0132             _log(f'REFUSED: {reason}')
0133         print(json.dumps(summary))
0134         return EXIT_REFUSED
0135     _log(f"{finding['task']} {dataset}: {plan['affirm']} files affirmed, "
0136          f"{plan['delivered_events']} events, {len(plan['detach'])} to detach")
0137     if args.dry_run:
0138         summary['detached'] = plan['detach']
0139         print(json.dumps(summary))
0140         return 0
0141 
0142     if plan['detach']:
0143         # The detach is catalog work a token does alone; the proxy is only
0144         # the fallback.
0145         proxy, summary['proxy'] = _evgen.resolve_proxy_or_none()
0146         try:
0147             client = _evgen.rucio_client(proxy)
0148         except _evgen.DoerError as e:
0149             summary['error'] = e.msg if hasattr(e, 'msg') else str(e)
0150             print(json.dumps(summary))
0151             return EXIT_RUCIO
0152         for start in range(0, len(plan['detach']), DETACH_BATCH):
0153             batch = plan['detach'][start:start + DETACH_BATCH]
0154             try:
0155                 client.detach_dids(scope=RUCIO_SCOPE, name=dataset,
0156                                    dids=[{'scope': RUCIO_SCOPE, 'name': d} for d in batch])
0157             except Exception as e:                            # noqa: BLE001
0158                 summary['error'] = f'detach_dids: {e}'
0159                 _log(f'ERROR: detach failed: {e}')
0160                 print(json.dumps(summary))
0161                 return EXIT_RUCIO
0162             summary['detached'].extend(batch)
0163             _log(f'detached {len(batch)} file(s) from {dataset}')
0164         # The catalog is asked what the dataset holds now, never assumed.
0165         try:
0166             held = content_validation._dataset_files(dataset)
0167         except Exception as e:                                # noqa: BLE001
0168             summary['error'] = f'the dataset could not be re-read: {e}'
0169             print(json.dumps(summary))
0170             return EXIT_VERIFY
0171         still = [d for d in plan['detach'] if d in held]
0172         if still:
0173             summary['error'] = f'{len(still)} file(s) still attached after detach'
0174             summary['still_attached'] = still[:20]
0175             print(json.dumps(summary))
0176             return EXIT_VERIFY
0177 
0178     body = {
0179         'dataset': dataset,
0180         'delivered_events': plan['delivered_events'],
0181         'units': finding.get('units', 0),
0182         'affirmed': plan['affirm'],
0183         'detached': summary['detached'],
0184         'checked_at': finding.get('checked_at', ''),
0185         'accepted_by': args.requested_by or args.owner,
0186     }
0187     try:
0188         _record_acceptance(args.swf_monitor_url, args.owner, args.token,
0189                            finding['task'], body)
0190     except Exception as e:                                    # noqa: BLE001
0191         # The detach stands; the record does not. Loud, with what to re-record.
0192         summary['error'] = f'record-content-acceptance failed: {e}'
0193         summary['unrecorded'] = body
0194         _log(f'ERROR: acceptance not recorded: {e}')
0195         print(json.dumps(summary))
0196         return EXIT_RECORD
0197     summary['recorded'] = True
0198 
0199     # The fresh finding, so the page shows the dataset as it now stands.
0200     try:
0201         _cv.store(task, content_validation.reconcile_all(task))
0202     except Exception as e:                                    # noqa: BLE001
0203         _log(f'WARNING: fresh finding not stored: {e}')
0204 
0205     print(json.dumps(summary))
0206     return 0
0207 
0208 
0209 if __name__ == '__main__':
0210     sys.exit(main())