feat(api): POST /files и GET /agent/files/{sha256}, files/ 0700 при старте, уборка в tick раз в сутки (#43)
Не base64 — 400 bad_request, пустой — 422 empty_file, больше 10 МБ — 413 too_large; агенту — {file, size, content} в base64, нет файла — 404 no_file; уборка отдельно от автообновления (решения 14–16).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
19bba60cac
commit
e8707d4762
@ -14,7 +14,7 @@
|
||||
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
|
||||
| `backup` | копии файла базы, ротация | `paths` |
|
||||
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
|
||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `relay`, `release` |
|
||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` |
|
||||
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
|
||||
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
|
||||
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
|
||||
|
||||
@ -9,12 +9,13 @@
|
||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента); каталог данных, `backups/` и `release/` — права 0700. Порт по умолчанию 8790.
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
|
||||
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
|
||||
|
||||
## Архитектура
|
||||
- `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром.
|
||||
- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`.
|
||||
- `files` (хранилище вложений `files/<sha256>`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`.
|
||||
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы).
|
||||
- Подробная карта модулей и границ — `ARCHITECTURE.md`.
|
||||
|
||||
|
||||
10
README.md
10
README.md
@ -16,7 +16,7 @@ Python для хаба uv ставит свой (3.13, `.python-version`; сис
|
||||
Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`.
|
||||
|
||||
Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`):
|
||||
`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API.
|
||||
`hub.sqlite`, `backups/`, `release/` и `files/` (файлы вложений по sha256; каталоги — 0700). Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API.
|
||||
|
||||
## Первый запуск
|
||||
|
||||
@ -128,7 +128,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
|
||||
## HTTP API
|
||||
|
||||
Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
|
||||
ошибки `{error, detail}` (400/401/404/409/422).
|
||||
ошибки `{error, detail}` (400/401/404/409/413/422).
|
||||
|
||||
| Маршрут | Токен | Назначение |
|
||||
|---|---|---|
|
||||
@ -139,6 +139,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
|
||||
| `POST /agent/state` | агент | загрузка состояния задач пачками |
|
||||
| `GET /agent/known-keys` | агент | ключи зеркала |
|
||||
| `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` |
|
||||
| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` |
|
||||
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
|
||||
| `GET /issues/{key}` | клиент | задача целиком |
|
||||
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
|
||||
@ -148,6 +149,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
|
||||
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
|
||||
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
|
||||
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
|
||||
| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` |
|
||||
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок |
|
||||
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) |
|
||||
|
||||
@ -223,6 +225,10 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
|
||||
|
||||
- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-<batch>.sqlite`
|
||||
и хранит последние `backup_keep` копий (по умолчанию 30).
|
||||
- Файлы вложений: `files/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
|
||||
раз — через минуту после старта) хаб удаляет файлы старше 30 дней, на которые не ссылается `ensure_attachment` в
|
||||
очереди или у агента; повтор такой команды после уборки — 422 «файл … не загружен: POST /files», файл загружается
|
||||
заново.
|
||||
- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала,
|
||||
хаб ставит пакет «Автообновление».
|
||||
- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`.
|
||||
|
||||
@ -114,3 +114,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `Content-Type: a
|
||||
13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная
|
||||
запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого,
|
||||
агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент).
|
||||
14. 15.10.2026 — `POST /files` отбрасывает пробельные символы (переносы строк) в `content` до строгой проверки base64.
|
||||
Обоснование: GNU `base64` и многие библиотеки переносят строку через 76 символов; спека говорит только «не base64 —
|
||||
400». Цена: строка с пробелами внутри тоже принимается.
|
||||
15. 15.10.2026 — время последней уборки — в памяти процесса (`Hub.last_sweep`); первая уборка — на первом тике после
|
||||
старта (через 60 с). Обоснование: без новой настройки и файла-метки, уборка дешёвая. Цена: при нескольких
|
||||
перезапусках хаба в сутки уборок больше одной (спека уточнена: «в пределах работы процесса», рулинг R-1).
|
||||
16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`.
|
||||
Обоснование: для агента это тот же случай «файла нет». Цена: нет.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
|
||||
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
|
||||
Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links)."""
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
@ -13,7 +14,7 @@ from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from . import backup, db, mirror, queue, relay, release, settings
|
||||
from . import backup, db, files, mirror, queue, relay, release, settings
|
||||
from .commands import CommandError
|
||||
from .paths import backups_dir as default_backups_dir
|
||||
from .paths import secure_dir
|
||||
@ -29,6 +30,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им
|
||||
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
|
||||
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
|
||||
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
|
||||
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
|
||||
|
||||
|
||||
class Raw(NamedTuple):
|
||||
@ -45,16 +47,19 @@ class ApiError(Exception):
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None):
|
||||
def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None):
|
||||
self.db_path = Path(db_path)
|
||||
self.web_dir = Path(web_dir) if web_dir else WEB_APP
|
||||
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
|
||||
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
|
||||
self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files"
|
||||
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
|
||||
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
|
||||
# неизвестен, а не null (hub-hygiene-d, решение 7)
|
||||
self.agent_update = {"update_state": None, "launcher_version": None, "reported": False}
|
||||
self.claim_wait = 30
|
||||
secure_dir(self.backups_dir) # каталог копий закрыт при каждом старте, а не только после первой копии
|
||||
secure_dir(self.files_dir) # files/ — 0700 при каждом старте, как backups/
|
||||
conn = self.conn()
|
||||
settings.ensure_defaults(conn)
|
||||
conn.close()
|
||||
@ -63,17 +68,35 @@ class Hub:
|
||||
return db.connect(self.db_path)
|
||||
|
||||
def tick(self):
|
||||
"""Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл."""
|
||||
"""Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой
|
||||
логируется и не мешает ни другой, ни следующему тику."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.conn()
|
||||
queue.maybe_auto_refresh(conn)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("автообновление: сбой тика")
|
||||
try:
|
||||
if conn is not None:
|
||||
self.sweep_files(conn)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("уборка файлов: сбой")
|
||||
finally:
|
||||
if conn:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
def sweep_files(self, conn, now=None):
|
||||
"""Уборка files/ (files.sweep) не чаще раза в SWEEP_EVERY; время прошлой — в памяти процесса, после
|
||||
перезапуска хаба первая уборка — на первом тике (решение 15). None — ещё рано."""
|
||||
now = time.time() if now is None else now
|
||||
if self.last_sweep is not None and now - self.last_sweep < SWEEP_EVERY:
|
||||
return None
|
||||
self.last_sweep = now
|
||||
removed = files.sweep(conn, self.files_dir, now)
|
||||
if removed:
|
||||
log.info("уборка файлов: удалено %d", len(removed))
|
||||
return removed
|
||||
|
||||
def note_agent_update(self, body):
|
||||
"""update_state и launcher_version из claim: хранятся в памяти, агент шлёт их в каждом claim (R-3).
|
||||
Значение неверного типа игнорируется — claim из-за него не отвергается; сам claim отмечается (reported)."""
|
||||
@ -141,6 +164,15 @@ def h_agent_release(hub, conn, m, q, body):
|
||||
return 200, rel
|
||||
|
||||
|
||||
def h_agent_file(hub, conn, m, q, body):
|
||||
"""Файл вложения агенту: {file, size, content}, content — base64 (проходит и реле папки, и HTTP). id не из 64 hex
|
||||
нижнего регистра — тот же 404 no_file (решение 16)."""
|
||||
data = files.read(hub.files_dir, m.group(1))
|
||||
if data is None:
|
||||
raise ApiError(404, "no_file", f"файла {m.group(1)} нет в хранилище хаба")
|
||||
return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")}
|
||||
|
||||
|
||||
def h_agent_settings(hub, conn, m, q, body):
|
||||
return 200, settings.for_agent(conn)
|
||||
|
||||
@ -258,6 +290,25 @@ def h_batch_cancel(hub, conn, m, q, body):
|
||||
raise ApiError(404, "not_found", "batch")
|
||||
|
||||
|
||||
def h_files_post(hub, conn, m, q, body):
|
||||
"""Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64)
|
||||
отбрасываются до строгой проверки (решение 14)."""
|
||||
content = body.get("content")
|
||||
if not isinstance(content, str):
|
||||
raise ApiError(400, "bad_request", "content — строка base64")
|
||||
try:
|
||||
data = base64.b64decode("".join(content.split()), validate=True)
|
||||
except ValueError: # binascii.Error — подкласс ValueError
|
||||
raise ApiError(400, "bad_request", "content — не base64") from None
|
||||
try:
|
||||
sha, size = files.put(hub.files_dir, data)
|
||||
except files.EmptyFile:
|
||||
raise ApiError(422, "empty_file", "пустой файл") from None
|
||||
except files.TooLarge:
|
||||
raise ApiError(413, "too_large", f"файл больше {files.MAX_SIZE} байт ({len(data)})") from None
|
||||
return 200, {"file": sha, "size": size}
|
||||
|
||||
|
||||
def h_command_retry(hub, conn, m, q, body):
|
||||
if not isinstance(body.get("author") or "api", str):
|
||||
raise ApiError(422, "bad_retry", "author — строка")
|
||||
@ -344,6 +395,7 @@ ROUTES = [
|
||||
("POST", r"^/agent/state$", h_agent_state, "agent"),
|
||||
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
|
||||
("GET", r"^/agent/release$", h_agent_release, "agent"),
|
||||
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
|
||||
("GET", r"^/issues$", h_issues, "client"),
|
||||
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
||||
("GET", r"^/board$", h_board, "client"),
|
||||
@ -352,6 +404,7 @@ ROUTES = [
|
||||
("GET", r"^/batches$", h_batches_get, "client"),
|
||||
("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
|
||||
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
|
||||
("POST", r"^/files$", h_files_post, "client"),
|
||||
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
|
||||
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"),
|
||||
("POST", r"^/refresh$", h_refresh, "client"),
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub import api, db, queue, release, settings
|
||||
from jira_hub import api, db, files, queue, release, settings
|
||||
from jira_hub.client import HubClient, HubError
|
||||
|
||||
|
||||
@ -606,3 +610,101 @@ def test_bug_without_setting_is_422(hub):
|
||||
"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]})
|
||||
assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug"
|
||||
assert hub["client"].get("/batches")["batches"] == []
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def test_files_upload_and_agent_download(hub, tmp_path):
|
||||
"""Спека hub-api «Загрузка файла», «Повторная загрузка», «Скачивание файла вложения»."""
|
||||
c, a = hub["client"], hub["agent"]
|
||||
sha = hashlib.sha256(b"abc").hexdigest()
|
||||
out = c.post("/files", {"content": _b64(b"abc")})
|
||||
assert out == {"file": sha, "size": 3} and sha.startswith("ba7816bf")
|
||||
assert (tmp_path / "files" / sha).read_bytes() == b"abc"
|
||||
assert c.post("/files", {"content": _b64(b"abc")}) == out
|
||||
assert [p.name for p in (tmp_path / "files").iterdir()] == [sha]
|
||||
got = a.get(f"/agent/files/{sha}")
|
||||
assert got["file"] == sha and got["size"] == 3
|
||||
assert hashlib.sha256(base64.b64decode(got["content"])).hexdigest() == sha
|
||||
with pytest.raises(HubError) as e:
|
||||
c.get(f"/agent/files/{sha}")
|
||||
assert e.value.status == 401
|
||||
with pytest.raises(HubError) as e:
|
||||
a.post("/files", {"content": _b64(b"abc")})
|
||||
assert e.value.status == 401
|
||||
with pytest.raises(HubError) as e:
|
||||
a.get("/agent/files/" + "0" * 64)
|
||||
assert (e.value.status, e.value.error) == (404, "no_file")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body, status, error", [
|
||||
({}, 400, "bad_request"), ({"content": 5}, 400, "bad_request"), ({"content": None}, 400, "bad_request"),
|
||||
({"content": "не base64!"}, 400, "bad_request"), ({"content": "YWJj="}, 400, "bad_request"),
|
||||
({"content": ""}, 422, "empty_file"),
|
||||
])
|
||||
def test_files_bad_upload(hub, tmp_path, body, status, error):
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/files", body)
|
||||
assert (e.value.status, e.value.error) == (status, error)
|
||||
assert list((tmp_path / "files").iterdir()) == []
|
||||
|
||||
|
||||
def test_files_base64_with_line_breaks(hub):
|
||||
data = bytes(range(256)) * 4
|
||||
s = _b64(data)
|
||||
wrapped = "\n".join(s[i:i + 76] for i in range(0, len(s), 76)) + "\n" # как GNU base64
|
||||
assert hub["client"].post("/files", {"content": wrapped}) == {"file": hashlib.sha256(data).hexdigest(), "size": 1024}
|
||||
|
||||
|
||||
def test_files_too_large_is_413(hub, tmp_path):
|
||||
"""Спека hub-api «Слишком большой файл»: 11 МБ — 413, тело запроса (≈ 15 МБ base64) до 16 МБ проходит."""
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))})
|
||||
assert (e.value.status, e.value.error) == (413, "too_large")
|
||||
assert list((tmp_path / "files").iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["..", "%2E%2E", hashlib.sha256(b"abc").hexdigest().upper(), "0" * 63, "x" * 64])
|
||||
def test_agent_file_bad_id_is_no_file(hub, bad):
|
||||
hub["client"].post("/files", {"content": _b64(b"abc")})
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["agent"].get(f"/agent/files/{bad}")
|
||||
assert (e.value.status, e.value.error) == (404, "no_file")
|
||||
|
||||
|
||||
def test_files_dir_closed_on_every_start(tmp_path):
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
d = tmp_path / "files"
|
||||
assert h.files_dir == d and stat.S_IMODE(d.stat().st_mode) == 0o700
|
||||
os.chmod(d, 0o755)
|
||||
api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
assert stat.S_IMODE(d.stat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch):
|
||||
h = hub["hub"]
|
||||
sha = hub["client"].post("/files", {"content": _b64(b"old")})["file"]
|
||||
old = time.time() - 31 * 86400
|
||||
os.utime(tmp_path / "files" / sha, (old, old))
|
||||
calls = []
|
||||
real = files.sweep
|
||||
monkeypatch.setattr(api.files, "sweep", lambda *a, **k: calls.append(1) or real(*a, **k))
|
||||
h.tick()
|
||||
assert len(calls) == 1 and not (tmp_path / "files" / sha).exists()
|
||||
h.tick()
|
||||
assert len(calls) == 1 # второй тик в те же сутки — без уборки
|
||||
h.last_sweep -= api.SWEEP_EVERY
|
||||
h.tick()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog):
|
||||
def broken(*args, **kwargs):
|
||||
raise OSError("диск недоступен")
|
||||
monkeypatch.setattr(api.files, "sweep", broken)
|
||||
hub["agent"].post("/agent/claim", {"agent_version": 9}) # агент на связи — тик ставит автообновление
|
||||
hub["hub"].tick() # не бросает
|
||||
assert "уборка файлов: сбой" in caplog.text
|
||||
assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user