amangs commited on
Commit
64de9c5
·
verified ·
1 Parent(s): 86655ea

Deploy FreeLLMAPI with HF dataset backup integration from aapii/api

Browse files
Files changed (4) hide show
  1. Dockerfile +44 -20
  2. README.md +6 -6
  3. backup_manager.py +294 -0
  4. entrypoint.sh +29 -0
Dockerfile CHANGED
@@ -1,28 +1,52 @@
1
- FROM ghcr.io/open-webui/open-webui:main
2
 
3
- USER root
 
 
 
 
 
 
 
 
4
 
5
- # Hugging Face Space wiring
6
- ENV PORT=7860
7
- ENV UVICORN_WORKERS=1
 
 
 
 
 
 
 
8
 
9
- # Writable storage
10
- ENV DATA_DIR=/data
11
- ENV HF_HOME=/data/.cache
12
- #ENV DATABASE_URL=sqlite:///${DATA_DIR}/webui.db
13
- ENV DATABASE_URL=$DATABASE_URL
14
- ENV GLOBAL_LOG_LEVEL=ERROR
15
- ENV ENABLE_OLLAMA=false
16
 
 
 
 
 
17
 
18
- # Create & symlink
19
- RUN mkdir -p /data /data/.cache && \
20
- chmod -R 777 /data && \
21
- rm -rf /app/backend/data && \
22
- ln -s /data /app/backend/data
23
 
24
  EXPOSE 7860
25
 
26
- # Tell uvicorn to listen on $PORT (7860) inside the container
27
- # CMD ["uvicorn", "open_webui.main:app", "--host", "0.0.0.0", "--port", "7860"]
28
- CMD ["uvicorn", "open_webui.main:app", "--host", "0.0.0.0", "--port", "7860", "--reload", "--log-level", "error"]
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:26.3.0-alpine
2
 
3
+ # 安装 Python3、构建工具、sqlite3 CLI,以及 py3-virtualenv(用于创建 venv)
4
+ RUN apk add --no-cache \
5
+ git \
6
+ python3 \
7
+ py3-pip \
8
+ py3-virtualenv \
9
+ make \
10
+ g++ \
11
+ sqlite
12
 
13
+ WORKDIR /app
14
+
15
+ # 克隆 FreeLLMAPI
16
+ ARG FREELLMAPI_VERSION="v0.2.8"
17
+ RUN apk add --no-cache curl && \
18
+ curl -L "https://github.com/tashfeenahmed/freellmapi/archive/refs/tags/${FREELLMAPI_VERSION}.tar.gz" | \
19
+ tar xz --strip-components=1
20
+
21
+ # 安装 Node 依赖并构建
22
+ RUN npm install && npm run build
23
 
24
+ # 确保数据目录存在
25
+ RUN mkdir -p /app/server/data
 
 
 
 
 
26
 
27
+ # 创建 Python 虚拟环境,并安装 huggingface-hub
28
+ RUN python3 -m venv /opt/venv && \
29
+ /opt/venv/bin/pip install --upgrade pip && \
30
+ /opt/venv/bin/pip install huggingface-hub
31
 
32
+ # 复制备份系统
33
+ COPY backup_manager.py /app/backup_manager.py
34
+ COPY entrypoint.sh /entrypoint.sh
35
+ RUN chmod +x /entrypoint.sh
 
36
 
37
  EXPOSE 7860
38
 
39
+ # 默认环境变量
40
+ ENV PORT=7860
41
+ ENV NODE_ENV=production
42
+ # 改为 freeapi.db
43
+ ENV DB_PATH=/app/server/data/freeapi.db
44
+ ENV BACKUP_MODE=scheduled
45
+ ENV BACKUP_INTERVAL=300
46
+ ENV REALTIME_CHECK_INTERVAL=30
47
+ ENV MIN_BACKUP_INTERVAL=60
48
+ ENV KEEP_BACKUPS=10
49
+ # 将 venv 加入 PATH,让 entrypoint.sh 可以直接用 python3/pip
50
+ ENV PATH="/opt/venv/bin:$PATH"
51
+
52
+ ENTRYPOINT ["/entrypoint.sh"]
README.md CHANGED
@@ -1,11 +1,11 @@
1
  ---
2
- title: OpenWebUI
3
- emoji: ⚡
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
8
- app_port: 7860
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: Api
3
+ emoji: 📈
4
+ colorFrom: green
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ license: apache-2.0
9
  ---
10
 
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
backup_manager.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import time
5
+ import hashlib
6
+ import subprocess
7
+ import tempfile
8
+ from datetime import datetime
9
+ from huggingface_hub import HfApi, hf_hub_download
10
+ from huggingface_hub.utils import EntryNotFoundError
11
+
12
+ # ==================== 环境变量配置 ====================
13
+ REPO_ID = os.environ.get("HF_DATASET_REPO")
14
+ TOKEN = os.environ.get("HF_TOKEN")
15
+ DB_PATH = os.environ.get("DB_PATH", "/app/server/data/freeapi.db")
16
+ BACKUP_MODE = os.environ.get("BACKUP_MODE", "scheduled").lower()
17
+ BACKUP_INTERVAL = int(os.environ.get("BACKUP_INTERVAL", "300"))
18
+ CHECK_INTERVAL = int(os.environ.get("REALTIME_CHECK_INTERVAL", "30"))
19
+ MIN_INTERVAL = int(os.environ.get("MIN_BACKUP_INTERVAL", "60"))
20
+ KEEP_BACKUPS = int(os.environ.get("KEEP_BACKUPS", "10"))
21
+
22
+ DB_FILENAME = os.path.basename(DB_PATH) # "freeapi.db"
23
+
24
+ api = HfApi(token=TOKEN) if TOKEN else None
25
+
26
+
27
+ def log(msg: str):
28
+ print(f"[BackupManager] {msg}", flush=True)
29
+
30
+
31
+ def find_db_file():
32
+ """
33
+ 探测实际的数据库文件位置。
34
+ """
35
+ candidates = [
36
+ DB_PATH,
37
+ f"/app/server/data/{DB_FILENAME}",
38
+ f"/app/data/{DB_FILENAME}",
39
+ f"/app/server/dist/data/{DB_FILENAME}",
40
+ os.path.join(os.getcwd(), "server", "data", DB_FILENAME),
41
+ os.path.join(os.getcwd(), "data", DB_FILENAME),
42
+ os.path.join(os.getcwd(), DB_FILENAME),
43
+ ]
44
+
45
+ for path in candidates:
46
+ if path and os.path.isfile(path):
47
+ log(f"Found database at: {path}")
48
+ return path
49
+
50
+ # 目录扫描
51
+ for root_dir in ["/app", "/app/server", os.getcwd()]:
52
+ if os.path.isdir(root_dir):
53
+ for root, dirs, files in os.walk(root_dir):
54
+ if DB_FILENAME in files:
55
+ path = os.path.join(root, DB_FILENAME)
56
+ log(f"Found database by scan at: {path}")
57
+ return path
58
+
59
+ return None
60
+
61
+
62
+ def get_file_stat(path: str):
63
+ if not os.path.exists(path):
64
+ return None
65
+ st = os.stat(path)
66
+ return (st.st_size, st.st_mtime)
67
+
68
+
69
+ def create_consistent_snapshot(src: str, dst: str) -> bool:
70
+ """
71
+ 使用 sqlite3 .backup 创建一致性快照,避免并发冲突。
72
+ 正确语法: .backup <dst> (不需要 to 关键字)
73
+ """
74
+ try:
75
+ result = subprocess.run(
76
+ ["sqlite3", src, f".backup {dst}"],
77
+ capture_output=True, text=True, timeout=30, check=True
78
+ )
79
+ if result.returncode == 0:
80
+ return True
81
+ except FileNotFoundError:
82
+ log("sqlite3 CLI not found, falling back to file copy")
83
+ except Exception as e:
84
+ log(f"sqlite3 .backup warning: {e}, falling back to file copy")
85
+
86
+ try:
87
+ import shutil
88
+ shutil.copy2(src, dst)
89
+ return True
90
+ except Exception as e:
91
+ log(f"File copy failed: {e}")
92
+ return False
93
+
94
+
95
+ def restore() -> bool:
96
+ """
97
+ 从 Dataset 恢复数据库:
98
+ 1. 列出所有历史备份 freeapi.db.YYYYMMDD_HHMMSS
99
+ 2. 按时间戳排序,取最新
100
+ 3. 下载并重命名为 freeapi.db
101
+ """
102
+ if not REPO_ID or not TOKEN:
103
+ log("HF_DATASET_REPO or HF_TOKEN not set, skip restore")
104
+ return False
105
+
106
+ data_dir = os.path.dirname(DB_PATH)
107
+ os.makedirs(data_dir, exist_ok=True)
108
+
109
+ try:
110
+ files = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset")
111
+ backups = [f for f in files if f.startswith(f"{DB_FILENAME}.") and len(f) > len(DB_FILENAME)]
112
+
113
+ if not backups:
114
+ log("No existing backup found in dataset, starting fresh")
115
+ return False
116
+
117
+ backups.sort()
118
+ latest_backup = backups[-1]
119
+ log(f"Found latest backup: {latest_backup}")
120
+
121
+ # 下载到 data_dir
122
+ downloaded = hf_hub_download(
123
+ repo_id=REPO_ID,
124
+ filename=latest_backup,
125
+ repo_type="dataset",
126
+ local_dir=data_dir,
127
+ token=TOKEN,
128
+ local_dir_use_symlinks=False
129
+ )
130
+
131
+ # 原子替换为 freeapi.db
132
+ target = os.path.join(data_dir, DB_FILENAME)
133
+ os.replace(downloaded, target)
134
+ log(f"Restored database from {latest_backup} to {target}")
135
+ return True
136
+
137
+ except Exception as e:
138
+ log(f"Restore failed: {e}")
139
+ return False
140
+
141
+
142
+ def upload_file(local_path: str, remote_name: str) -> bool:
143
+ if not api or not REPO_ID:
144
+ return False
145
+ try:
146
+ api.upload_file(
147
+ path_or_fileobj=local_path,
148
+ path_in_repo=remote_name,
149
+ repo_id=REPO_ID,
150
+ repo_type="dataset",
151
+ commit_message=f"Backup {remote_name} @ {datetime.now().isoformat()}"
152
+ )
153
+ return True
154
+ except Exception as e:
155
+ log(f"Upload failed for {remote_name}: {e}")
156
+ return False
157
+
158
+
159
+ def cleanup_old_backups():
160
+ """
161
+ 清理旧备份:
162
+ - 删除 legacy 主文件 freeapi.db(我们不再使用它)
163
+ - 保留最新��� KEEP_BACKUPS 个历史版本
164
+ """
165
+ if not api or not REPO_ID or KEEP_BACKUPS <= 0:
166
+ return
167
+
168
+ try:
169
+ files = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset")
170
+
171
+ # 删除旧的主文件(如果存在)
172
+ if DB_FILENAME in files:
173
+ try:
174
+ api.delete_file(
175
+ path_in_repo=DB_FILENAME,
176
+ repo_id=REPO_ID,
177
+ repo_type="dataset",
178
+ commit_message="Remove legacy main db file"
179
+ )
180
+ log(f"Removed legacy main file: {DB_FILENAME}")
181
+ except Exception as e:
182
+ log(f"Failed to remove legacy file: {e}")
183
+
184
+ # 清理历史版本
185
+ backups = [f for f in files if f.startswith(f"{DB_FILENAME}.") and len(f) > len(DB_FILENAME)]
186
+ backups.sort()
187
+
188
+ if len(backups) <= KEEP_BACKUPS:
189
+ return
190
+
191
+ to_delete = backups[:-KEEP_BACKUPS]
192
+ for f in to_delete:
193
+ try:
194
+ api.delete_file(
195
+ path_in_repo=f,
196
+ repo_id=REPO_ID,
197
+ repo_type="dataset",
198
+ commit_message=f"Auto cleanup: remove {f}"
199
+ )
200
+ log(f"Cleaned up old backup: {f}")
201
+ except Exception as e:
202
+ log(f"Failed to delete {f}: {e}")
203
+ except Exception as e:
204
+ log(f"Cleanup scan failed: {e}")
205
+
206
+
207
+ def do_backup() -> bool:
208
+ """
209
+ 执行备份:只上传带时间戳的历史版本,不再重复上传 freeapi.db
210
+ """
211
+ actual_db = find_db_file()
212
+ if not actual_db:
213
+ log("Database file not found, skip backup")
214
+ return False
215
+
216
+ # 创建一致性快照
217
+ fd, snapshot = tempfile.mkstemp(suffix=".db", prefix="freellmapi_")
218
+ os.close(fd)
219
+
220
+ try:
221
+ if not create_consistent_snapshot(actual_db, snapshot):
222
+ return False
223
+
224
+ # 生成时间戳文件名,直接上传
225
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
226
+ hist_name = f"{DB_FILENAME}.{ts}"
227
+
228
+ if upload_file(snapshot, hist_name):
229
+ log(f"Uploaded backup: {hist_name}")
230
+ cleanup_old_backups()
231
+ return True
232
+ return False
233
+
234
+ finally:
235
+ try:
236
+ os.unlink(snapshot)
237
+ except Exception:
238
+ pass
239
+
240
+
241
+ def scheduled_daemon():
242
+ log(f"Scheduled mode started (interval: {BACKUP_INTERVAL}s)")
243
+ while True:
244
+ time.sleep(BACKUP_INTERVAL)
245
+ log("Scheduled backup triggered")
246
+ do_backup()
247
+
248
+
249
+ def realtime_daemon():
250
+ log(f"Realtime mode started (check: {CHECK_INTERVAL}s, min interval: {MIN_INTERVAL}s)")
251
+ last_stat = None
252
+ last_backup_time = 0
253
+
254
+ while True:
255
+ time.sleep(CHECK_INTERVAL)
256
+
257
+ actual_db = find_db_file()
258
+ if not actual_db:
259
+ continue
260
+
261
+ curr_stat = get_file_stat(actual_db)
262
+ if curr_stat is None:
263
+ continue
264
+
265
+ if last_stat is not None and curr_stat != last_stat:
266
+ now = time.time()
267
+ if now - last_backup_time >= MIN_INTERVAL:
268
+ log("Database change detected, triggering backup...")
269
+ if do_backup():
270
+ last_backup_time = now
271
+ else:
272
+ log("Change detected but within min interval, skipped")
273
+
274
+ last_stat = curr_stat
275
+
276
+
277
+ def main():
278
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "daemon"
279
+
280
+ if cmd == "restore":
281
+ restore()
282
+ elif cmd == "backup":
283
+ do_backup()
284
+ elif cmd == "daemon":
285
+ if BACKUP_MODE == "realtime":
286
+ realtime_daemon()
287
+ else:
288
+ scheduled_daemon()
289
+ else:
290
+ log(f"Unknown command: {cmd}")
291
+
292
+
293
+ if __name__ == "__main__":
294
+ main()
entrypoint.sh ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ echo "=== FreeLLMAPI Dataset Persistence Manager ==="
5
+
6
+ # 使用虚拟环境中的 Python(PATH 已在 Dockerfile 中设置)
7
+ # 如需额外安装,也用 /opt/venv/bin/pip,但 Dockerfile 中已预装 huggingface-hub
8
+
9
+ # 1. 启动时:从 Dataset 还原数据库
10
+ python3 /app/backup_manager.py restore
11
+
12
+ # 2. 启动后台备份守护进程
13
+ python3 /app/backup_manager.py daemon &
14
+ BACKUP_PID=$!
15
+ echo "Backup daemon started (PID: $BACKUP_PID, mode: ${BACKUP_MODE:-scheduled})"
16
+
17
+ # 3. 捕获终止信号,优雅关闭时执行最终备份
18
+ cleanup() {
19
+ echo "Shutdown signal received, running final backup..."
20
+ python3 /app/backup_manager.py backup || true
21
+ kill $BACKUP_PID 2>/dev/null || true
22
+ wait $BACKUP_PID 2>/dev/null || true
23
+ exit 0
24
+ }
25
+ trap cleanup SIGTERM SIGINT
26
+
27
+ # 4. 启动 FreeLLMAPI(前台进程)
28
+ echo "Starting FreeLLMAPI..."
29
+ exec node server/dist/index.js