Back to home page

EIC code displayed by LXR

 
 

    


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

0001 """Ingest for host reporters.
0002 
0003 A reporter runs on a host whose state only that host can see and pushes
0004 a record here on its own schedule (docs/OSG_SUBMIT_REPORTER.md,
0005 docs/PANDA_SERVER_REPORTER.md). Push rather than poll is not a
0006 preference: the submit host refuses ssh except through the facility
0007 gateway with agent forwarding, so nothing here can reach it.
0008 
0009 The record lands in the cached-product store under a key naming the
0010 host. That store already holds a keyed JSON value with the time it was
0011 built, which is exactly a reporter's record and its freshness, so a
0012 reporter needs no table of its own and no migration. A reader gets the
0013 record and its age together, and an absent or stale record is a fact
0014 the reader can state rather than an empty page.
0015 """
0016 import logging
0017 
0018 from django.utils import timezone
0019 from rest_framework import status
0020 from rest_framework.authentication import (SessionAuthentication,
0021                                            TokenAuthentication)
0022 from rest_framework.decorators import (api_view, authentication_classes,
0023                                        permission_classes)
0024 from rest_framework.permissions import IsAuthenticated
0025 from rest_framework.response import Response
0026 
0027 logger = logging.getLogger(__name__)
0028 
0029 # The harvester records (docs/HARVESTER_REPORTER.md), one per harvester
0030 # instance; every reader of harvester records takes this list.
0031 # `osgsub01-harvester` is the harvester reporter on osgsub01, whose own key
0032 # belongs to the OSG submit reporter. `perlmutter-harvester` is the
0033 # Perlmutter harvester's record, built on this host from what that
0034 # harvester reports to the PanDA server.
0035 HARVESTER_HOSTS = ('pandaharvester01', 'pandaharvester02', 'osgsub01-harvester',
0036                    'perlmutter-harvester')
0037 
0038 # Hosts that may report. A reporter writes one key, so an unknown host
0039 # is refused rather than allowed to create keys in the shared store.
0040 # `bnl-scdf` is a pool rather than a host: the pool reporter runs here
0041 # and reports what its collector answers (docs/POOL_REPORTER.md), which
0042 # is the same kind of record from the same kind of reporter.
0043 REPORTING_HOSTS = {'osgsub01', 'pandaserver01', 'bnl-scdf', *HARVESTER_HOSTS}
0044 
0045 # A record is a summary, not a payload. Anything larger is a reporter
0046 # defect and is refused with its size named, rather than stored.
0047 MAX_RECORD_BYTES = 512 * 1024
0048 
0049 
0050 def product_key(host):
0051     """The cached-product key holding one host's latest record."""
0052     return 'host_report:{}'.format(host)
0053 
0054 
0055 def latest(host):
0056     """One host's record with its age, or None when it has never reported.
0057 
0058     Returns ``{host, record, reported_at, age_seconds}``. Callers render
0059     the age: a reporter that has stopped is otherwise indistinguishable
0060     from one that is merely quiet.
0061     """
0062     from .models import CachedProduct
0063     row = CachedProduct.objects.filter(key=product_key(host)).first()
0064     if row is None or not row.value:
0065         return None
0066     age = None
0067     if row.built_at:
0068         age = (timezone.now() - row.built_at).total_seconds()
0069     return {'host': host, 'record': row.value,
0070             'reported_at': row.built_at, 'age_seconds': age}
0071 
0072 
0073 class RecordRefused(ValueError):
0074     """A record the store does not take; ``status`` is the HTTP answer."""
0075 
0076     def __init__(self, message, status_code):
0077         super().__init__(message)
0078         self.status_code = status_code
0079 
0080 
0081 def store(host, record, by=''):
0082     """Store ``record`` whole as ``host``'s latest; the one write path, for
0083     the ingest and for a reporter running on this host. Returns
0084     ``(reported_at, bytes)``; raises RecordRefused."""
0085     from .models import CachedProduct
0086 
0087     if host not in REPORTING_HOSTS:
0088         raise RecordRefused("unknown reporting host '{}'".format(host),
0089                             status.HTTP_404_NOT_FOUND)
0090     if not isinstance(record, dict) or not record:
0091         raise RecordRefused('the record must be a non-empty object',
0092                             status.HTTP_400_BAD_REQUEST)
0093     size = len(str(record))
0094     if size > MAX_RECORD_BYTES:
0095         raise RecordRefused('record of {} bytes exceeds the {} byte limit'.format(
0096             size, MAX_RECORD_BYTES), status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
0097     now = timezone.now()
0098     CachedProduct.objects.update_or_create(
0099         key=product_key(host),
0100         defaults={'value': record, 'built_at': now, 'building_since': None},
0101     )
0102     logger.info('host report stored for %s (%d bytes) from %s', host, size, by or '?')
0103     return now, size
0104 
0105 
0106 @api_view(['POST', 'GET'])
0107 # Token first and explicitly: the project declares no default
0108 # authenticators, so DRF would fall back to session and basic only and
0109 # never look at a reporter's token.
0110 @authentication_classes([TokenAuthentication, SessionAuthentication])
0111 @permission_classes([IsAuthenticated])
0112 def host_report(request, host):
0113     """Store a host reporter's record, or return the one stored.
0114 
0115     POST body is the record itself, as the reporter composed it. It is
0116     stored whole and unedited: a reporter delivers its own failures as
0117     fields, and rewriting the record here would lose them.
0118     """
0119     if host not in REPORTING_HOSTS:
0120         return Response(
0121             {'error': "unknown reporting host '{}'".format(host)},
0122             status=status.HTTP_404_NOT_FOUND)
0123 
0124     if request.method == 'GET':
0125         found = latest(host)
0126         if found is None:
0127             return Response({'host': host, 'record': None,
0128                              'detail': 'this host has never reported'},
0129                             status=status.HTTP_404_NOT_FOUND)
0130         return Response(found)
0131 
0132     record = request.data
0133     try:
0134         now, size = store(host, record, getattr(request.user, 'username', ''))
0135     except RecordRefused as exc:
0136         return Response({'error': str(exc)}, status=exc.status_code)
0137     if host == 'osgsub01':
0138         # a pool's throttle on our pilots is an incident (osg_throttle.py);
0139         # reading it never fails the ingest
0140         try:
0141             from .osg_throttle import observe
0142             observe(record, getattr(request.user, 'username', ''))
0143         except Exception:
0144             logger.exception('OSG throttle reading failed for the %s record', host)
0145     return Response({'host': host, 'stored': True, 'reported_at': now,
0146                      'bytes': size},
0147                     status=status.HTTP_201_CREATED)