File indexing completed on 2026-11-09 10:47:28
0001 """Credential-free orchestration for removing datasets from a disk RSE
0002 (swf-epicprod docs/STORAGE.md, Removing datasets from an RSE).
0003
0004 Two phases, both run by the production operations agent: a preview, the
0005 live catalog's answer for every dataset matching the pattern; and, once
0006 an operator confirms by typing the RSE name, the removal of the datasets
0007 that preview found eligible, each checked again live. The web tier
0008 validates and queues; it holds no catalog credential and writes nothing.
0009
0010 No table holds the requests. The agent keeps each request's working
0011 state in a file under ``$SWF_TMP_DIR/rse-deletions/<id>.json``, the way
0012 it keeps fetched payload logs, and the page reads it; the record that
0013 stands is the action stream, where the agent logs each preview
0014 (``rse_deletion_preview``) and each removal (``rse_deletion``, high
0015 sublevel). One removal per RSE runs at a time, held by the agent.
0016 Acting is for the operations role (docs/AUTHORITY.md).
0017 """
0018
0019 import json
0020 import uuid
0021 from datetime import datetime, timedelta
0022 from pathlib import Path
0023
0024 from django.conf import settings
0025 from django.utils import timezone
0026
0027 from monitor_app.activemq_connection import ActiveMQConnectionManager
0028
0029
0030 PREVIEW_VALID = timedelta(hours=1)
0031
0032
0033
0034
0035 STALE_AFTER = timedelta(hours=2)
0036 STATUS_LABELS = {
0037 'previewing': 'Previewing',
0038 'previewed': 'Previewed',
0039 'queued': 'Queued for removal',
0040 'running': 'Removing',
0041 'done': 'Done',
0042 'partial': 'Done with failures',
0043 'failed': 'Failed',
0044 }
0045 PENDING_STATUSES = ('previewing', 'queued', 'running')
0046 ACTIONS = ('rse_deletion_preview', 'rse_deletion')
0047
0048
0049 class RseDeletionRequestError(Exception):
0050 def __init__(self, detail, status=400):
0051 super().__init__(detail)
0052 self.detail = detail
0053 self.status = status
0054
0055
0056 def request_dir():
0057 return Path(getattr(settings, 'SWF_TMP_DIR', '/data/swf-tmp')) / 'rse-deletions'
0058
0059
0060 def read_request(deletion_id):
0061 """The agent's working record of one request, or None."""
0062 try:
0063 path = request_dir() / f'{uuid.UUID(str(deletion_id))}.json'
0064 with open(path) as f:
0065 return json.load(f)
0066 except (ValueError, OSError):
0067 return None
0068
0069
0070 def _parse(stamp):
0071 try:
0072 return datetime.fromisoformat(str(stamp))
0073 except (TypeError, ValueError):
0074 return None
0075
0076
0077 def serialize(record):
0078 """The page's view of a request. A pending request the agent stopped
0079 writing reads as failed, with what it recorded kept."""
0080 out = dict(record)
0081 updated = _parse(out.get('updated_at'))
0082 if (out.get('status') in PENDING_STATUSES and updated
0083 and timezone.now() - updated > STALE_AFTER):
0084 out['status'] = 'failed'
0085 out['diagnostic'] = (
0086 f'No word from the agent for {int(STALE_AFTER.total_seconds() // 3600)} h; '
0087 'abandoned. Any recorded outcomes are partial.')
0088 out.setdefault('plan', {})
0089 out.setdefault('result', {})
0090 out['pending'] = out.get('status') in PENDING_STATUSES
0091 out['status_display'] = STATUS_LABELS.get(out.get('status'), out.get('status'))
0092 return out
0093
0094
0095 def _validate(rse, pattern):
0096 from swf_epicprod.rse_deletion import RseDeletionError, validate
0097 try:
0098 return validate(rse, pattern)
0099 except RseDeletionError as exc:
0100 raise RseDeletionRequestError(str(exc))
0101
0102
0103 def _send(message):
0104 try:
0105 sent = ActiveMQConnectionManager().send_message(
0106 '/queue/epicprod.ops', json.dumps(message))
0107 failure = 'ops-agent queue unreachable'
0108 except Exception as exc:
0109 sent, failure = False, str(exc)
0110 if not sent:
0111 raise RseDeletionRequestError(f'Could not queue: {failure}', 503)
0112
0113
0114 def request_preview(*, rse, pattern, requested_by):
0115 """Queue a live preview; the agent writes the request on receipt."""
0116 rse, pattern = _validate(rse, pattern)
0117 now = timezone.now().isoformat()
0118 record = {'id': str(uuid.uuid4()), 'rse': rse, 'pattern': pattern,
0119 'requested_by': requested_by, 'confirmed_by': '',
0120 'status': 'previewing', 'diagnostic': '',
0121 'requested_at': now, 'updated_at': now}
0122 _send({'msg_type': 'rse_deletion', 'namespace': 'prodops',
0123 'phase': 'preview', 'deletion_id': record['id'],
0124 'rse': rse, 'pattern': pattern, 'created_by': requested_by,
0125 'requested_at': now})
0126 return record
0127
0128
0129 def request_execute(deletion_id, *, confirm_rse, confirmed_by):
0130 """Queue the removal of what the request's preview found eligible."""
0131 record = read_request(deletion_id)
0132 if record is None:
0133 raise RseDeletionRequestError('Request not found.', 404)
0134 record = serialize(record)
0135 if record['status'] != 'previewed':
0136 raise RseDeletionRequestError(
0137 f"This request is {record['status_display'].lower()}; "
0138 'only a previewed request can be confirmed.', 409)
0139 if str(confirm_rse or '').strip() != record['rse']:
0140 raise RseDeletionRequestError(
0141 f"Type {record['rse']} exactly to confirm the removal.")
0142 previewed = _parse(record.get('previewed_at'))
0143 if not previewed or timezone.now() - previewed > PREVIEW_VALID:
0144 raise RseDeletionRequestError(
0145 'The preview is more than an hour old; preview again.', 409)
0146 names = [d['name'] for d in (record.get('plan') or {}).get('datasets', [])
0147 if d.get('eligible')]
0148 if not names:
0149 raise RseDeletionRequestError('The preview found nothing eligible.', 409)
0150 now = timezone.now().isoformat()
0151 _send({'msg_type': 'rse_deletion', 'namespace': 'prodops',
0152 'phase': 'execute', 'deletion_id': record['id'],
0153 'rse': record['rse'], 'pattern': record['pattern'], 'names': names,
0154 'created_by': confirmed_by, 'confirmed_at': now})
0155 return dict(record, status='queued', confirmed_by=confirmed_by,
0156 confirmed_at=now, pending=True,
0157 status_display=STATUS_LABELS['queued'])
0158
0159
0160 def recent(rse, limit=20):
0161 """The RSE's latest previews and removals from the action stream,
0162 newest first, for the page's Requests table."""
0163 from monitor_app.models import AppLog
0164 rows = (AppLog.objects
0165 .filter(app_name='epicprod', extra_data__action__in=ACTIONS,
0166 extra_data__subject_key=rse)
0167 .order_by('-timestamp')[:limit])
0168 out = []
0169 for row in rows:
0170 x = row.extra_data or {}
0171 removal = x.get('action') == 'rse_deletion'
0172 out.append({
0173 'at': row.timestamp,
0174 'kind': 'Removal' if removal else 'Preview',
0175 'pattern': x.get('pattern') or '',
0176 'by': x.get('username') or '',
0177 'outcome': x.get('status') or x.get('outcome') or '',
0178 'matched': x.get('matched'),
0179 'eligible': x.get('eligible'),
0180 'removed': x.get('removed') if removal else None,
0181 'removed_tb': (x['removed_bytes'] / 1e12
0182 if removal and x.get('removed_bytes') is not None else None),
0183 'note': x.get('reason') or '',
0184 })
0185 return out