Spaces:
Running
Running
SilverElixir commited on
Commit ·
350f2ff
1
Parent(s): 6d075c6
CI/deploy hardening: lease push, concurrency, permissions, backup failure step, export snapshot, slimmer image (A11-01, A11-04..A11-07)
Browse files- .dockerignore +7 -0
- .github/workflows/state-backup.yml +11 -0
- .github/workflows/sync-to-hf.yml +13 -1
- lumen_admin.py +8 -2
- tests/test_bot_admin.py +21 -0
.dockerignore
CHANGED
|
@@ -15,6 +15,13 @@ docs/
|
|
| 15 |
assets/
|
| 16 |
proxy/
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
# Байткод/кэш
|
| 19 |
__pycache__/
|
| 20 |
*.py[cod]
|
|
|
|
| 15 |
assets/
|
| 16 |
proxy/
|
| 17 |
|
| 18 |
+
# Внутренние доки и метаданные — в рантайме не читаются, в слои образа не едут
|
| 19 |
+
AGENTS.md
|
| 20 |
+
SECURITY.md
|
| 21 |
+
audit-report.md
|
| 22 |
+
opencode.json
|
| 23 |
+
.github/
|
| 24 |
+
|
| 25 |
# Байткод/кэш
|
| 26 |
__pycache__/
|
| 27 |
*.py[cod]
|
.github/workflows/state-backup.yml
CHANGED
|
@@ -18,6 +18,9 @@ on:
|
|
| 18 |
- cron: "0 3 * * *" # 03:00 UTC ежедневно
|
| 19 |
workflow_dispatch: # + ручной запуск по требованию
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
# Два одновременных запуска (шедул + ручной) толкались бы в один бранч backup-репо.
|
| 22 |
concurrency:
|
| 23 |
group: state-backup
|
|
@@ -47,3 +50,11 @@ jobs:
|
|
| 47 |
git add export_state.json
|
| 48 |
git diff --cached --quiet || git commit -m "Backup $(date -u +%Y-%m-%d)"
|
| 49 |
git push origin HEAD:main
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
- cron: "0 3 * * *" # 03:00 UTC ежедневно
|
| 19 |
workflow_dispatch: # + ручной запуск по требованию
|
| 20 |
|
| 21 |
+
permissions:
|
| 22 |
+
contents: read
|
| 23 |
+
|
| 24 |
# Два одновременных запуска (шедул + ручной) толкались бы в один бранч backup-репо.
|
| 25 |
concurrency:
|
| 26 |
group: state-backup
|
|
|
|
| 50 |
git add export_state.json
|
| 51 |
git diff --cached --quiet || git commit -m "Backup $(date -u +%Y-%m-%d)"
|
| 52 |
git push origin HEAD:main
|
| 53 |
+
|
| 54 |
+
- name: Report backup failure
|
| 55 |
+
# Канала уведомлений нет (нужен отдельный секрет/чат — решение владельца):
|
| 56 |
+
# минимум фиксируем провал видимым шагом и сводкой, а не тишиной в логах.
|
| 57 |
+
if: failure()
|
| 58 |
+
run: |
|
| 59 |
+
echo "## State backup FAILED — проверьте вкладку Actions" >> "$GITHUB_STEP_SUMMARY"
|
| 60 |
+
exit 1
|
.github/workflows/sync-to-hf.yml
CHANGED
|
@@ -10,6 +10,15 @@ on:
|
|
| 10 |
types: [completed]
|
| 11 |
branches: [main]
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
jobs:
|
| 14 |
sync:
|
| 15 |
# Только пуши в ЭТОТ репозиторий. Без второй проверки пул-реквест из форка с
|
|
@@ -37,4 +46,7 @@ jobs:
|
|
| 37 |
run: |
|
| 38 |
set -euo pipefail
|
| 39 |
git remote add hf "https://user:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE_REPO}"
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
types: [completed]
|
| 11 |
branches: [main]
|
| 12 |
|
| 13 |
+
permissions:
|
| 14 |
+
contents: read
|
| 15 |
+
|
| 16 |
+
# Два завершившихся подряд CI-прогона деплоились бы наперегонки: старый коммит
|
| 17 |
+
# перезаписывал новый. Сериализуем очередь, уже идущий деплой не отменяем.
|
| 18 |
+
concurrency:
|
| 19 |
+
group: hf-space-sync
|
| 20 |
+
cancel-in-progress: false
|
| 21 |
+
|
| 22 |
jobs:
|
| 23 |
sync:
|
| 24 |
# Только пуши в ЭТОТ репозиторий. Без второй проверки пул-реквест из форка с
|
|
|
|
| 46 |
run: |
|
| 47 |
set -euo pipefail
|
| 48 |
git remote add hf "https://user:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE_REPO}"
|
| 49 |
+
# Lease вместо голого force: хотфикс, залитый напрямую на Space,
|
| 50 |
+
# честно уронит push вместо молчаливого отката.
|
| 51 |
+
git fetch hf main || true
|
| 52 |
+
git push hf HEAD:main --force-with-lease
|
lumen_admin.py
CHANGED
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
| 6 |
|
| 7 |
import asyncio
|
| 8 |
import contextlib
|
|
|
|
| 9 |
import hmac
|
| 10 |
import logging
|
| 11 |
import os
|
|
@@ -189,8 +190,13 @@ async def export_state(request: Request) -> dict[str, Any]:
|
|
| 189 |
_log_denied(request, "GET /export_state")
|
| 190 |
return _forbidden()
|
| 191 |
import bot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
return {
|
| 193 |
"exported_at": datetime.now().isoformat(),
|
| 194 |
-
"chats":
|
| 195 |
-
"global_quota":
|
| 196 |
}
|
|
|
|
| 6 |
|
| 7 |
import asyncio
|
| 8 |
import contextlib
|
| 9 |
+
import copy
|
| 10 |
import hmac
|
| 11 |
import logging
|
| 12 |
import os
|
|
|
|
| 190 |
_log_denied(request, "GET /export_state")
|
| 191 |
return _forbidden()
|
| 192 |
import bot
|
| 193 |
+
# Снимок живых структур: экспорт отдавал ссылки на мутабельные объекты —
|
| 194 |
+
# запись между возвратом и сериализацией ответа давала несогласованный бэкап.
|
| 195 |
+
chats = {str(cid): _serialize_chat_state(state) for cid, state in list(bot.chat_state.items())}
|
| 196 |
+
# Квота вложенная (счётчики per-model) — только deepcopy отцепляет её целиком.
|
| 197 |
+
quota = copy.deepcopy(dict(bot.GLOBAL_QUOTA))
|
| 198 |
return {
|
| 199 |
"exported_at": datetime.now().isoformat(),
|
| 200 |
+
"chats": chats,
|
| 201 |
+
"global_quota": quota,
|
| 202 |
}
|
tests/test_bot_admin.py
CHANGED
|
@@ -171,6 +171,27 @@ def test_fastapi_schema_endpoints_are_disabled():
|
|
| 171 |
assert bot.app.openapi_url is None
|
| 172 |
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
def test_export_state_rejects_query_param_regression():
|
| 175 |
# См. test_check_admin_key_rejects_query_param_regression — /export_state — самый
|
| 176 |
# чувствительный из трёх эндпоинтов (отдаёт ПОЛНЫЕ истории всех чатов), поэтому
|
|
|
|
| 171 |
assert bot.app.openapi_url is None
|
| 172 |
|
| 173 |
|
| 174 |
+
def test_export_state_returns_detached_snapshot():
|
| 175 |
+
# Аудит A11-05: экспорт отдавал живые ссылки на мутабельные объекты —
|
| 176 |
+
# запись между возвратом и сериализацией давала несогласованный бэкап.
|
| 177 |
+
original = bot.ADMIN_PANEL_KEY
|
| 178 |
+
bot.ADMIN_PANEL_KEY = "real-admin-key"
|
| 179 |
+
chat_id = 999980
|
| 180 |
+
state = bot.get_state(chat_id)
|
| 181 |
+
state["history"].append({"role": "user", "content": "hi"})
|
| 182 |
+
bot.GLOBAL_QUOTA.setdefault("groq", {})["snaptest"] = {"used": 1}
|
| 183 |
+
try:
|
| 184 |
+
result = asyncio.run(bot.export_state(_FakeAdminRequest(headers={"Authorization": "Bearer real-admin-key"})))
|
| 185 |
+
bot.GLOBAL_QUOTA["groq"]["snaptest"]["used"] = 999
|
| 186 |
+
state["history"].append({"role": "user", "content": "after"})
|
| 187 |
+
assert result["global_quota"]["groq"]["snaptest"]["used"] == 1
|
| 188 |
+
assert all(m["content"] != "after" for m in result["chats"][str(chat_id)]["history"])
|
| 189 |
+
finally:
|
| 190 |
+
bot.GLOBAL_QUOTA.get("groq", {}).pop("snaptest", None)
|
| 191 |
+
bot.chat_state.pop(chat_id, None)
|
| 192 |
+
bot.ADMIN_PANEL_KEY = original
|
| 193 |
+
|
| 194 |
+
|
| 195 |
def test_export_state_rejects_query_param_regression():
|
| 196 |
# См. test_check_admin_key_rejects_query_param_regression — /export_state — самый
|
| 197 |
# чувствительный из трёх эндпоинтов (отдаёт ПОЛНЫЕ истории всех чатов), поэтому
|