File size: 7,058 Bytes
0790cf1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
#!/usr/bin/env python3
"""
HermesFace Full Directory Persistence for Hugging Face Spaces
=============================================================

Tar-gz snapshot of /opt/data with atomic upload, rotation, and integrity check.

Usage:
    python3 hermes_persist.py save     # backup
    python3 hermes_persist.py load     # restore latest
    python3 hermes_persist.py status   # show current backups

Env vars:
    HF_TOKEN              HF access token with write permission
    HERMES_DATASET_REPO   Target dataset repo (e.g. username/HermesFace-data)
    HERMES_HOME           Source directory (default: /opt/data)
    MAX_BACKUPS           Rotation size (default: 5)
"""
import hashlib
import json
import os
import shutil
import sys
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional

from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError


class Config:
    HERMES_HOME = Path(os.environ.get("HERMES_HOME", "/opt/data"))
    DATASET_REPO = os.environ.get("HERMES_DATASET_REPO", "")
    HF_TOKEN = os.environ.get("HF_TOKEN", "")
    MAX_BACKUPS = int(os.environ.get("MAX_BACKUPS", "5"))
    BACKUP_PREFIX = "backup-"

    EXCLUDE_SUFFIXES = (".lock", ".tmp", ".pyc", ".socket", ".pid")
    EXCLUDE_NAMES = {"__pycache__", "node_modules", ".DS_Store", ".git"}
    SKIP_DIRS = {".cache", "logs/sys_logs"}


def _log(level: str, msg: str, **kv: object) -> None:
    payload = {"timestamp": datetime.now().isoformat(), "level": level,
               "module": "hermes-persist", "message": msg, **kv}
    print(json.dumps(payload))


def _tar_filter(info: tarfile.TarInfo) -> Optional[tarfile.TarInfo]:
    name = info.name
    if any(name.endswith(suf) for suf in Config.EXCLUDE_SUFFIXES):
        return None
    parts = set(Path(name).parts)
    if parts & Config.EXCLUDE_NAMES:
        return None
    rel = name.lstrip("./")
    for skip in Config.SKIP_DIRS:
        if rel == skip or rel.startswith(skip + "/"):
            return None
    return info


def _api() -> HfApi:
    if not Config.HF_TOKEN:
        raise RuntimeError("HF_TOKEN not set")
    if not Config.DATASET_REPO:
        raise RuntimeError("HERMES_DATASET_REPO not set")
    return HfApi(token=Config.HF_TOKEN)


def _ensure_repo(api: HfApi) -> None:
    try:
        api.repo_info(repo_id=Config.DATASET_REPO, repo_type="dataset")
    except RepositoryNotFoundError:
        _log("INFO", "creating_dataset", repo=Config.DATASET_REPO)
        api.create_repo(repo_id=Config.DATASET_REPO, repo_type="dataset", private=True)


def save() -> int:
    if not Config.HERMES_HOME.exists():
        _log("ERROR", "no_source_dir", path=str(Config.HERMES_HOME))
        return 1

    api = _api()
    _ensure_repo(api)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    archive_name = f"state/{Config.BACKUP_PREFIX}{timestamp}.tar.gz"

    with tempfile.TemporaryDirectory() as tmp:
        tar_path = Path(tmp) / "hermes.tar.gz"
        with tarfile.open(tar_path, "w:gz") as tf:
            tf.add(str(Config.HERMES_HOME), arcname=".", filter=_tar_filter)

        size = tar_path.stat().st_size
        sha256 = hashlib.sha256(tar_path.read_bytes()).hexdigest()
        _log("INFO", "uploading_backup", file=archive_name, bytes=size, sha256=sha256[:12])

        api.upload_file(
            path_or_fileobj=str(tar_path),
            path_in_repo=archive_name,
            repo_id=Config.DATASET_REPO,
            repo_type="dataset",
            commit_message=f"HermesFace backup {timestamp}",
        )

    try:
        files = api.list_repo_files(repo_id=Config.DATASET_REPO, repo_type="dataset")
        backups = sorted(
            f for f in files
            if f.startswith(f"state/{Config.BACKUP_PREFIX}") and f.endswith(".tar.gz")
        )
        if len(backups) > Config.MAX_BACKUPS:
            for old in backups[: -Config.MAX_BACKUPS]:
                _log("INFO", "rotating_backup", delete=old)
                api.delete_file(
                    path_in_repo=old,
                    repo_id=Config.DATASET_REPO,
                    repo_type="dataset",
                    token=Config.HF_TOKEN,
                )
    except Exception as e:
        _log("WARNING", "rotation_failed", error=str(e))

    _log("INFO", "save_completed", file=archive_name)
    return 0


def load() -> int:
    api = _api()
    try:
        files = api.list_repo_files(repo_id=Config.DATASET_REPO, repo_type="dataset")
    except RepositoryNotFoundError:
        _log("ERROR", "repo_not_found", repo=Config.DATASET_REPO)
        return 1

    backups = sorted(
        (f for f in files
         if f.startswith(f"state/{Config.BACKUP_PREFIX}") and f.endswith(".tar.gz")),
        reverse=True,
    )
    if not backups:
        _log("WARNING", "no_backups_found")
        return 1

    Config.HERMES_HOME.mkdir(parents=True, exist_ok=True)

    for backup in backups:
        _log("INFO", "attempting_restore", file=backup)
        try:
            local = hf_hub_download(
                repo_id=Config.DATASET_REPO,
                repo_type="dataset",
                filename=backup,
                token=Config.HF_TOKEN,
            )
            # Local safety backup
            snapshot = Config.HERMES_HOME.parent / f"hermes_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
            if Config.HERMES_HOME.exists() and any(Config.HERMES_HOME.iterdir()):
                shutil.copytree(Config.HERMES_HOME, snapshot, dirs_exist_ok=True)
            with tarfile.open(local, "r:*") as tf:
                tf.extractall(str(Config.HERMES_HOME))
            _log("INFO", "restore_completed", file=backup)
            return 0
        except Exception as e:
            _log("ERROR", "restore_failed", file=backup, error=str(e))
            continue

    _log("ERROR", "all_restore_attempts_failed")
    return 1


def status() -> int:
    api = _api()
    try:
        files = api.list_repo_files(repo_id=Config.DATASET_REPO, repo_type="dataset")
    except RepositoryNotFoundError:
        _log("WARNING", "repo_not_found", repo=Config.DATASET_REPO)
        return 0

    backups = sorted(
        (f for f in files
         if f.startswith(f"state/{Config.BACKUP_PREFIX}") and f.endswith(".tar.gz")),
        reverse=True,
    )
    local_files = 0
    if Config.HERMES_HOME.exists():
        local_files = sum(1 for _ in Config.HERMES_HOME.rglob("*") if _.is_file())

    print(json.dumps({
        "repo": Config.DATASET_REPO,
        "local_dir": str(Config.HERMES_HOME),
        "local_files": local_files,
        "remote_backups": backups,
        "max_backups": Config.MAX_BACKUPS,
    }, indent=2))
    return 0


COMMANDS = {"save": save, "load": load, "status": status}


def main() -> None:
    if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
        print(f"Usage: {sys.argv[0]} {{{'|'.join(COMMANDS)}}}", file=sys.stderr)
        sys.exit(2)
    sys.exit(COMMANDS[sys.argv[1]]())


if __name__ == "__main__":
    main()