File indexing completed on 2026-10-20 10:15:22
0001 """The storage door canary's cycle (site-canary docs/STORAGE_DOORS.md):
0002 the doors production writes through are used — a small write, a stat, a
0003 delete, and the date on the certificate they serve — and what they
0004 answer becomes a record production operations can act on.
0005
0006 The cycle runs as the production-operations agent's
0007 ``storage_door_cycle`` doer (``scripts/storage-door-cycle.py``), by cron
0008 enqueue every fifteen minutes; a door is probed when its last probe is
0009 older than ``storage_doors.interval_h``, which starts at one hour, so the
0010 cadence is a setting rather than a cron line. The doors come from the
0011 catalog, not from a list here: each RSE's preferred xrootd write
0012 protocol (``canary.doors.write_door``) is the door its writes go
0013 through, so an RSE added to the catalog is probed the day it exists.
0014
0015 Nothing acts on the record automatically. The canary reports and
0016 production operations act (STORAGE_DOORS.md, Nothing acts on it
0017 automatically): a door that changes verdict raises its own action, at
0018 alarm severity when it goes down, and the cycle leaves the cached
0019 product ``storage_door_state`` for the page and the endpoint. The
0020 payload is not tied to this record; it reads its own write door's
0021 certificate itself.
0022
0023 Settings live in SysConfig under ``storage_doors.*``, seeded at their
0024 defaults on first read so every knob is visible on the System page.
0025 """
0026 import logging
0027 import os
0028 from datetime import timedelta, timezone as dt_timezone
0029
0030 from django.utils import timezone
0031
0032 logger = logging.getLogger(__name__)
0033
0034 STATE_KEY = 'storage_door_state'
0035 STATE_TTL_S = 24 * 3600
0036 MODES = ('shadow', 'live')
0037 DEFAULTS = {
0038 'enabled': False,
0039 'mode': 'shadow',
0040 'interval_h': 1,
0041 'validity_h': 3,
0042 'skip_rses': [],
0043 'probe_prefix': '/canary',
0044 'probe_name': 'storage-door-probe',
0045 'timeout_s': 30,
0046 'max_per_cycle': 4,
0047 'warn_days': 7,
0048 'catalog_url': 'https://rucio-server.jlab.org:443',
0049 'account': 'eicprod',
0050 }
0051
0052
0053
0054
0055
0056 REGISTRATION_ERRORS = ('pilot 1305', 'exe 78', 'trans 78')
0057
0058
0059 def down_windows(hours=24, now=None):
0060 """The spans each door was recorded down, newest verdict last, from
0061 the cycle's own verdict changes on the action stream:
0062 ``[{'rse', 'door', 'start', 'end'}]`` with ``end`` None while a door
0063 is still down. A door that has never changed verdict has no span,
0064 which is the honest answer before the canary has watched it.
0065 """
0066 from django.utils.dateparse import parse_datetime
0067 from monitor_app.models import AppLog
0068
0069 now = now or timezone.now()
0070 since = now - timedelta(hours=hours)
0071 opened, spans = {}, []
0072 rows = (AppLog.objects
0073 .filter(app_name='epicprod', timestamp__gte=since,
0074 extra_data__action='storage_door_verdict')
0075 .order_by('timestamp')
0076 .values_list('timestamp', 'extra_data'))
0077
0078
0079
0080
0081 lead = timedelta(hours=settings()['interval_h'])
0082 for stamp, extra in rows:
0083 extra = extra or {}
0084 rse = str(extra.get('subject_key') or '')
0085 if not rse:
0086 continue
0087 verdict = str(extra.get('verdict') or '')
0088 if verdict == 'down' and rse not in opened:
0089 opened[rse] = {'rse': rse, 'door': str(extra.get('door') or ''),
0090 'start': stamp - lead, 'end': None}
0091 elif verdict != 'down' and rse in opened:
0092 span = opened.pop(rse)
0093 span['end'] = stamp
0094 spans.append(span)
0095 spans.extend(opened.values())
0096 return spans
0097
0098
0099 def set_aside_door(rows, spans, parse=None):
0100 """The node guard's rows with the registration failures that ended
0101 while their write door was down set aside: a dead door is not a
0102 node's fault (site-canary docs/NODE_GUARD.md, The detector).
0103 Returns (kept, set_aside_by_queue). Pure over ``spans``.
0104
0105 Only a failure of the registration class is set aside, and only
0106 inside a span: a node that kills jobs for its own reasons during an
0107 outage still reads as what it is.
0108 """
0109 from django.utils.dateparse import parse_datetime
0110 parse = parse or parse_datetime
0111 by_queue = {}
0112 if not spans:
0113 return list(rows), by_queue
0114 kept = []
0115 for row in rows:
0116 error = str(row.get('error') or '')
0117 when = parse(str(row.get('endtime') or '')) if row.get('endtime') else None
0118 if when is not None and when.tzinfo is None:
0119
0120
0121 when = when.replace(tzinfo=dt_timezone.utc)
0122 if (row.get('jobstatus') == 'failed' and error in REGISTRATION_ERRORS
0123 and when is not None
0124 and any(s['start'] <= when and (s['end'] is None or when <= s['end'])
0125 for s in spans)):
0126 queue = str(row.get('queue') or '')
0127 by_queue[queue] = by_queue.get(queue, 0) + 1
0128 continue
0129 kept.append(row)
0130 return kept, by_queue
0131
0132
0133 def _safe(label, fn, fallback):
0134 try:
0135 return fn()
0136 except Exception as exc:
0137 logger.exception('storage doors: %s failed', label)
0138 return fallback(exc) if callable(fallback) else fallback
0139
0140
0141 def setting(key, default):
0142 from monitor_app.models import SysConfig
0143 return SysConfig.get_setting(key, default)
0144
0145
0146 def settings():
0147 out = {key: setting(f'storage_doors.{key}', default)
0148 for key, default in DEFAULTS.items()}
0149 skip = out['skip_rses']
0150 if not (isinstance(skip, (list, tuple)) and all(isinstance(r, str) for r in skip)):
0151 logger.error('storage_doors.skip_rses is not a list of RSE names: %r; '
0152 'probing every RSE', skip)
0153 out['skip_rses'] = []
0154 out['interval_h'] = max(0.25, float(out['interval_h'] or 1))
0155 out['validity_h'] = max(2 * out['interval_h'], float(out['validity_h'] or 3))
0156 out['timeout_s'] = max(5, int(out['timeout_s'] or 30))
0157 out['max_per_cycle'] = max(1, int(out['max_per_cycle'] or 4))
0158 if out['mode'] not in MODES:
0159 logger.error('storage_doors.mode is not one of %s: %r; running shadow',
0160 MODES, out['mode'])
0161 out['mode'] = 'shadow'
0162 return out
0163
0164
0165 def catalog_client(cfg):
0166 """The catalog, read as the production account through the proxy the
0167 ops agent holds. Raises when there is no proxy: a cycle that cannot
0168 read the catalog has no doors to probe and says so."""
0169 from rucio.client import Client
0170 proxy = os.environ.get('EVGEN_X509_PROXY', '')
0171 if not proxy or not os.path.exists(proxy):
0172 raise RuntimeError('no proxy (EVGEN_X509_PROXY) on this host; '
0173 'the catalog was not read')
0174 base = str(cfg['catalog_url'])
0175 return Client(rucio_host=base, auth_host=base, account=str(cfg['account']),
0176 auth_type='x509_proxy', creds={'client_proxy': proxy},
0177 timeout=30)
0178
0179
0180 def catalog_doors(cfg):
0181 """{RSE: the door its writes go through} from the catalog. An RSE
0182 whose write protocols are all of a scheme the probe does not speak is
0183 carried with no door and reported as not probed."""
0184 from canary import doors as canary_doors
0185 client = catalog_client(cfg)
0186 skip = {r.strip() for r in cfg['skip_rses'] if r.strip()}
0187 out = {}
0188 for record in client.list_rses():
0189 rse = record.get('rse') if isinstance(record, dict) else str(record)
0190 if not rse or rse in skip:
0191 continue
0192 door = _safe(f'{rse} protocols',
0193 lambda rse=rse: canary_doors.write_door(client.get_protocols(rse)),
0194 None)
0195 out[rse] = door
0196 return out
0197
0198
0199 def previous_state():
0200 """The last cycle's record, or an empty one."""
0201 from monitor_app.models import CachedProduct
0202 row = CachedProduct.objects.filter(key=STATE_KEY).first()
0203 value = (row.value if row and isinstance(row.value, dict) else {}) or {}
0204 return value.get('doors') or {}
0205
0206
0207 def is_due(entry, interval_h, now):
0208 """Whether a door is due a probe: never probed, or last probed longer
0209 ago than the interval. Pure."""
0210 if not entry or not entry.get('probed_at'):
0211 return True
0212 from django.utils.dateparse import parse_datetime
0213 last = parse_datetime(str(entry['probed_at']))
0214 if last is None:
0215 return True
0216 if timezone.is_naive(last):
0217 last = timezone.make_aware(last, timezone.utc)
0218 return (now - last) >= timedelta(hours=interval_h)
0219
0220
0221 def run_cycle(*, dry_run=False, created_by='storage-doors', force=False,
0222 only=()):
0223 """One cycle: the due doors probed, the verdicts recorded, a changed
0224 verdict announced. Returns (doors, summary)."""
0225 from canary import doors as canary_doors
0226 from monitor_app.epicprod_logging import log_epicprod_action
0227
0228 t0 = timezone.now()
0229 cfg = settings()
0230 errors = []
0231
0232 def failed(label):
0233 def note(exc):
0234 errors.append(f'{label}: {type(exc).__name__}: {exc}')
0235 return None
0236 return note
0237
0238 previous = previous_state()
0239 resolved = _safe('catalog', lambda: catalog_doors(cfg), failed('catalog')) or {}
0240 if only:
0241
0242 wanted = {r.strip() for r in only if r.strip()}
0243 resolved = {rse: door for rse, door in resolved.items() if rse in wanted}
0244
0245
0246
0247
0248
0249 def overdue(item):
0250 was = previous.get(item[0]) or {}
0251 return str(was.get('probed_at') or '')
0252 ordered = sorted(resolved.items(), key=overdue)
0253
0254 readings, state, probed = [], {}, 0
0255 for rse, door in ordered:
0256 was = previous.get(rse) or {}
0257 if not door:
0258 state[rse] = {**was, 'rse': rse, 'verdict': 'unknown',
0259 'reason': 'no_xrootd_write_door',
0260 'evidence': {}, 'door': '', 'probed_at': was.get('probed_at')}
0261 continue
0262 if (not (force or cfg['enabled']) or not is_due(was, cfg['interval_h'], t0)
0263 or probed >= cfg['max_per_cycle']):
0264 state[rse] = {**was, 'rse': rse, 'door': door['door']}
0265 continue
0266 path = canary_doors.probe_path(door['prefix'], cfg['probe_prefix'],
0267 cfg['probe_name'])
0268 reading = _safe(f'{rse} probe', lambda door=door, path=path: canary_doors.probe_door(
0269 door['door'], path, timeout_s=cfg['timeout_s']), failed(f'{rse} probe'))
0270 if reading is None:
0271 state[rse] = {**was, 'rse': rse, 'door': door['door']}
0272 continue
0273 reading['rse'] = rse
0274 reading['expiry'] = _safe(
0275 f'{rse} certificate',
0276 lambda door=door: canary_doors.door_certificate(door['door'], cfg['timeout_s']),
0277 failed(f'{rse} certificate'))
0278 readings.append(reading)
0279 probed += 1
0280
0281 verdicts = canary_doors.decide_doors(
0282 readings, {'warn_days': cfg['warn_days']}, t0) if readings else {}
0283 changes = []
0284 for reading in readings:
0285 rse = reading['rse']
0286 verdict = verdicts[reading['door']]
0287 was = previous.get(rse) or {}
0288 state[rse] = {
0289 'rse': rse, 'door': reading['door'], 'path': reading.get('path'),
0290 'verdict': verdict['verdict'], 'reason': verdict['reason'],
0291 'evidence': verdict['evidence'], 'probed_at': t0.isoformat(),
0292 'since': (was.get('since') if was.get('verdict') == verdict['verdict']
0293 else t0.isoformat()),
0294 }
0295 if was.get('verdict') and was['verdict'] != verdict['verdict']:
0296 changes.append((rse, was['verdict'], verdict['verdict'], verdict))
0297 elif not was.get('verdict') and verdict['verdict'] != 'up':
0298 changes.append((rse, 'unseen', verdict['verdict'], verdict))
0299
0300 if not dry_run:
0301 for rse, before, after, verdict in changes:
0302 evidence = verdict['evidence']
0303 severity = {'down': 'alarm', 'unknown': 'warning'}.get(after, 'info')
0304 _safe(f'{rse} verdict record', lambda rse=rse, before=before, after=after,
0305 verdict=verdict, evidence=evidence, severity=severity: log_epicprod_action(
0306 'storage-doors', 'storage_door_verdict',
0307 subject_type='storage_rse', subject_key=rse, username=created_by,
0308 outcome='error' if after == 'down' else 'ok',
0309 sublevel='high' if after == 'down' else 'normal', live_default=True,
0310 level=logging.ERROR if after == 'down' else logging.INFO,
0311 message=(f'storage door {rse} {before} -> {after} '
0312 f'({verdict["reason"]})'
0313 + (f'; certificate {evidence["certificate"]}, '
0314 f'{evidence["certificate_days_left"]} days'
0315 if evidence.get('certificate') else '')
0316 + (f'; the door said: {evidence["said"]}'
0317 if evidence.get('said') else '')),
0318 severity=severity, verdict=after, was=before,
0319 reason=verdict['reason'], door=state[rse]['door'],
0320 **{k: v for k, v in evidence.items() if k != 'said'}),
0321 failed(f'{rse} verdict record'))
0322
0323 down = sorted(r for r, s in state.items() if s.get('verdict') == 'down')
0324 expiring = sorted(r for r, s in state.items()
0325 if (s.get('evidence') or {}).get('certificate_expiring'))
0326 summary = {'cycle_at': t0.isoformat(), 'max_per_cycle': cfg['max_per_cycle'], 'mode': cfg['mode'], 'enabled': cfg['enabled'],
0327 'interval_h': cfg['interval_h'], 'rses': len(state), 'probed': probed,
0328 'down': down, 'expiring': expiring, 'changes': len(changes),
0329 'errors': errors,
0330 'duration_s': round((timezone.now() - t0).total_seconds(), 1)}
0331
0332 if not dry_run:
0333 payload = {'cycle_at': summary['cycle_at'], 'mode': cfg['mode'],
0334 'enabled': cfg['enabled'],
0335 'valid_until': (t0 + timedelta(hours=cfg['validity_h'])).isoformat(),
0336 'settings': {k: cfg[k] for k in DEFAULTS},
0337 'doors': state, 'down': down, 'expiring': expiring,
0338 'errors': errors, 'duration_s': summary['duration_s']}
0339 from monitor_app.cached_product import get_product
0340 _safe('state store', lambda: get_product(
0341 STATE_KEY, lambda: payload, ttl_seconds=STATE_TTL_S, refresh=True),
0342 failed('state store'))
0343 _safe('cycle record', lambda: log_epicprod_action(
0344 'storage-doors', 'storage_door_cycle', username=created_by,
0345 outcome='error' if errors else 'ok',
0346 duration_ms=int(summary['duration_s'] * 1000),
0347 sublevel='normal' if (errors or down) else 'low',
0348 live_default=bool(errors or down),
0349 level=logging.ERROR if errors else logging.INFO,
0350 message=(f'storage door cycle ({cfg["mode"]}'
0351 f'{"" if cfg["enabled"] else ", off"}): '
0352 f'{probed} of {len(state)} doors probed'
0353 + (f'; down: {", ".join(down)}' if down else '')
0354 + (f'; certificate expiring: {", ".join(expiring)}' if expiring else '')
0355 + (f'; {len(changes)} verdict change'
0356 f'{"s" if len(changes) != 1 else ""}' if changes else '')
0357 + (f'; errors: {"; ".join(errors)}' if errors else '')),
0358 **{k: v for k, v in summary.items() if k != 'errors'}),
0359 failed('cycle record'))
0360 return state, summary