host-metrics/scripts/repair-malformed-documents.py
2026-09-17 22:31:35 +02:00

89 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Recover the POC's former hostname-keyed JSON documents in place.
Default: read-only report. --apply requires an exclusive backup JSONL path.
Only documents missing @timestamp and containing a valid Telegraf JSON payload
are candidates. Existing IDs are retained; writes use optimistic concurrency.
"""
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import urllib.request
BASE = 'http://127.0.0.1:9200'
def request(path, data, method='POST', content_type='application/json'):
body = data if isinstance(data, bytes) else json.dumps(data).encode()
req = urllib.request.Request(BASE + path, data=body, method=method, headers={'Content-Type': content_type})
with urllib.request.urlopen(req, timeout=30) as response:
return json.load(response)
def recover(source):
for value in source.values():
if not isinstance(value, str) or not value.lstrip().startswith('{'):
continue
try:
parsed = json.loads(value)
if not (isinstance(parsed.get('name'), str) and isinstance(parsed.get('fields'), dict)
and isinstance(parsed.get('tags'), dict) and parsed['tags'].get('host')):
continue
parsed['@timestamp'] = datetime.fromtimestamp(parsed['timestamp'] / 1000, timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
if source.get('ingested_at'):
parsed['ingested_at'] = source['ingested_at']
return parsed
except (ValueError, TypeError, KeyError, OverflowError):
continue
return None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--apply', action='store_true')
parser.add_argument('--backup', type=Path)
args = parser.parse_args()
if args.apply and not args.backup:
parser.error('--apply requires --backup PATH')
backup = args.backup.open('x') if args.apply else None
found = changed = skipped = 0
scroll = None
try:
page = request('/host-metrics-v1-*/_search?scroll=2m', {
'size': 500, 'seq_no_primary_term': True,
'query': {'bool': {'must_not': [{'exists': {'field': '@timestamp'}}]}}})
while True:
scroll = page.get('_scroll_id', scroll)
hits = page['hits']['hits']
if not hits:
break
operations = []
for hit in hits:
parsed = recover(hit['_source'])
if parsed is None:
skipped += 1; continue
found += 1
if args.apply:
backup.write(json.dumps(hit) + '\n')
action = {'index': {'_index': hit['_index'], '_id': hit['_id'],
'if_seq_no': hit['_seq_no'], 'if_primary_term': hit['_primary_term']}}
operations.extend([json.dumps(action), json.dumps(parsed)])
if operations:
backup.flush()
import os
os.fsync(backup.fileno())
result = request('/_bulk?refresh=wait_for', ('\n'.join(operations) + '\n').encode(), content_type='application/x-ndjson')
if result.get('errors'):
raise RuntimeError('Bulk errors; originals are backed up: ' + json.dumps(result))
changed += len(result['items'])
page = request('/_search/scroll', {'scroll': '2m', 'scroll_id': scroll})
finally:
if backup:
backup.close()
if scroll:
request('/_search/scroll', {'scroll_id': [scroll]}, method='DELETE')
print(f'Recoverable: {found}; repaired: {changed}; skipped unrecognized documents: {skipped}')
if not args.apply:
print('Read-only report. Use --apply --backup /path/to/original-documents.jsonl to repair.')
if __name__ == '__main__':
main()