chore: add script for cache
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
eas_cache.py — Cache manager for EAS local builds stored on RustFS/S3
|
||||
|
||||
Reproduit le système de cache d'Expo cloud pour les builds locaux :
|
||||
1. restore : télécharge le cache Gradle depuis S3 et l'extrait dans ~/.gradle
|
||||
2. save : compresse ~/.gradle et l'upload dans S3
|
||||
|
||||
Clé de cache = sha256(package-lock.json + app.json) → premier appel exact,
|
||||
sha256(package-lock.json) → fallback si app.json change
|
||||
|
||||
Usage:
|
||||
python scripts/eas_cache.py restore --app mobile
|
||||
python scripts/eas_cache.py save --app mobile
|
||||
|
||||
Variables d'environnement requises:
|
||||
AWS_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
S3_ENDPOINT ex: https://s3.uber-stup.club
|
||||
S3_BUCKET ex: build-cache (défaut: build-cache)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ── Dirs à cacher ──────────────────────────────────────────────────────────────
|
||||
CACHE_DIRS = [
|
||||
Path.home() / ".gradle" / "caches" / "modules-2", # dépendances Maven/Gradle
|
||||
Path.home() / ".gradle" / "caches" / "transforms-3", # artefacts transformés
|
||||
Path.home() / ".gradle" / "wrapper" / "dists", # distribution Gradle
|
||||
]
|
||||
|
||||
# Fichiers utilisés pour calculer la clé de cache par app
|
||||
HASH_FILES = {
|
||||
"omnex-plateform-client": ["omnex-plateform-client/package-lock.json", "omnex-plateform-client/app.json"],
|
||||
"omnex-plateform-app": ["omnex-plateform-app/package-lock.json", "omnex-plateform-app/app.json"],
|
||||
}
|
||||
|
||||
|
||||
# ── Clé de cache ───────────────────────────────────────────────────────────────
|
||||
|
||||
def compute_key(app: str, repo_root: Path, full: bool = True) -> str:
|
||||
"""Calcule la clé de cache (sha256 tronqué à 16 chars)."""
|
||||
files = HASH_FILES[app]
|
||||
if not full:
|
||||
files = files[:1] # fallback : seulement package-lock.json
|
||||
|
||||
h = hashlib.sha256()
|
||||
for rel in files:
|
||||
path = repo_root / rel
|
||||
if path.exists():
|
||||
h.update(path.read_bytes())
|
||||
else:
|
||||
h.update(rel.encode()) # fichier absent → on hash le nom pour différencier
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def s3_key(app: str, cache_key: str) -> str:
|
||||
return f"gradle-cache/{app}/{cache_key}.tar.gz"
|
||||
|
||||
BUCKET_DEFAULT = "apk-builds"
|
||||
|
||||
|
||||
# ── Helpers S3 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _boto3_client():
|
||||
try:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except ImportError:
|
||||
print("❌ boto3 non installé — lance : pip install boto3")
|
||||
sys.exit(1)
|
||||
|
||||
endpoint = os.environ.get("S3_ENDPOINT", "").rstrip("/")
|
||||
if not endpoint:
|
||||
print("❌ Variable S3_ENDPOINT manquante")
|
||||
sys.exit(1)
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
|
||||
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
||||
region_name=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
config=Config(
|
||||
s3={"addressing_style": "path"},
|
||||
signature_version="s3v4",
|
||||
),
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
def ensure_bucket(s3, bucket: str):
|
||||
"""Crée le bucket s'il n'existe pas."""
|
||||
try:
|
||||
s3.head_bucket(Bucket=bucket)
|
||||
except Exception:
|
||||
try:
|
||||
s3.create_bucket(Bucket=bucket)
|
||||
print(f"🪣 Bucket '{bucket}' créé")
|
||||
except Exception as e:
|
||||
print(f"❌ Impossible de créer le bucket '{bucket}': {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class _Progress:
|
||||
"""Affiche la progression upload/download en MB."""
|
||||
|
||||
def __init__(self, total: int, label: str):
|
||||
self.total = total
|
||||
self.done = 0
|
||||
self.label = label
|
||||
self.start = time.time()
|
||||
|
||||
def __call__(self, chunk: int):
|
||||
self.done += chunk
|
||||
pct = self.done * 100 // self.total if self.total else 0
|
||||
mb_done = self.done / 1_048_576
|
||||
mb_total = self.total / 1_048_576
|
||||
elapsed = time.time() - self.start
|
||||
speed = (self.done / elapsed / 1_048_576) if elapsed > 0 else 0
|
||||
print(
|
||||
f"\r {self.label} {mb_done:.1f}/{mb_total:.1f} MB {pct}% {speed:.1f} MB/s",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
if self.done >= self.total:
|
||||
print()
|
||||
|
||||
|
||||
def object_exists(s3, bucket: str, key: str) -> bool:
|
||||
try:
|
||||
s3.head_object(Bucket=bucket, Key=key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def object_size(s3, bucket: str, key: str) -> int:
|
||||
try:
|
||||
resp = s3.head_object(Bucket=bucket, Key=key)
|
||||
return resp["ContentLength"]
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
# ── Commande restore ───────────────────────────────────────────────────────────
|
||||
|
||||
def restore(app: str, repo_root: Path, bucket: str):
|
||||
s3 = _boto3_client()
|
||||
ensure_bucket(s3, bucket)
|
||||
|
||||
full_key = compute_key(app, repo_root, full=True)
|
||||
fallback_key = compute_key(app, repo_root, full=False)
|
||||
|
||||
hit_key = None
|
||||
for label, ck in [("exact", full_key), ("fallback", fallback_key)]:
|
||||
obj = s3_key(app, ck)
|
||||
if object_exists(s3, bucket, obj):
|
||||
print(f"✅ Cache {label} trouvé → {obj}")
|
||||
hit_key = ck
|
||||
break
|
||||
|
||||
if hit_key is None:
|
||||
print("ℹ️ Aucun cache trouvé (premier build ou clé inconnue)")
|
||||
return
|
||||
|
||||
obj = s3_key(app, hit_key)
|
||||
size = object_size(s3, bucket, obj)
|
||||
progress = _Progress(size, "⬇️ download")
|
||||
|
||||
import tempfile
|
||||
with tempfile.TemporaryFile() as tmp:
|
||||
s3.download_fileobj(bucket, obj, tmp, Callback=progress)
|
||||
tmp.seek(0)
|
||||
print("📦 Extraction du cache…")
|
||||
with tarfile.open(fileobj=tmp, mode="r:gz") as tf:
|
||||
tf.extractall(path=Path.home())
|
||||
|
||||
print(f"✅ Cache restauré ({size / 1_048_576:.1f} MB)")
|
||||
|
||||
|
||||
# ── Commande save ──────────────────────────────────────────────────────────────
|
||||
|
||||
def save(app: str, repo_root: Path, bucket: str):
|
||||
s3 = _boto3_client()
|
||||
ensure_bucket(s3, bucket)
|
||||
cache_key = compute_key(app, repo_root, full=True)
|
||||
obj = s3_key(app, cache_key)
|
||||
|
||||
# Pas besoin de re-uploader si la clé existe déjà
|
||||
if object_exists(s3, bucket, obj):
|
||||
print(f"ℹ️ Cache déjà présent pour cette clé ({cache_key}), skip upload")
|
||||
return
|
||||
|
||||
dirs_to_cache = [d for d in CACHE_DIRS if d.exists()]
|
||||
if not dirs_to_cache:
|
||||
print("⚠️ Aucun répertoire Gradle à cacher (~/.gradle introuvable)")
|
||||
return
|
||||
|
||||
print(f"📦 Compression de {len(dirs_to_cache)} répertoires…")
|
||||
for d in dirs_to_cache:
|
||||
print(f" {d}")
|
||||
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz", compresslevel=6) as tf:
|
||||
for d in dirs_to_cache:
|
||||
# Chemin relatif depuis home pour restaurer au bon endroit
|
||||
arcname = str(d.relative_to(Path.home()))
|
||||
tf.add(d, arcname=arcname)
|
||||
|
||||
size = buf.tell()
|
||||
buf.seek(0)
|
||||
|
||||
print(f"⬆️ Upload {obj} ({size / 1_048_576:.1f} MB)…")
|
||||
progress = _Progress(size, "⬆️ upload ")
|
||||
s3.upload_fileobj(buf, bucket, obj, Callback=progress)
|
||||
|
||||
print(f"✅ Cache sauvegardé ({cache_key})")
|
||||
|
||||
|
||||
# ── Commande clean (optionnel) ─────────────────────────────────────────────────
|
||||
|
||||
def clean(app: str, bucket: str, keep: int):
|
||||
"""Supprime les anciennes entrées de cache (garde les `keep` plus récentes)."""
|
||||
s3 = _boto3_client()
|
||||
prefix = f"gradle-cache/{app}/"
|
||||
resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
|
||||
objs = sorted(
|
||||
resp.get("Contents", []),
|
||||
key=lambda o: o["LastModified"],
|
||||
reverse=True,
|
||||
)
|
||||
to_delete = objs[keep:]
|
||||
if not to_delete:
|
||||
print(f"ℹ️ Rien à supprimer (≤ {keep} entrées)")
|
||||
return
|
||||
s3.delete_objects(
|
||||
Bucket=bucket,
|
||||
Delete={"Objects": [{"Key": o["Key"]} for o in to_delete]},
|
||||
)
|
||||
print(f"🗑 {len(to_delete)} ancienne(s) entrée(s) supprimée(s)")
|
||||
|
||||
|
||||
# ── CLI ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="EAS build cache manager — RustFS/S3")
|
||||
parser.add_argument("command", choices=["restore", "save", "clean"])
|
||||
parser.add_argument("--app", required=True, choices=list(HASH_FILES.keys()))
|
||||
parser.add_argument(
|
||||
"--bucket",
|
||||
default=os.environ.get("S3_BUCKET", "apk-builds"),
|
||||
help="Nom du bucket S3 (défaut: apk-builds)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
default=os.environ.get("GITHUB_WORKSPACE", "."),
|
||||
help="Racine du repo (défaut: GITHUB_WORKSPACE ou répertoire courant)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep",
|
||||
type=int,
|
||||
default=5,
|
||||
help="[clean] Nombre d'entrées à conserver par app (défaut: 5)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
print(f"🔧 eas_cache | app={args.app} commande={args.command} bucket={args.bucket}")
|
||||
|
||||
if args.command == "restore":
|
||||
restore(args.app, repo_root, args.bucket)
|
||||
elif args.command == "save":
|
||||
save(args.app, repo_root, args.bucket)
|
||||
elif args.command == "clean":
|
||||
clean(args.app, args.bucket, args.keep)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
boto3>=1.34.0
|
||||
@@ -0,0 +1,17 @@
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Client ajoute au panier] -->|Décrémente stock| B[Stock -= quantité]
|
||||
B --> C[Ajout au panier]
|
||||
C -->|❌ Si échec| D[Stock déjà décrémenté!]
|
||||
|
||||
E[Client supprime du panier] -->|Transaction DB| F[Stock += quantité]
|
||||
F --> G[Suppression du panier]
|
||||
|
||||
H[Client valide commande] --> I[Panier vidé]
|
||||
I -->|Sans restaurer stock| J[Commande créée]
|
||||
|
||||
K[Client annule commande] -->|Transaction DB| L[Stock += quantité]
|
||||
L --> M[Commande annulée]
|
||||
|
||||
N[Paiement crypto échoue] -->|Transaction DB| O[Stock += quantité]
|
||||
```
|
||||
Reference in New Issue
Block a user