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 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
- git push hf HEAD:main --force
 
 
 
 
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": {str(cid): _serialize_chat_state(state) for cid, state in bot.chat_state.items()},
195
- "global_quota": bot.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
  # чувствительный из трёх эндпоинтов (отдаёт ПОЛНЫЕ истории всех чатов), поэтому