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
0030
0031
0032
0033
0034
0035 HARVESTER_HOSTS = ('pandaharvester01', 'pandaharvester02', 'osgsub01-harvester',
0036 'perlmutter-harvester')
0037
0038
0039
0040
0041
0042
0043 REPORTING_HOSTS = {'osgsub01', 'pandaserver01', 'bnl-scdf', *HARVESTER_HOSTS}
0044
0045
0046
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
0108
0109
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
0139
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)