Warning, file /swf-monitor/src/monitor_app/viewdir/panda_node_guard.py was not indexed
or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).
0001 """The Node guard page (site-canary docs/NODE_GUARD.md): the node record
0002 (black holes, half open, pinned) from the canary store with a person's
0003 clear and pin; every node the guard's last cycle judged, with its
0004 state, reason and evidence; the queues' calibration and any storm; the
0005 switches. Reads the store and the cached product the cycle stored;
0006 computes nothing and reaches neither PanDA nor Rucio.
0007 """
0008 import logging
0009 from datetime import datetime, timezone
0010
0011 from django.contrib import messages
0012 from django.shortcuts import redirect, render
0013 from django.urls import reverse
0014
0015 from monitor_app.panda.node_guard import DEFAULTS, STATE_KEY
0016
0017 logger = logging.getLogger(__name__)
0018
0019 STATE_LABELS = {'would_exclude': 'would exclude', 'excluded': 'excluded', 'clear': 'clear'}
0020 RECORD_LABELS = {'black_hole': 'black hole', 'half_open': 'half open', 'pinned': 'pinned',
0021 'clear': 'clear'}
0022 REASON_LABELS = {
0023 'black_hole': 'black hole',
0024 'fixed_time': 'fixed-time kill',
0025 'below_floor': 'under the job floor',
0026 'failed_fraction': 'failed share under the threshold',
0027 'not_fast': 'failures not fast',
0028 'no_calibration': 'no calibration for the queue',
0029 'tasks_fail_everywhere': 'its tasks finish nowhere on the queue',
0030 'no_other_node': 'no other node on the queue',
0031 'queue_event': "the queue's event",
0032 }
0033
0034
0035 def _evidence_view(e):
0036 """The evidence dict as the templates read it."""
0037 e = e or {}
0038 return {
0039 'jobs': e.get('jobs'), 'failed': e.get('failed'), 'finished': e.get('finished'),
0040 'failed_pct': round(100 * (e.get('failed_fraction') or 0)),
0041 'fast_failed': e.get('fast_failed'), 'failed_no_duration': e.get('failed_no_duration'),
0042 'fast_pct': round(100 * (e.get('fast_fraction') or 0)),
0043 'tasks_failed': e.get('tasks_failed') or [],
0044 'tasks_finished_elsewhere': e.get('tasks_finished_elsewhere') or [],
0045 'other_nodes_finishing': e.get('other_nodes_finishing'),
0046 'first_end': e.get('first_end'), 'last_end': e.get('last_end'),
0047 'site': e.get('site') or '', 'sites': e.get('sites') or [],
0048 'durations': {k: (round(v / 60.0, 1) if v is not None else None)
0049 for k, v in (e.get('failed_duration_s') or {}).items()},
0050 'error_codes': e.get('error_codes') or [],
0051 'sample_jobs': e.get('sample_jobs') or [],
0052 'duration_spread': e.get('duration_spread'), 'fixed_time': bool(e.get('fixed_time')),
0053 'elsewhere_hosts': sorted((e.get('tasks_finished_elsewhere_hosts') or {}).items()),
0054 'median_finished_min': (round(e['median_finished_s'] / 60.0)
0055 if e.get('median_finished_s') else None),
0056 'fast_under_min': (round(e['fast_under_s'] / 60.0) if e.get('fast_under_s') else None),
0057 }
0058
0059
0060 def _record_rows():
0061 """The node record as the page shows it: every node not clear."""
0062 from canary.store.models import NodeState
0063 rows = []
0064 for node in (NodeState.objects.exclude(status='clear')
0065 .select_related('queue').order_by('queue__name', 'host')):
0066 lv = node.last_verdict or {}
0067 row = {'queue': node.queue.name, 'host': node.host, 'site': node.site,
0068 'status': node.status, 'status_label': RECORD_LABELS.get(node.status, node.status),
0069 'reason': node.reason, 'opened_at': node.opened_at, 'expires_at': node.expires_at,
0070 'reopened': node.reopened, 'trips': node.trips,
0071 'last_seen_at': node.last_seen_at,
0072 'last_verdict_at': lv.get('at'), 'last_tripped': lv.get('tripped'),
0073 'last_finished': lv.get('finished'), 'last_fast_failed': lv.get('fast_failed'),
0074 'changes': node.changes.count()}
0075 row.update(_evidence_view(node.evidence))
0076 rows.append(row)
0077 return rows
0078
0079
0080 def panda_node_guard(request):
0081 from monitor_app.middleware import is_tunnel_request
0082 from monitor_app.authority import may_act
0083 from monitor_app.models import CachedProduct, SysConfig
0084
0085 config = SysConfig.get_config()
0086 row = CachedProduct.objects.filter(key=STATE_KEY).first()
0087 state = (row.value if row else None) or {}
0088 settings = {k: config.get(f'node_guard.{k}', v) for k, v in DEFAULTS.items()}
0089 nodes = []
0090 for n in state.get('nodes') or []:
0091 row = {
0092 'queue': n.get('queue'), 'host': n.get('host'),
0093 'state': n.get('state') or 'clear',
0094 'state_label': STATE_LABELS.get(n.get('state'), n.get('state') or ''),
0095 'reason': n.get('reason') or '',
0096 'reason_label': REASON_LABELS.get(n.get('reason'), n.get('reason') or ''),
0097 'log_id': n.get('log_id'),
0098 }
0099 row.update(_evidence_view(n.get('evidence')))
0100 nodes.append(row)
0101 try:
0102 record = _record_rows()
0103 except Exception as exc:
0104 logger.exception('node guard: the record could not be read')
0105 record = []
0106 state = dict(state, errors=list(state.get('errors') or [])
0107 + [f'the node record could not be read: {type(exc).__name__}: {exc}'])
0108 operable = (request.user.is_authenticated and may_act(request.user.username)
0109 and not is_tunnel_request(request))
0110
0111 nodes.sort(key=lambda n: (n['state'] == 'clear', n['queue'] or '', n['host'] or ''))
0112 queues = []
0113 for name, q in sorted((state.get('queues') or {}).items()):
0114 med = q.get('median_finished_s')
0115 fast = q.get('fast_under_s')
0116 queues.append({
0117 'queue': name, 'jobs': q.get('jobs'), 'hosts': q.get('hosts'),
0118 'judged': q.get('judged'), 'tripped': q.get('tripped'),
0119 'queue_event': bool(q.get('queue_event')), 'storm_hosts': q.get('storm_hosts'),
0120 'storm_nodes': q.get('storm_nodes') or [],
0121
0122 'bursts': [{'at': datetime.fromtimestamp(b['from_s'], timezone.utc), 'hosts': b['hosts']}
0123 for b in (q.get('bursts') or []) if b.get('from_s') is not None],
0124 'median_finished_min': round(med / 60.0) if med else None,
0125 'fast_under_min': round(fast / 60.0) if fast else None,
0126 'not_nodes': sorted((q.get('not_nodes') or {}).items()),
0127 })
0128 context = {
0129 'active_nav': {'panda_node_guard': True, 'sites': True},
0130 'state': state, 'never_run': not state,
0131 'cycle_at': state.get('cycle_at'),
0132 'mode': state.get('mode') or settings['mode'],
0133 'enabled': bool(state.get('enabled', settings['enabled'])),
0134 'settings': settings, 'errors': state.get('errors') or [],
0135 'nodes': nodes, 'tripped': [n for n in nodes if n['state'] != 'clear'],
0136 'queues': queues, 'record': record, 'operable': operable,
0137 }
0138 return render(request, 'monitor_app/panda_node_guard.html', context)
0139
0140
0141 def panda_node_guard_json(request):
0142 """The node guard as JSON for scripts (prod-notify): the last cycle's
0143 tripped nodes (would_exclude in shadow mode, excluded live) and the
0144 node record's rows that are not clear. The same reads as the page,
0145 nothing computed."""
0146 from django.core.serializers.json import DjangoJSONEncoder
0147 from django.http import JsonResponse
0148 from monitor_app.models import CachedProduct, SysConfig
0149
0150 config = SysConfig.get_config()
0151 row = CachedProduct.objects.filter(key=STATE_KEY).first()
0152 state = (row.value if row else None) or {}
0153 tripped = [
0154 {'queue': n.get('queue'), 'host': n.get('host'), 'state': n.get('state'),
0155 'reason': n.get('reason') or '', 'site': (n.get('evidence') or {}).get('site') or '',
0156 'log_id': n.get('log_id')}
0157 for n in state.get('nodes') or [] if (n.get('state') or 'clear') != 'clear']
0158 errors = list(state.get('errors') or [])
0159 try:
0160 record = [{k: r.get(k) for k in ('queue', 'host', 'site', 'status', 'reason',
0161 'opened_at', 'expires_at', 'trips', 'last_verdict_at')}
0162 for r in _record_rows()]
0163 except Exception as exc:
0164 logger.exception('node guard json: the record could not be read')
0165 record = []
0166 errors.append(f'the node record could not be read: {type(exc).__name__}: {exc}')
0167 return JsonResponse({
0168 'cycle_at': state.get('cycle_at'),
0169 'mode': state.get('mode') or config.get('node_guard.mode', DEFAULTS['mode']),
0170 'enabled': bool(state.get('enabled', config.get('node_guard.enabled', DEFAULTS['enabled']))),
0171 'errors': errors, 'tripped': tripped, 'record': record,
0172 }, encoder=DjangoJSONEncoder)
0173
0174
0175 def panda_node_guard_set(request):
0176 """A person's decision on one node of the record: clear, pin, unpin
0177 (back to clear, the guard may trip it again) or open a black hole by
0178 hand. POST-only; a write, so login-gated, authority-gated, and
0179 refused on the tunnel face like every rare operator action."""
0180 from monitor_app.middleware import is_tunnel_request
0181 from monitor_app.authority import may_act
0182 from monitor_app.epicprod_logging import log_epicprod_action
0183 from canary.store import nodes as node_record
0184
0185 url = reverse('monitor_app:panda_node_guard')
0186 if request.method != 'POST':
0187 messages.warning(request, 'Node guard actions only respond to POST submissions.')
0188 return redirect(url)
0189 queue = (request.POST.get('queue') or '').strip()
0190 host = (request.POST.get('host') or '').strip()
0191 action = (request.POST.get('action') or '').strip()
0192 if is_tunnel_request(request):
0193 logger.warning('node guard %s refused on the tunnel face (%s/%s)', action, queue, host)
0194 return redirect(url)
0195 if not request.user.is_authenticated:
0196 messages.error(request, 'Login required to act on a node.')
0197 return redirect(url)
0198 if not may_act(request.user.username):
0199 messages.error(request, 'Your account has no authority to act on the system.')
0200 return redirect(url)
0201 status = {'clear': 'clear', 'unpin': 'clear', 'pin': 'pinned', 'open': 'black_hole'}.get(action)
0202 if not (queue and host and status):
0203 messages.error(request, 'A node (queue and host) and one of clear, pin, unpin, open are needed.')
0204 return redirect(url)
0205 note = (request.POST.get('note') or '').strip()
0206 try:
0207 node = node_record.set_status(queue, host, status, username=request.user.username,
0208 reason=f'{action}: {note}' if note else action)
0209 except Exception as exc:
0210 logger.exception('node guard %s failed (%s/%s)', action, queue, host)
0211 messages.error(request, f'The action failed: {type(exc).__name__}: {exc}')
0212 return redirect(url)
0213 log_epicprod_action(
0214 'web', 'node_guard_set', subject_type='panda_node', subject_key=f'{queue}/{host}',
0215 username=request.user.username, outcome=node.status, sublevel='normal', live_default=True,
0216 message=f'node guard {queue} {host}: {action} by {request.user.username}'
0217 + (f' ({note})' if note else '') + f'; status {node.status}',
0218 action_taken=action, note=note, status=node.status)
0219 messages.success(request, f'{host} on {queue}: {RECORD_LABELS.get(node.status, node.status)}.')
0220 return redirect(url)