| |
| """ |
| Nightly Wallet Label Backup & Sync |
| ==================================== |
| 1. Validates clean label data integrity |
| 2. Backs up to R2 cloud storage (with rotation) |
| 3. Syncs clean labels into running Redis/ClickHouse |
| 4. Logs freshness metrics |
| """ |
|
|
| import csv |
| import hashlib |
| import json |
| import os |
| import shutil |
| from datetime import UTC, datetime |
| from pathlib import Path |
|
|
| DATA_DIR = os.environ.get("RMI_DATA_DIR", "/root/backend/data") |
| CLEAN_DIR = os.path.join(DATA_DIR, "wallet-labels-clean") |
| BACKUP_DIR = os.path.join(DATA_DIR, "wallet-labels-backups") |
| MANIFEST_PATH = os.path.join(CLEAN_DIR, "manifest.json") |
|
|
| |
| R2_BUCKET = os.environ.get("R2_BUCKET", "rmi-backups") |
| R2_ENDPOINT = os.environ.get("R2_ENDPOINT", "") |
| R2_KEY = os.environ.get("R2_ACCESS_KEY", "") |
| R2_SECRET = os.environ.get("R2_SECRET_KEY", "") |
|
|
| MAX_BACKUPS = 30 |
|
|
|
|
| def validate_labels(): |
| """Validate all clean label files for integrity.""" |
| print("Validating label data...") |
| results = {} |
|
|
| for fname in ["wallet_labels_ethereum.csv", "wallet_labels_solana.csv"]: |
| path = os.path.join(CLEAN_DIR, fname) |
| if not os.path.exists(path): |
| results[fname] = {"status": "MISSING"} |
| continue |
|
|
| count = 0 |
| dupes = 0 |
| invalids = 0 |
| seen = set() |
|
|
| with open(path) as f: |
| reader = csv.DictReader(f) |
| for row in reader: |
| addr = row.get("address", "") |
| if not addr: |
| invalids += 1 |
| continue |
| if addr.lower() in seen: |
| dupes += 1 |
| seen.add(addr.lower()) |
| count += 1 |
|
|
| results[fname] = { |
| "status": "OK" if dupes == 0 and invalids == 0 else "ISSUES", |
| "count": count, |
| "duplicates": dupes, |
| "invalids": invalids, |
| "unique": len(seen), |
| } |
| print(f" {fname}: {count:,} rows, {dupes} dupes, {invalids} invalid — {results[fname]['status']}") |
|
|
| |
| if os.path.exists(MANIFEST_PATH): |
| with open(MANIFEST_PATH) as f: |
| manifest = json.load(f) |
| results["manifest"] = { |
| "generated": manifest.get("generated_at"), |
| "total": manifest.get("total_labels"), |
| "chains": manifest.get("chains"), |
| } |
|
|
| return results |
|
|
|
|
| def create_backup(): |
| """Create a timestamped backup of the clean label data.""" |
| os.makedirs(BACKUP_DIR, exist_ok=True) |
|
|
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| backup_path = os.path.join(BACKUP_DIR, f"labels_{timestamp}") |
| os.makedirs(backup_path, exist_ok=True) |
|
|
| |
| for fname in os.listdir(CLEAN_DIR): |
| src = os.path.join(CLEAN_DIR, fname) |
| dst = os.path.join(backup_path, fname) |
| if os.path.isfile(src): |
| shutil.copy2(src, dst) |
|
|
| |
| checksums = {} |
| for fname in os.listdir(backup_path): |
| fpath = os.path.join(backup_path, fname) |
| with open(fpath, "rb") as f: |
| checksums[fname] = hashlib.sha256(f.read()).hexdigest() |
|
|
| with open(os.path.join(backup_path, "checksums.json"), "w") as f: |
| json.dump(checksums, f, indent=2) |
|
|
| |
| backup_manifest = { |
| "backup_time": datetime.now(UTC).isoformat(), |
| "backup_id": timestamp, |
| "files": os.listdir(backup_path), |
| "checksums": checksums, |
| } |
| with open(os.path.join(backup_path, "backup_manifest.json"), "w") as f: |
| json.dump(backup_manifest, f, indent=2) |
|
|
| |
| archive_path = f"{backup_path}.tar.gz" |
| shutil.make_archive(backup_path, "gztar", BACKUP_DIR, f"labels_{timestamp}") |
|
|
| |
| shutil.rmtree(backup_path) |
|
|
| |
| backups = sorted(Path(BACKUP_DIR).glob("labels_*.tar.gz")) |
| while len(backups) > MAX_BACKUPS: |
| oldest = backups.pop(0) |
| oldest.unlink() |
| print(f" Rotated out: {oldest.name}") |
|
|
| size_mb = os.path.getsize(archive_path) / (1024 * 1024) |
| print(f"Backup created: {archive_path} ({size_mb:.1f} MB)") |
|
|
| return archive_path |
|
|
|
|
| def upload_to_r2(archive_path: str) -> bool: |
| """Upload backup to R2 cloud storage.""" |
| if not all([R2_ENDPOINT, R2_KEY, R2_SECRET]): |
| print("R2 not configured — skipping cloud upload") |
| return False |
|
|
| try: |
| import boto3 |
|
|
| s3 = boto3.client( |
| "s3", |
| endpoint_url=R2_ENDPOINT, |
| aws_access_key_id=R2_KEY, |
| aws_secret_access_key=R2_SECRET, |
| ) |
|
|
| fname = os.path.basename(archive_path) |
| s3.upload_file( |
| archive_path, |
| R2_BUCKET, |
| f"wallet-labels/{fname}", |
| ) |
| print(f"Uploaded to R2: wallet-labels/{fname}") |
| return True |
| except ImportError: |
| print("boto3 not installed — skipping R2 upload") |
| return False |
| except Exception as e: |
| print(f"R2 upload failed: {e}") |
| return False |
|
|
|
|
| def sync_to_redis(): |
| """Sync clean labels into Redis for fast lookups.""" |
| try: |
| import redis |
|
|
| r = redis.Redis( |
| host=os.environ.get("REDIS_HOST", "localhost"), |
| port=int(os.environ.get("REDIS_PORT", "6379")), |
| password=os.environ.get("REDIS_PASSWORD", ""), |
| decode_responses=True, |
| ) |
| r.ping() |
| except Exception as e: |
| print(f"Redis not available: {e}") |
| return 0 |
|
|
| synced = 0 |
| for chain_file in ["wallet_labels_ethereum.csv", "wallet_labels_solana.csv"]: |
| path = os.path.join(CLEAN_DIR, chain_file) |
| if not os.path.exists(path): |
| continue |
|
|
| pipe = r.pipeline() |
| batch = 0 |
| with open(path) as f: |
| for row in csv.DictReader(f): |
| addr = row["address"].lower() |
| key = f"wl:{addr}" |
| pipe.hset( |
| key, |
| mapping={ |
| "chain": row.get("chain", ""), |
| "source": row.get("source", ""), |
| "name": row.get("name", ""), |
| "entity_type": row.get("entity_type", ""), |
| "synced_at": datetime.now(UTC).isoformat(), |
| }, |
| ) |
| batch += 1 |
| if batch % 1000 == 0: |
| pipe.execute() |
| pipe = r.pipeline() |
| synced += batch |
| batch = 0 |
|
|
| if batch > 0: |
| pipe.execute() |
| synced += batch |
|
|
| print(f"Synced {synced:,} labels to Redis") |
| return synced |
|
|
|
|
| def main(): |
| print(f"=== Wallet Label Nightly Backup — {datetime.now().isoformat()} ===\n") |
|
|
| |
| validation = validate_labels() |
|
|
| |
| archive = create_backup() |
|
|
| |
| uploaded = upload_to_r2(archive) |
|
|
| |
| synced = sync_to_redis() |
|
|
| |
| manifest = { |
| "generated_at": datetime.now(UTC).isoformat(), |
| "last_backup": str(archive), |
| "cloud_uploaded": uploaded, |
| "redis_synced": synced, |
| "validation": validation, |
| } |
| with open(os.path.join(CLEAN_DIR, "nightly_manifest.json"), "w") as f: |
| json.dump(manifest, f, indent=2, default=str) |
|
|
| print("\n=== Done ===") |
| print(f"Backup: {archive}") |
| print(f"Cloud: {'✓' if uploaded else '✗'}") |
| print(f"Redis: {synced:,} labels synced") |
|
|
| return manifest |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|