Open-WebUI / backup.py
tkgray
backup: keep dated daily snapshots alongside the rolling archive
4466efd
Raw History Blame Contribute Delete
6.44 kB
#!/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())