File indexing completed on 2026-11-09 10:47:22
0001
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
0050 django.setup()
0051
0052
0053
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
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
0144
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:
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
0165 try:
0166 held = content_validation._dataset_files(dataset)
0167 except Exception as e:
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:
0191
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
0200 try:
0201 _cv.store(task, content_validation.reconcile_all(task))
0202 except Exception as e:
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())