Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| deploy.py — Persistent data deployment for Hugging Face Spaces. | |
| Usage: | |
| python deploy.py | |
| Flow: | |
| 1. Back up existing data from the deployed Space -> persistence/ | |
| 2. Commit and push local changes to the Space repository | |
| 3. Wait for the Space to redeploy (poll /health) | |
| 4. Restore the backed-up data to the new deployment | |
| 5. Generate a comprehensive audit report | |
| """ | |
| import argparse | |
| import io | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import tarfile | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any, Optional | |
| # ───────────────────────────────────────────────────────────── | |
| # Console encoding detection | |
| # ───────────────────────────────────────────────────────────── | |
| _USE_UNICODE = True | |
| try: | |
| "\u2713".encode(sys.stdout.encoding or "utf-8") | |
| except (UnicodeEncodeError, UnicodeDecodeError, LookupError): | |
| _USE_UNICODE = False | |
| # ───────────────────────────────────────────────────────────── | |
| # ANSI colours & helpers | |
| # ───────────────────────────────────────────────────────────── | |
| GREEN = "\033[92m" | |
| RED = "\033[91m" | |
| YELLOW = "\033[93m" | |
| CYAN = "\033[96m" | |
| BOLD = "\033[1m" | |
| RESET = "\033[0m" | |
| def _c(colour: str, text: str) -> str: | |
| return f"{colour}{text}{RESET}" | |
| def _ok(text: str) -> str: | |
| return _c(GREEN, text) | |
| def _fail(text: str) -> str: | |
| return _c(RED, text) | |
| def _warn(text: str) -> str: | |
| return _c(YELLOW, text) | |
| def _info(text: str) -> str: | |
| return _c(CYAN, text) | |
| def _bold(text: str) -> str: | |
| return _c(BOLD, text) | |
| # Icon constants (must be defined outside f-string expressions for Python < 3.12) | |
| # Unicode vs ASCII fallbacks for Windows console compatibility | |
| if _USE_UNICODE: | |
| _OK_ICON = _ok("\u2713") | |
| _FAIL_ICON = _fail("\u2717") | |
| _WARN_ICON = _warn("\u26A0") | |
| _INFO_ICON = _info("\u25B8") | |
| _BULLET = "\u2022" | |
| _DASH = "\u2014" | |
| _TABLE_TL = "\u2554" | |
| _TABLE_TR = "\u2557" | |
| _TABLE_BL = "\u255A" | |
| _TABLE_BR = "\u255D" | |
| _TABLE_H = "\u2550" | |
| _TABLE_V = "\u2551" | |
| _TABLE_TM = "\u2560" | |
| _TABLE_BM = "\u255A" | |
| _TABLE_ML = "\u251C" | |
| _TABLE_MR = "\u2524" | |
| _TABLE_MM = "\u253C" | |
| _TABLE_MH = "\u2500" | |
| _TABLE_MV = "\u2502" | |
| _TABLE_TML = "\u255F" | |
| _TABLE_TMR = "\u257E" | |
| else: | |
| _OK_ICON = _ok("v") | |
| _FAIL_ICON = _fail("x") | |
| _WARN_ICON = _warn("!") | |
| _INFO_ICON = _info(">") | |
| _BULLET = "*" | |
| _DASH = "-" | |
| _TABLE_TL = "+" | |
| _TABLE_TR = "+" | |
| _TABLE_BL = "+" | |
| _TABLE_BR = "+" | |
| _TABLE_H = "=" | |
| _TABLE_V = "|" | |
| _TABLE_TM = "+" | |
| _TABLE_BM = "+" | |
| _TABLE_ML = "+" | |
| _TABLE_MR = "+" | |
| _TABLE_MM = "+" | |
| _TABLE_MH = "-" | |
| _TABLE_MV = "|" | |
| _TABLE_TML = "+" | |
| _TABLE_TMR = "-" | |
| def _indicator(status: str) -> str: | |
| mapping = { | |
| "passed": _OK_ICON, | |
| "failed": _FAIL_ICON, | |
| "skipped": _WARN_ICON, | |
| "running": _INFO_ICON, | |
| } | |
| return mapping.get(status, _DASH) | |
| def _timestamp() -> str: | |
| return datetime.now(timezone.utc).strftime("%H:%M:%S") | |
| def _human_size(n_bytes: int) -> str: | |
| for unit in ("B", "KB", "MB", "GB"): | |
| if n_bytes < 1024: | |
| return f"{n_bytes:.2f} {unit}" | |
| n_bytes /= 1024 | |
| return f"{n_bytes:.2f} TB" | |
| def _human_duration(seconds: float) -> str: | |
| if seconds < 60: | |
| return f"{seconds:.1f}s" | |
| mins = int(seconds // 60) | |
| secs = int(seconds % 60) | |
| return f"{mins}m {secs}s" | |
| # ───────────────────────────────────────────────────────────── | |
| # Environment loading | |
| # ───────────────────────────────────────────────────────────── | |
| def load_env(env_path: str = ".env") -> dict[str, str]: | |
| env: dict[str, str] = {} | |
| p = Path(env_path) | |
| if not p.is_file(): | |
| return env | |
| for line in p.read_text(encoding="utf-8").splitlines(): | |
| line = line.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| m = re.match(r"^([A-Za-z_][A-Za-z_0-9]*)\s*=\s*(.*?)\s*$", line) | |
| if not m: | |
| continue | |
| key = m.group(1) | |
| val = m.group(2) | |
| if val.startswith('"') and val.endswith('"'): | |
| val = val[1:-1] | |
| elif val.startswith("'") and val.endswith("'"): | |
| val = val[1:-1] | |
| env[key] = val | |
| return env | |
| # ───────────────────────────────────────────────────────────── | |
| # HTTP helpers | |
| # ───────────────────────────────────────────────────────────── | |
| BACKUP_ARCHIVE = "backup.tar.gz" | |
| RESTORE_ENDPOINT = "/api/v1/system/backup/restore" | |
| BACKUP_ENDPOINT = "/api/v1/system/backup" | |
| HEALTH_ENDPOINT = "/health" | |
| HF_API_SPACES = "https://huggingface.co/api/spaces" | |
| def _build_url(base: str, path: str) -> str: | |
| base = base.rstrip("/") | |
| path = path.lstrip("/") | |
| return f"{base}/{path}" | |
| def http_get(url: str, headers: dict[str, str], timeout: int = 120) -> tuple[int, bytes]: | |
| req = urllib.request.Request(url, headers=headers, method="GET") | |
| with urllib.request.urlopen(req, timeout=timeout) as resp: | |
| return resp.status, resp.read() | |
| def http_get_stream( | |
| url: str, headers: dict[str, str], dest: Path, timeout: int = 300 | |
| ) -> tuple[int, int]: | |
| req = urllib.request.Request(url, headers=headers, method="GET") | |
| with urllib.request.urlopen(req, timeout=timeout) as resp: | |
| total = 0 | |
| with open(dest, "wb") as f: | |
| while True: | |
| chunk = resp.read(65536) | |
| if not chunk: | |
| break | |
| f.write(chunk) | |
| total += len(chunk) | |
| return resp.status, total | |
| def http_post_multipart( | |
| url: str, | |
| file_path: Path, | |
| field_name: str, | |
| headers: dict[str, str], | |
| timeout: int = 300, | |
| ) -> tuple[int, bytes]: | |
| boundary = "----DeployBoundary" + hex(int(time.time() * 1e6))[2:] | |
| data = io.BytesIO() | |
| data.write(f"--{boundary}\r\n".encode()) | |
| data.write( | |
| f'Content-Disposition: form-data; name="{field_name}"; filename="{file_path.name}"\r\n'.encode() | |
| ) | |
| data.write(b"Content-Type: application/gzip\r\n\r\n") | |
| data.write(file_path.read_bytes()) | |
| data.write(f"\r\n--{boundary}--\r\n".encode()) | |
| body = data.getvalue() | |
| content_type = f"multipart/form-data; boundary={boundary}" | |
| req_headers = {**headers, "Content-Type": content_type} | |
| req = urllib.request.Request(url, data=body, headers=req_headers, method="POST") | |
| with urllib.request.urlopen(req, timeout=timeout) as resp: | |
| return resp.status, resp.read() | |
| def http_get_json( | |
| url: str, headers: dict[str, str], timeout: int = 30 | |
| ) -> Optional[dict[str, Any]]: | |
| try: | |
| req = urllib.request.Request(url, headers=headers, method="GET") | |
| with urllib.request.urlopen(req, timeout=timeout) as resp: | |
| return json.loads(resp.read().decode("utf-8")) | |
| except Exception: | |
| return None | |
| def http_head(url: str, timeout: int = 30) -> Optional[int]: | |
| try: | |
| req = urllib.request.Request(url, method="HEAD") | |
| with urllib.request.urlopen(req, timeout=timeout) as resp: | |
| return resp.status | |
| except urllib.error.URLError: | |
| return None | |
| # ───────────────────────────────────────────────────────────── | |
| # Git helpers | |
| # ───────────────────────────────────────────────────────────── | |
| def _run_git(args: list[str], cwd: str | None = None) -> tuple[int, str]: | |
| cmd = ["git"] + args | |
| result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) | |
| return result.returncode, result.stdout.strip() | |
| def git_status(cwd: str) -> list[str]: | |
| rc, out = _run_git(["status", "--porcelain"], cwd=cwd) | |
| if rc != 0: | |
| return [] | |
| lines = [line for line in out.split("\n") if line.strip()] | |
| return lines | |
| def git_add_all(cwd: str) -> bool: | |
| changes = git_status(cwd) | |
| if not changes: | |
| return True | |
| paths = [] | |
| for line in changes: | |
| fn = line[3:].strip() if len(line) > 3 else "" | |
| if fn and fn.lower() != "nul": | |
| paths.append(fn) | |
| if not paths: | |
| return True | |
| rc, _ = _run_git(["add", "--"] + paths, cwd=cwd) | |
| return rc == 0 | |
| def git_commit(cwd: str, message: str) -> tuple[bool, str]: | |
| rc, out = _run_git(["commit", "-m", message], cwd=cwd) | |
| if rc == 0: | |
| m = re.search(r"\[[^\]]+ ([a-f0-9]+)\]", out) | |
| sha = m.group(1) if m else "unknown" | |
| return True, sha | |
| if "nothing to commit" in out.lower() or "no changes" in out.lower(): | |
| return True, "no-change" | |
| return False, out | |
| def git_push(cwd: str, remote: str = "origin", branch: str = "main") -> tuple[bool, str]: | |
| rc, out = _run_git(["push", remote, branch], cwd=cwd) | |
| return rc == 0, out | |
| def _parse_hf_space_from_url(url: str) -> tuple[str, str]: | |
| m = re.search(r"huggingface\.co/spaces/([^/]+)/([^/\s]+)", url) | |
| if m: | |
| return m.group(1), m.group(2).rstrip("/") | |
| return "aetherbase", "llm-ready-data" | |
| def git_remote_url(cwd: str) -> str: | |
| rc, out = _run_git(["remote", "get-url", "origin"], cwd=cwd) | |
| if rc != 0: | |
| return "" | |
| return out | |
| def git_log(cwd: str, n: int = 3) -> str: | |
| _, out = _run_git(["log", "--oneline", f"-{n}"], cwd=cwd) | |
| return out | |
| # ───────────────────────────────────────────────────────────── | |
| # Core deployment logic | |
| # ───────────────────────────────────────────────────────────── | |
| class AuditReport: | |
| def __init__(self): | |
| self.start_time = time.time() | |
| self.fields: dict[str, Any] = { | |
| "backup_started": "", | |
| "backup_completed": "", | |
| "backup_files": 0, | |
| "backup_size": 0, | |
| "backup_status": "pending", | |
| "commit_status": "pending", | |
| "commit_hash": "", | |
| "push_status": "pending", | |
| "deploy_status": "pending", | |
| "deploy_duration": "", | |
| "restore_status": "pending", | |
| "restore_files": 0, | |
| "verification_status": "pending", | |
| "errors": [], | |
| "warnings": [], | |
| } | |
| def end(self): | |
| self.fields["total_duration"] = _human_duration(time.time() - self.start_time) | |
| def set(self, key: str, value: Any): | |
| self.fields[key] = value | |
| def error(self, msg: str): | |
| self.fields["errors"].append(msg) | |
| def warn(self, msg: str): | |
| self.fields["warnings"].append(msg) | |
| def _row(self, label: str, status: str, detail: str) -> str: | |
| icon = _indicator(status) | |
| return f"{_TABLE_MV} {label:<28} {icon:<2} {detail:<30} {_TABLE_MV}" | |
| def _sep(self) -> str: | |
| return f"{_TABLE_ML}{_TABLE_MH * 28}{_TABLE_MM}{_TABLE_MH * 4}{_TABLE_MM}{_TABLE_MH * 32}{_TABLE_MR}" | |
| def print(self): | |
| self.end() | |
| f = self.fields | |
| err_count = len(f["errors"]) | |
| warn_count = len(f["warnings"]) | |
| start_dt = datetime.fromtimestamp(self.start_time).strftime("%c") | |
| lines: list[str] = [] | |
| lines.append("") | |
| lines.append( | |
| f"{_TABLE_TL}{_TABLE_H * 16}{_TABLE_H * 16}{_TABLE_H * 18}{_TABLE_H * 20}" | |
| f"{_TABLE_TR}" | |
| ) | |
| title = "DEPLOYMENT AUDIT REPORT" | |
| padding = (70 - len(title)) // 2 | |
| lines.append( | |
| f"{_TABLE_V} {' ' * padding}{_bold(title)}{' ' * (70 - padding - len(title))} {_TABLE_V}" | |
| ) | |
| lines.append( | |
| f"{_TABLE_V} {' ' * (70 - len(start_dt))}{start_dt} {_TABLE_V}" | |
| ) | |
| lines.append( | |
| f"{_TABLE_TM}{_TABLE_H * 70}{_TABLE_H * 0}" | |
| ) | |
| lines.append(self._row("Category", "", "Status | Detail")) | |
| lines.append( | |
| f"{_TABLE_TML}{_TABLE_MH * 70}" | |
| ) | |
| lines.append(self._row("", "", "")) | |
| # ── Backup section ── | |
| lines.append( | |
| self._row( | |
| "Backup Started", | |
| "passed" if f["backup_started"] else "failed", | |
| f["backup_started"] or "\u2014", | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Backup Completed", | |
| "passed" if f["backup_completed"] else "failed", | |
| f["backup_completed"] or "\u2014", | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Files Backed Up", | |
| "passed" if f["backup_files"] > 0 else "failed", | |
| str(f["backup_files"]), | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Total Backup Size", | |
| "passed" if f["backup_size"] > 0 else "failed", | |
| _human_size(f["backup_size"]), | |
| ) | |
| ) | |
| lines.append(self._sep()) | |
| # ── Deployment section ── | |
| lines.append( | |
| self._row( | |
| "Commit Status", | |
| f["commit_status"], | |
| f["commit_hash"] or "\u2014", | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Push Status", | |
| f["push_status"], | |
| _ok("Pushed") if f["push_status"] == "passed" else _fail("Failed"), | |
| ) | |
| ) | |
| dep_icon = "passed" if f["deploy_status"] == "passed" else "failed" | |
| lines.append( | |
| self._row( | |
| "HF Space Deployment", | |
| dep_icon, | |
| f["deploy_duration"] or "\u2014", | |
| ) | |
| ) | |
| lines.append(self._sep()) | |
| # ── Restore section ── | |
| lines.append( | |
| self._row( | |
| "Data Restoration", | |
| f["restore_status"], | |
| _ok("Restored") if f["restore_status"] == "passed" else _fail("Failed"), | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Files Restored", | |
| "passed" if f["restore_files"] > 0 else "failed", | |
| str(f["restore_files"]), | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Verification Status", | |
| f["verification_status"], | |
| _ok("Verified") if f["verification_status"] == "passed" else _fail("Failed"), | |
| ) | |
| ) | |
| lines.append(self._sep()) | |
| # ── Summary section ── | |
| lines.append( | |
| self._row( | |
| "Total Execution Time", | |
| "passed", | |
| f.get("total_duration", ""), | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Errors", | |
| "failed" if err_count > 0 else "passed", | |
| str(err_count), | |
| ) | |
| ) | |
| lines.append( | |
| self._row( | |
| "Warnings", | |
| "passed" if warn_count == 0 else "skipped", | |
| str(warn_count), | |
| ) | |
| ) | |
| lines.append( | |
| f"{_TABLE_BL}{_TABLE_H * 16}{_TABLE_H * 16}{_TABLE_H * 18}{_TABLE_H * 20}" | |
| f"{_TABLE_BR}" | |
| ) | |
| for line in lines: | |
| print(line) | |
| if err_count > 0: | |
| print(f"\n{_fail('Errors:')}") | |
| for e in f["errors"]: | |
| print(f" {_fail(_BULLET)} {e}") | |
| if warn_count > 0: | |
| print(f"\n{_warn('Warnings:')}") | |
| for w in f["warnings"]: | |
| print(f" {_warn(_BULLET)} {w}") | |
| def run_deployment(args: argparse.Namespace, env: dict[str, str]) -> int: | |
| audit = AuditReport() | |
| cwd = os.getcwd() | |
| persistence_dir = Path(cwd) / "persistence" | |
| persistence_dir.mkdir(parents=True, exist_ok=True) | |
| space_url = args.space_url or env.get("SPACE_URL", "https://aetherbase-llm-ready-data.hf.space") | |
| api_key = args.api_key or env.get("api_key", env.get("API_KEY", "")) | |
| hf_token = args.hf_token or env.get("hf_token", env.get("HF_TOKEN", "")) | |
| skip_backup = args.skip_backup | |
| if not api_key: | |
| print(f" {_fail('ERROR:')} No API key found. Set API_KEY in .env or pass --api-key.") | |
| return 1 | |
| if not hf_token: | |
| print(f" {_warn('WARNING:')} No HF token. Can't verify Space build status. Set HF_TOKEN in .env or pass --hf-token.") | |
| hf_token = None | |
| auth_headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "User-Agent": "deploy.py/1.0", | |
| } | |
| remote_url = git_remote_url(cwd) | |
| owner, space_name = _parse_hf_space_from_url(remote_url) if remote_url else ("aetherbase", "llm-ready-data") | |
| hf_api_headers = None | |
| if hf_token: | |
| hf_api_headers = { | |
| "Authorization": f"Bearer {hf_token}", | |
| "User-Agent": "deploy.py/1.0", | |
| } | |
| # ────────────────────────────────────────────── | |
| # PHASE 1: BACKUP (remote via API, fallback to local) | |
| # ────────────────────────────────────────────── | |
| print(f"\n{_bold('PHASE 1/4: Data Backup')}") | |
| print(f" {_INFO_ICON} Backing up data from: {space_url}") | |
| if skip_backup: | |
| print(f" {_WARN_ICON} Backup skipped (--skip-backup)") | |
| audit.set("backup_status", "skipped") | |
| else: | |
| backup_file = persistence_dir / BACKUP_ARCHIVE | |
| backup_url = _build_url(space_url, BACKUP_ENDPOINT) | |
| audit.set("backup_started", _timestamp()) | |
| backed_up = False | |
| # Try remote backup endpoint first | |
| try: | |
| status, total_bytes = http_get_stream(backup_url, auth_headers, backup_file, timeout=120) | |
| if status == 200: | |
| backed_up = True | |
| print(f" {_OK_ICON} Remote backup successful") | |
| except Exception as exc: | |
| print(f" {_WARN_ICON} Remote backup unavailable: {exc}") | |
| # Fall back to local backup | |
| if not backed_up: | |
| data_dir = Path(cwd) / "data" | |
| if data_dir.is_dir(): | |
| print(f" {_INFO_ICON} Falling back to local backup: {data_dir}") | |
| try: | |
| buf = io.BytesIO() | |
| with tarfile.open(fileobj=buf, mode="w:gz") as tar: | |
| for path in sorted(data_dir.rglob("*")): | |
| if path.is_file(): | |
| arcname = path.relative_to(data_dir.parent) | |
| tar.add(str(path), arcname=str(arcname)) | |
| buf.seek(0) | |
| backup_file.write_bytes(buf.read()) | |
| total_bytes = backup_file.stat().st_size | |
| file_count = 0 | |
| with tarfile.open(backup_file, "r:gz") as tar: | |
| file_count = sum(1 for m in tar.getmembers() if m.isfile()) | |
| backed_up = True | |
| print(f" {_OK_ICON} Local backup saved: {backup_file}") | |
| except Exception as exc: | |
| msg = f"Local backup failed: {exc}" | |
| audit.error(msg) | |
| print(f" {_FAIL_ICON} {msg}") | |
| audit.print() | |
| return 1 | |
| else: | |
| msg = f"No data found locally ({data_dir}) and remote backup unavailable" | |
| audit.warn(msg) | |
| print(f" {_WARN_ICON} {msg}") | |
| audit.set("backup_files", 0) | |
| audit.set("backup_size", 0) | |
| audit.set("backup_completed", _timestamp()) | |
| audit.set("backup_status", "skipped") | |
| if backed_up: | |
| file_count = 0 | |
| try: | |
| with tarfile.open(backup_file, "r:gz") as tar: | |
| file_count = sum(1 for m in tar.getmembers() if m.isfile()) | |
| except Exception: | |
| audit.warn("Could not count files in backup archive") | |
| file_count = backup_file.stat().st_size | |
| audit.set("backup_completed", _timestamp()) | |
| audit.set("backup_files", file_count) | |
| audit.set("backup_size", total_bytes) | |
| audit.set("backup_status", "passed") | |
| print(f" {_OK_ICON} Files: {file_count} | Size: {_human_size(total_bytes)}") | |
| # ────────────────────────────────────────────── | |
| # PHASE 2: GIT COMMIT & PUSH | |
| # ────────────────────────────────────────────── | |
| print(f"\n{_bold('PHASE 2/4: Git Commit & Push')}") | |
| changed = git_status(cwd) | |
| if not changed: | |
| print(f" {_WARN_ICON} No changes to commit (working tree clean)") | |
| audit.set("commit_status", "passed") | |
| audit.set("commit_hash", "no-change") | |
| audit.set("push_status", "passed") | |
| else: | |
| print(f" {_INFO_ICON} Staging {len(changed)} file(s)...") | |
| if not git_add_all(cwd): | |
| msg = "Git add failed" | |
| audit.error(msg) | |
| audit.set("commit_status", "failed") | |
| print(f" {_FAIL_ICON} {msg}") | |
| audit.print() | |
| return 1 | |
| commit_msg = args.message or f"deploy: auto-deploy {_timestamp()}" | |
| print(f" {_INFO_ICON} Committing: {commit_msg}") | |
| ok, sha = git_commit(cwd, commit_msg) | |
| if not ok: | |
| msg = f"Git commit failed: {sha}" | |
| audit.error(msg) | |
| audit.set("commit_status", "failed") | |
| print(f" {_FAIL_ICON} {msg}") | |
| audit.print() | |
| return 1 | |
| audit.set("commit_status", "passed") | |
| audit.set("commit_hash", sha) | |
| print(f" {_OK_ICON} Committed: {sha}") | |
| print(f" {_INFO_ICON} Pushing to origin/main...") | |
| ok, push_out = git_push(cwd) | |
| if not ok: | |
| msg = f"Git push failed" | |
| audit.error(msg) | |
| audit.set("push_status", "failed") | |
| print(f" {_FAIL_ICON} {msg}") | |
| print(f" {push_out}") | |
| audit.print() | |
| return 1 | |
| audit.set("push_status", "passed") | |
| print(f" {_OK_ICON} Push successful") | |
| # ────────────────────────────────────────────── | |
| # PHASE 3: WAIT FOR REDEPLOYMENT (via HF API) | |
| # ────────────────────────────────────────────── | |
| print(f"\n{_bold('PHASE 3/4: Waiting for HF Space Redeployment')}") | |
| api_url = f"{HF_API_SPACES}/{owner}/{space_name}" | |
| deploy_start = time.time() | |
| max_wait = args.timeout | |
| poll_interval = 10 | |
| waited = 0 | |
| deployed = False | |
| last_error = "" | |
| seen_building = False | |
| print(f" {_INFO_ICON} Tracking {owner}/{space_name} via HF API...") | |
| if hf_api_headers: | |
| initial = http_get_json(api_url, hf_api_headers, timeout=15) or {} | |
| prev_stage = initial.get("runtime", {}).get("stage", "") | |
| while waited < max_wait: | |
| data = http_get_json(api_url, hf_api_headers, timeout=15) | |
| stage = (data or {}).get("runtime", {}).get("stage", "") | |
| if stage == "BUILDING": | |
| if not seen_building: | |
| print(f" {_INFO_ICON} Build started (stage: BUILDING)") | |
| seen_building = True | |
| elif stage == "RUNNING": | |
| if seen_building: | |
| print(f" {_OK_ICON} Build complete (stage: RUNNING)") | |
| deployed = True | |
| break | |
| if waited > 30 and prev_stage == "RUNNING": | |
| print(f" {_OK_ICON} Space is running (may have skipped BUILDING stage)") | |
| deployed = True | |
| break | |
| elif stage in ("PAUSED", "STOPPED", "NO_APP"): | |
| last_error = f"Space in unexpected state: {stage}" | |
| deployed = False | |
| break | |
| prev_stage = stage | |
| time.sleep(poll_interval) | |
| waited += poll_interval | |
| if waited % 30 == 0 and not seen_building: | |
| print(f" {_INFO_ICON} Still waiting for build to start... ({waited}s)") | |
| elif waited % 30 == 0 and seen_building: | |
| print(f" {_INFO_ICON} Still building... ({waited}s)") | |
| deploy_duration = time.time() - deploy_start | |
| if not hf_api_headers: | |
| print(f" {_WARN_ICON} No HF token — assuming deployment is underway") | |
| print(f" {_INFO_ICON} Waiting {max_wait}s for build + startup...") | |
| time.sleep(max_wait) | |
| deployed = True | |
| if deployed: | |
| health_url = _build_url(space_url, HEALTH_ENDPOINT) | |
| try: | |
| req = urllib.request.Request(health_url, method="GET") | |
| with urllib.request.urlopen(req, timeout=15) as resp: | |
| if resp.status == 200: | |
| print(f" {_OK_ICON} App health check passed (new instance serving)") | |
| except Exception: | |
| print(f" {_WARN_ICON} App health check unavailable (may still be starting)") | |
| audit.set("deploy_status", "passed") | |
| audit.set("deploy_duration", _human_duration(deploy_duration)) | |
| print(f" {_OK_ICON} Space redeployed in {_human_duration(deploy_duration)}") | |
| else: | |
| audit.set("deploy_status", "failed") | |
| audit.set("deploy_duration", _human_duration(deploy_duration)) | |
| msg = f"Space did not redeploy within {max_wait}s (last stage: {stage}, error: {last_error})" | |
| audit.error(msg) | |
| print(f" {_FAIL_ICON} {msg}") | |
| audit.print() | |
| return 1 | |
| # ────────────────────────────────────────────── | |
| # PHASE 4: DATA RESTORE | |
| # ────────────────────────────────────────────── | |
| print(f"\n{_bold('PHASE 4/4: Data Restoration')}") | |
| if skip_backup: | |
| print(f" {_WARN_ICON} Restore skipped (no backup)") | |
| audit.set("restore_status", "skipped") | |
| audit.set("verification_status", "skipped") | |
| audit.set("restore_files", 0) | |
| else: | |
| backup_file = persistence_dir / BACKUP_ARCHIVE | |
| if not backup_file.is_file(): | |
| msg = f"Backup file not found: {backup_file}" | |
| audit.error(msg) | |
| audit.set("restore_status", "failed") | |
| print(f" {_FAIL_ICON} {msg}") | |
| audit.print() | |
| return 1 | |
| restore_url = _build_url(space_url, RESTORE_ENDPOINT) | |
| print(f" {_INFO_ICON} Restoring data to: {space_url}") | |
| try: | |
| status, body = http_post_multipart( | |
| restore_url, backup_file, "file", auth_headers, timeout=300 | |
| ) | |
| if status == 200: | |
| try: | |
| result = json.loads(body) | |
| restored = result.get("files_restored", 0) | |
| except Exception: | |
| restored = 0 | |
| audit.set("restore_files", max(restored, 1)) | |
| audit.set("restore_status", "passed") | |
| print(f" {_OK_ICON} Data restored: {restored} files") | |
| # Verification | |
| print(f" {_INFO_ICON} Verifying data restoration...") | |
| try: | |
| verify_backup = persistence_dir / "verify_check.tar.gz" | |
| status_v, _ = http_get_stream( | |
| _build_url(space_url, BACKUP_ENDPOINT), | |
| auth_headers, | |
| verify_backup, | |
| timeout=120, | |
| ) | |
| if status_v == 200: | |
| with tarfile.open(verify_backup, "r:gz") as tar: | |
| verify_files = sum(1 for m in tar.getmembers() if m.isfile()) | |
| verify_backup.unlink(missing_ok=True) | |
| if verify_files >= audit.fields.get("backup_files", 0) * 0.9: | |
| audit.set("verification_status", "passed") | |
| print(f" {_OK_ICON} Verification passed: {verify_files} files found") | |
| else: | |
| audit.set("verification_status", "failed") | |
| audit.warn( | |
| f"Verification file count ({verify_files}) mismatch with backup ({audit.fields.get('backup_files', 0)})" | |
| ) | |
| print(f" {_WARN_ICON} Verification: file count mismatch ({verify_files} vs {audit.fields.get('backup_files', 0)})") | |
| else: | |
| audit.set("verification_status", "failed") | |
| audit.warn(f"Verification request returned HTTP {status_v}") | |
| print(f" {_WARN_ICON} Verification failed (HTTP {status_v})") | |
| except Exception as exc: | |
| audit.set("verification_status", "failed") | |
| audit.warn(f"Verification error: {exc}") | |
| print(f" {_WARN_ICON} Verification error: {exc}") | |
| else: | |
| audit.set("restore_status", "failed") | |
| msg = f"Restore returned HTTP {status}: {body[:200]}" | |
| audit.error(msg) | |
| print(f" {_FAIL_ICON} {msg}") | |
| audit.print() | |
| return 1 | |
| except Exception as exc: | |
| audit.set("restore_status", "failed") | |
| msg = f"Restore failed: {exc}" | |
| audit.error(msg) | |
| print(f" {_FAIL_ICON} {msg}") | |
| audit.print() | |
| return 1 | |
| # ────────────────────────────────────────────── | |
| # SUMMARY | |
| # ────────────────────────────────────────────── | |
| print(f"\n{_bold('=' * 70)}") | |
| audit.print() | |
| has_errors = len(audit.fields["errors"]) > 0 | |
| if has_errors: | |
| print(f"\n{_fail('Deployment completed with errors.')}") | |
| return 1 | |
| else: | |
| print(f"\n{_ok('Deployment completed successfully.')}") | |
| return 0 | |
| # ───────────────────────────────────────────────────────────── | |
| # CLI entrypoint | |
| # ───────────────────────────────────────────────────────────── | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Deploy to Hugging Face Spaces with persistent data handling.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=( | |
| "Environment variables (from .env or process env):\n" | |
| " API_KEY API key for the Hugging Face Space\n" | |
| " SPACE_URL Base URL of the deployed Space\n" | |
| " HF_TOKEN Hugging Face API token (for build tracking)\n" | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--space-url", | |
| default="", | |
| help="Base URL of the Hugging Face Space (e.g. https://aetherbase-llm-ready-data.hf.space)", | |
| ) | |
| parser.add_argument( | |
| "--api-key", | |
| default="", | |
| help="API key for authenticating with the Space", | |
| ) | |
| parser.add_argument( | |
| "--message", "-m", | |
| default="", | |
| help="Git commit message", | |
| ) | |
| parser.add_argument( | |
| "--timeout", | |
| type=int, | |
| default=300, | |
| help="Maximum wait time (seconds) for Space redeployment (default: 300)", | |
| ) | |
| parser.add_argument( | |
| "--hf-token", | |
| default="", | |
| help="Hugging Face API token (for tracking Space build status)", | |
| ) | |
| parser.add_argument( | |
| "--skip-backup", | |
| action="store_true", | |
| help="Skip the backup and restore phases", | |
| ) | |
| parser.add_argument( | |
| "--env-file", | |
| default=".env", | |
| help="Path to .env file (default: .env)", | |
| ) | |
| args = parser.parse_args() | |
| env = load_env(args.env_file) | |
| # Merge with process environment (process env takes precedence) | |
| for key in ("API_KEY", "SPACE_URL", "HF_TOKEN"): | |
| if os.environ.get(key): | |
| env[key] = os.environ[key] | |
| try: | |
| rc = run_deployment(args, env) | |
| except KeyboardInterrupt: | |
| print(f"\n{_warn('Deployment interrupted by user.')}") | |
| rc = 130 | |
| except Exception as exc: | |
| print(f"\n{_fail('Unexpected error: ' + str(exc))}") | |
| rc = 1 | |
| sys.exit(rc) | |
| if __name__ == "__main__": | |
| main() | |