canado / scripts /archive_sync.py
funny790's picture
Upload folder using huggingface_hub (part 4)
bb02ad0 verified
Raw
History Blame Contribute Delete
3.92 kB
#!/usr/bin/env python3
"""Pull-based audit archiver: copy immutable round artifacts to the archive bucket.
Copies every ``manifests/round-*.json`` and ``receipts/**/round-*.json`` object
from the production bucket into an archive bucket, skipping keys that already
exist there (append-only, enforced client-side — Hippius has no WORM
primitive). Mutable pointers (``latest.json``) are deliberately NOT archived.
The archive bucket MUST live under a SEPARATE Hippius account: Hippius S3 keys
are account-scoped, so an archive under the production account would be
deletable by the same credentials it is meant to survive. Run this from a box
that holds ONLY the archive account's write keys and (optionally) production
read keys — never the reverse.
Environment:
SOURCE_S3_ENDPOINT default https://s3.hippius.com
SOURCE_S3_BUCKET e.g. cascade-testnet-manifests
SOURCE_S3_ACCESS_KEY / SOURCE_S3_SECRET_KEY (read)
ARCHIVE_S3_ENDPOINT default https://s3.hippius.com
ARCHIVE_S3_BUCKET e.g. cascade-mainnet-archive
ARCHIVE_S3_ACCESS_KEY / ARCHIVE_S3_SECRET_KEY (write — the ONLY place these live)
Usage:
archive_sync.py [--dry-run] [--prefixes manifests/ receipts/]
Cron (hourly is plenty — rounds are epoch-paced):
17 * * * * cd /opt/cascade-archiver && ./archive_sync.py >> archive.log 2>&1
"""
from __future__ import annotations
import argparse
import fnmatch
import os
import sys
import boto3
from botocore.config import Config
IMMUTABLE_PATTERNS = ("manifests/round-*.json", "receipts/*/round-*.json",
"receipts/round-*.json")
CFG = Config(connect_timeout=10, read_timeout=60,
retries={"max_attempts": 3, "mode": "standard"})
def _client(side: str):
endpoint = os.environ.get(f"{side}_S3_ENDPOINT", "https://s3.hippius.com")
return boto3.client(
"s3", endpoint_url=endpoint,
aws_access_key_id=os.environ[f"{side}_S3_ACCESS_KEY"],
aws_secret_access_key=os.environ[f"{side}_S3_SECRET_KEY"],
config=CFG,
)
def _list_keys(s3, bucket: str, prefix: str) -> set[str]:
keys: set[str] = set()
token: str | None = None
while True:
kw = {"Bucket": bucket, "Prefix": prefix}
if token:
kw["ContinuationToken"] = token
resp = s3.list_objects_v2(**kw)
keys.update(o["Key"] for o in resp.get("Contents", []))
if not resp.get("IsTruncated"):
return keys
token = resp.get("NextContinuationToken")
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--dry-run", action="store_true",
help="list what would be copied; write nothing")
p.add_argument("--prefixes", nargs="+", default=["manifests/", "receipts/"])
args = p.parse_args(argv)
src = _client("SOURCE")
src_bucket = os.environ["SOURCE_S3_BUCKET"]
dst = None
dst_bucket = os.environ.get("ARCHIVE_S3_BUCKET", "")
if not args.dry_run:
dst = _client("ARCHIVE")
copied = skipped = 0
for prefix in args.prefixes:
source_keys = {
k for k in _list_keys(src, src_bucket, prefix)
if any(fnmatch.fnmatch(k, pat) for pat in IMMUTABLE_PATTERNS)
}
have = _list_keys(dst, dst_bucket, prefix) if dst is not None else set()
for key in sorted(source_keys - have):
if args.dry_run:
print(f"WOULD COPY {key}")
copied += 1
continue
body = src.get_object(Bucket=src_bucket, Key=key)["Body"].read()
dst.put_object(Bucket=dst_bucket, Key=key, Body=body)
print(f"archived {key} ({len(body)} bytes)")
copied += 1
skipped += len(source_keys & have)
print(f"done: {copied} copied, {skipped} already archived")
return 0
if __name__ == "__main__":
sys.exit(main())