File size: 6,435 Bytes
79eb867
 
 
 
 
 
 
 
 
 
 
4466efd
 
 
 
 
 
 
79eb867
 
4466efd
79eb867
 
 
 
 
 
 
 
 
 
 
4466efd
 
79eb867
 
4466efd
 
 
 
 
 
 
 
 
 
 
 
 
79eb867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4466efd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79eb867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4466efd
79eb867
 
 
4466efd
 
 
 
 
 
 
 
 
 
 
 
 
79eb867
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Back up Open WebUI user data to a Hugging Face dataset repo.

Covers everything a user would miss after a volume wipe:
  * webui.db        - users, logins, settings, chats (consistent SQLite snapshot)
  * vector_db/      - RAG embeddings for uploaded documents
  * uploads/        - user-uploaded files

Deliberately EXCLUDES /data/cache (the embedding-model cache, ~1 GB, regenerates
itself on boot).

Two copies go up per run: the rolling latest at backups/open-webui-data.tar.gz
(what restore.py reads on boot) and a dated snapshot
backups/daily/open-webui-data-<YYYY-MM-DD>.tar.gz. The dated snapshots are what
make a bad wipe survivable - the rolling file is overwritten every hour, so a
corrupted dataset gets baked into it within one interval. The newest
OWUI_BACKUP_DAILY_KEEP (default 7) dated snapshots are kept, older ones deleted.

Requires the HF_TOKEN env var (Space secret). Never fails the app.
"""
import datetime
import gzip
import os
import shutil
import sqlite3
import sys
import tarfile
import tempfile

DATA_DIR = os.environ.get("OWUI_DATA_DIR", "/data")
REPO_ID = os.environ.get("OWUI_BACKUP_REPO", "tkgray/open-webui-backups")
ARCHIVE_IN_REPO = "backups/open-webui-data.tar.gz"
DAILY_DIR = "backups/daily"
DAILY_PREFIX = "open-webui-data-"
INCLUDE_DIRS = ("vector_db", "uploads")


def env_int(name, default):
    raw = os.environ.get(name)
    if raw is None:
        return default
    try:
        return int(raw)
    except (TypeError, ValueError):
        return default


DAILY_KEEP = env_int("OWUI_BACKUP_DAILY_KEEP", 7)

DB_PATH = os.path.join(DATA_DIR, "webui.db")


def log(msg):
    print(f"[backup] {msg}", flush=True)


def snapshot_sqlite(src, dst):
    """Write a consistent copy of a live SQLite DB using the backup API.

    Copying the file directly (or tarring it) can capture a torn WAL, so use
    sqlite3's own online-backup, which is safe while the app is writing.
    """
    source = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
    try:
        dest = sqlite3.connect(dst)
        try:
            with dest:
                source.backup(dest)
        finally:
            dest.close()
    finally:
        source.close()


def build_archive(payload_dir, archive_path):
    """Deterministically tar.gz a staged payload.

    Every member's mtime/uid/gid and the gzip header mtime are zeroed and the
    member order is fixed, so an unchanged payload yields byte-identical output.
    That lets the dataset host recognise a no-op upload and skip the commit,
    instead of creating a fresh commit every hour.
    """
    entries = []
    for root, _dirs, files in os.walk(payload_dir):
        for name in files:
            full = os.path.join(root, name)
            entries.append((os.path.relpath(full, payload_dir), full))
    entries.sort()

    with open(archive_path, "wb") as raw:
        # filename="" suppresses the gzip FNAME header, which would otherwise
        # carry the temp archive path and make every run's bytes differ.
        with gzip.GzipFile(filename="", fileobj=raw, mode="wb", mtime=0) as gz:
            with tarfile.open(fileobj=gz, mode="w") as tf:
                for rel, full in entries:
                    info = tf.gettarinfo(full, arcname=rel)
                    info.mtime = 0
                    info.uid = 0
                    info.gid = 0
                    info.uname = ""
                    info.gname = ""
                    with open(full, "rb") as fh:
                        tf.addfile(info, fh)
    return archive_path


def prune_daily(api, token):
    """Delete dated snapshots beyond the newest DAILY_KEEP.

    Filenames are ISO-dated, so a plain sort is chronological. Only ever
    deletes under DAILY_DIR, so the rolling archive and the legacy single-file
    backup are untouchable by this path.
    """
    try:
        files = api.list_repo_files(REPO_ID, repo_type="dataset", token=token)
    except Exception as exc:  # noqa: BLE001 - a prune failure must not fail the run
        log(f"prune skipped, listing failed: {exc}")
        return

    dated = sorted(
        f for f in files
        if f.startswith(f"{DAILY_DIR}/{DAILY_PREFIX}") and f.endswith(".tar.gz")
    )
    stale = dated[:-DAILY_KEEP] if DAILY_KEEP > 0 else dated
    if not stale:
        log(f"prune: {len(dated)} dated snapshot(s) on disk, limit {DAILY_KEEP} - nothing to delete")
        return

    from huggingface_hub import CommitOperationDelete, create_commit

    create_commit(
        repo_id=REPO_ID,
        repo_type="dataset",
        operations=[CommitOperationDelete(path_in_repo=path) for path in stale],
        commit_message=f"prune {len(stale)} snapshot(s) beyond {DAILY_KEEP}-day retention",
        token=token,
    )
    log(f"prune: deleted {', '.join(stale)}")


def main():
    if not os.path.isfile(DB_PATH):
        log(f"{DB_PATH} does not exist yet — skipping.")
        return 0

    token = os.environ.get("HF_TOKEN")
    if not token:
        log("HF_TOKEN not set — skipping.")
        return 0

    stage = tempfile.mkdtemp()
    payload = os.path.join(stage, "data")
    os.makedirs(payload, exist_ok=True)
    try:
        snapshot_sqlite(DB_PATH, os.path.join(payload, "webui.db"))
        for name in INCLUDE_DIRS:
            src = os.path.join(DATA_DIR, name)
            if os.path.isdir(src):
                shutil.copytree(src, os.path.join(payload, name), symlinks=True)

        archive = os.path.join(stage, "open-webui-data.tar.gz")
        build_archive(payload, archive)
        log(f"staged {os.path.getsize(archive)} bytes; uploading to {REPO_ID} ...")

        from huggingface_hub import HfApi

        api = HfApi(token=token)
        dated_in_repo = f"{DAILY_DIR}/{DAILY_PREFIX}{datetime.date.today().isoformat()}.tar.gz"
        for path_in_repo in (ARCHIVE_IN_REPO, dated_in_repo):
            api.upload_file(
                path_or_fileobj=archive,
                path_in_repo=path_in_repo,
                repo_id=REPO_ID,
                repo_type="dataset",
                token=token,
            )
            log(f"Upload OK -> {path_in_repo}")

        prune_daily(api, token)
        return 0
    except Exception as exc:  # noqa: BLE001 - never fail the app over a backup
        log(f"Upload FAILED: {exc}")
        return 1
    finally:
        shutil.rmtree(stage, ignore_errors=True)


if __name__ == "__main__":
    sys.exit(main())