feat(files): хранилище файлов вложений files/<sha256> с уборкой (#43)
put — атомарно (mkstemp, fsync, os.replace), одна копия на содержимое, mtime при повторе, предел 10 485 760 байт, пустой — отказ; path/exists/size/read только для sha из 64 hex; sweep — старше 30 дней без ensure_attachment в queued/claimed (решения 10–13). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b4cf66b77
commit
19bba60cac
@ -13,6 +13,7 @@
|
||||
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
@ -29,7 +30,7 @@
|
||||
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`
|
||||
и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`.
|
||||
|
||||
Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
|
||||
Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
|
||||
|
||||
## Потоки данных
|
||||
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
|
||||
|
||||
@ -100,3 +100,17 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `Content-Type: a
|
||||
9. 15.10.2026 — `issue_type` вне списка — 422 «команда N: issue_type — task, epic или bug»; `init` добавляет `bug`, если
|
||||
ключ `issue_type_bug` или `issue_type_bug_id` есть в конфиге даже со значением `null` (тогда постановка `bug` — 422,
|
||||
как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет.
|
||||
10. 15.10.2026 — функции `files` принимают каталог хранилища первым параметром (`put(root, data)`, `path(root, sha)`,
|
||||
`exists`, `size`, `read`, `sweep(conn, root, now)`), каталог — `Hub.files_dir` (`<каталог базы>/files`), а не
|
||||
`paths.home()`. Обоснование: тесты поднимают хаб во временном каталоге без `JIRA_HUB_HOME` (как `release_dir`).
|
||||
Цена: лишний параметр в вызовах из `queue` и `api`.
|
||||
11. 15.10.2026 — временный файл записи — `tempfile.mkstemp` в `files/` (`.<sha>.<случайное>.tmp`, права 0600), а не
|
||||
фиксированное `.<sha>.tmp`. Обоснование: две одновременные загрузки одного содержимого (ThreadingHTTPServer) не
|
||||
пишут в один временный файл и не ломают друг другу `os.replace`. Цена: брошенные после сбоя временные файлы
|
||||
убирает уборка общим правилом 30 дней.
|
||||
12. 15.10.2026 — `files.path/exists/size/read` принимают только sha из 64 символов `[0-9a-f]`, иначе «файла нет»
|
||||
(`path` — `ValueError`). Обоснование: APFS не различает регистр — `BA78…` нашёл бы `ba78…`; `..` и `/` не строят
|
||||
путь вне `files/`. Цена: нет.
|
||||
13. 15.10.2026 — повторная загрузка: копия того же размера есть — только `os.utime`; другого размера (оборванная
|
||||
запись) — файл переписывается; содержимое по sha256 не перепроверяется. Обоснование: имя — sha256 содержимого,
|
||||
агент сверяет sha256 при скачивании. Цена: подменённый на диске файл того же размера хаб не заметит (заметит агент).
|
||||
|
||||
112
src/jira_hub/files.py
Normal file
112
src/jira_hub/files.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Хранилище файлов вложений: <каталог данных>/files/<sha256>, одна копия на содержимое, без имени вложения — имя задаёт
|
||||
команда ensure_attachment (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения:
|
||||
каталог передаётся параметром (Hub.files_dir, решение 10)."""
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import secure_dir
|
||||
|
||||
MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1)
|
||||
KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2)
|
||||
SHA_RE = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
class EmptyFile(ValueError):
|
||||
"""Пустое содержимое — API отвечает 422 empty_file (решение 3)."""
|
||||
|
||||
|
||||
class TooLarge(ValueError):
|
||||
"""Больше MAX_SIZE — API отвечает 413 too_large."""
|
||||
|
||||
|
||||
def is_sha(value) -> bool:
|
||||
"""sha256 в хранилище — ровно 64 символа [0-9a-f]: верхний регистр, «..», «/» путь не строят (решение 12)."""
|
||||
return isinstance(value, str) and SHA_RE.fullmatch(value) is not None
|
||||
|
||||
|
||||
def path(root, sha) -> Path:
|
||||
if not is_sha(sha):
|
||||
raise ValueError(f"не sha256: {sha!r}")
|
||||
return Path(root) / sha
|
||||
|
||||
|
||||
def exists(root, sha) -> bool:
|
||||
return is_sha(sha) and path(root, sha).is_file()
|
||||
|
||||
|
||||
def size(root, sha) -> int | None:
|
||||
"""Размер файла в байтах; None — файла нет или sha негодный."""
|
||||
if not is_sha(sha):
|
||||
return None
|
||||
try:
|
||||
return path(root, sha).stat().st_size
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def read(root, sha) -> bytes | None:
|
||||
if not is_sha(sha):
|
||||
return None
|
||||
try:
|
||||
return path(root, sha).read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def put(root, data: bytes) -> tuple[str, int]:
|
||||
"""Сохранить содержимое, вернуть (sha256, размер). Копия того же размера уже есть — только обновить mtime (уборка
|
||||
считает возраст от последней загрузки); другого размера (оборванная запись) — переписать (решение 13). Запись
|
||||
атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется
|
||||
(решение 11)."""
|
||||
if not data:
|
||||
raise EmptyFile("пустой файл")
|
||||
if len(data) > MAX_SIZE:
|
||||
raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})")
|
||||
root = Path(root)
|
||||
secure_dir(root)
|
||||
sha = hashlib.sha256(data).hexdigest()
|
||||
target = root / sha
|
||||
if size(root, sha) == len(data):
|
||||
try:
|
||||
os.utime(target)
|
||||
return sha, len(data)
|
||||
except FileNotFoundError:
|
||||
pass # убран между проверкой и utime — пишем заново
|
||||
fd, tmp = tempfile.mkstemp(dir=root, prefix=f".{sha}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, target)
|
||||
except BaseException:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
return sha, len(data)
|
||||
|
||||
|
||||
def sweep(conn, root, now: float | None = None) -> list[str]:
|
||||
"""Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или
|
||||
claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят
|
||||
тем же путём. Каталога нет — пусто."""
|
||||
root = Path(root)
|
||||
if not root.is_dir():
|
||||
return []
|
||||
cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400
|
||||
active = {r[0] for r in conn.execute("SELECT json_extract(params, '$.file') FROM command "
|
||||
"WHERE type = 'ensure_attachment' AND status IN ('queued', 'claimed')")}
|
||||
removed = []
|
||||
for p in sorted(root.iterdir()):
|
||||
if p.name in active or not p.is_file():
|
||||
continue
|
||||
try:
|
||||
if p.stat().st_mtime < cutoff:
|
||||
p.unlink()
|
||||
removed.append(p.name)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return removed
|
||||
149
tests/test_files.py
Normal file
149
tests/test_files.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""Хранилище файлов вложений (bug-and-attachments, hub-api «Хранилище файлов вложений»)."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub import db, files, settings
|
||||
|
||||
ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf…
|
||||
DAY = 86400
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path):
|
||||
return tmp_path / "files"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(open_db):
|
||||
"""База в памяти с настройками по умолчанию; закрывается после теста."""
|
||||
c = open_db()
|
||||
settings.ensure_defaults(c)
|
||||
return c
|
||||
|
||||
|
||||
def test_put_creates_closed_dir_and_file(root):
|
||||
assert files.put(root, b"abc") == (ABC, 3)
|
||||
assert ABC.startswith("ba7816bf")
|
||||
assert (root / ABC).read_bytes() == b"abc"
|
||||
assert stat.S_IMODE(os.stat(root).st_mode) == 0o700
|
||||
assert [p.name for p in root.iterdir()] == [ABC] # временных файлов не осталось
|
||||
|
||||
|
||||
def test_put_again_keeps_one_copy_and_touches_mtime(root):
|
||||
files.put(root, b"abc")
|
||||
old = time.time() - 40 * DAY
|
||||
os.utime(root / ABC, (old, old))
|
||||
assert files.put(root, b"abc") == (ABC, 3)
|
||||
assert [p.name for p in root.iterdir()] == [ABC]
|
||||
assert (root / ABC).stat().st_mtime > time.time() - 60 # свежая постановка не теряет файл в уборке
|
||||
|
||||
|
||||
def test_put_rewrites_truncated_copy(root):
|
||||
files.put(root, b"abc")
|
||||
(root / ABC).write_bytes(b"ab") # оборванная копия
|
||||
files.put(root, b"abc")
|
||||
assert (root / ABC).read_bytes() == b"abc"
|
||||
|
||||
|
||||
def test_put_limits(root):
|
||||
with pytest.raises(files.EmptyFile):
|
||||
files.put(root, b"")
|
||||
with pytest.raises(files.TooLarge):
|
||||
files.put(root, b"\0" * (files.MAX_SIZE + 1))
|
||||
assert not root.exists() or list(root.iterdir()) == []
|
||||
sha, size = files.put(root, b"\0" * files.MAX_SIZE) # ровно 10 485 760 байт — принимается
|
||||
assert files.MAX_SIZE == 10_485_760 and size == files.MAX_SIZE and files.size(root, sha) == files.MAX_SIZE
|
||||
|
||||
|
||||
def test_put_failure_leaves_no_temp(root, monkeypatch):
|
||||
def broken(*args):
|
||||
raise OSError("диск полон")
|
||||
monkeypatch.setattr(files.os, "replace", broken)
|
||||
with pytest.raises(OSError, match="диск полон"):
|
||||
files.put(root, b"abc")
|
||||
assert list(root.iterdir()) == []
|
||||
|
||||
|
||||
def test_put_after_dir_removed(root):
|
||||
files.put(root, b"abc")
|
||||
(root / ABC).unlink()
|
||||
root.rmdir()
|
||||
assert files.put(root, b"abc") == (ABC, 3) and files.exists(root, ABC)
|
||||
assert stat.S_IMODE(os.stat(root).st_mode) == 0o700
|
||||
|
||||
|
||||
def test_lookup(root):
|
||||
files.put(root, b"abc")
|
||||
assert files.exists(root, ABC) and files.size(root, ABC) == 3 and files.read(root, ABC) == b"abc"
|
||||
assert files.path(root, ABC) == root / ABC
|
||||
missing = "0" * 64
|
||||
assert not files.exists(root, missing) and files.size(root, missing) is None and files.read(root, missing) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [ABC.upper(), ABC[:63], ABC + "0", "..", "../hub.sqlite", ABC + "\n", "", None, 5])
|
||||
def test_bad_sha_never_builds_path(root, bad):
|
||||
"""APFS не различает регистр: ABC.upper() нашёл бы файл ABC, если бы путь строился без проверки."""
|
||||
files.put(root, b"abc")
|
||||
assert files.is_sha(bad) is False
|
||||
assert not files.exists(root, bad) and files.size(root, bad) is None and files.read(root, bad) is None
|
||||
with pytest.raises(ValueError):
|
||||
files.path(root, bad)
|
||||
|
||||
|
||||
def _file(root, content, age_days):
|
||||
sha, _ = files.put(root, content)
|
||||
t = time.time() - age_days * DAY
|
||||
os.utime(root / sha, (t, t))
|
||||
return sha
|
||||
|
||||
|
||||
def _command(conn, sha, status):
|
||||
if conn.execute("SELECT 1 FROM batch WHERE id = 1").fetchone() is None:
|
||||
conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 'x', 'claude', ?)", (db.now(),))
|
||||
conn.execute("INSERT INTO command(batch_id, seq, type, params, status) VALUES (1, 0, 'ensure_attachment', ?, ?)",
|
||||
(json.dumps({"key": "A-1", "file": sha, "filename": "a.xlsx"}), status))
|
||||
|
||||
|
||||
def test_sweep_spec_scenario(root, conn):
|
||||
"""Спека hub-api «Уборка старых файлов»: A старше 30 дней без ссылок, B старше 30 дней в queued, C вчерашний."""
|
||||
a = _file(root, b"A", 31)
|
||||
b = _file(root, b"B", 31)
|
||||
c = _file(root, b"C", 1)
|
||||
_command(conn, b, "queued")
|
||||
assert files.sweep(conn, root) == [a]
|
||||
assert sorted(p.name for p in root.iterdir()) == sorted([b, c])
|
||||
|
||||
|
||||
def test_sweep_keeps_claimed_and_shared_file(root, conn):
|
||||
shared = _file(root, b"S", 31)
|
||||
claimed = _file(root, b"K", 31)
|
||||
gone = _file(root, b"G", 31)
|
||||
_command(conn, shared, "done")
|
||||
_command(conn, shared, "queued") # тот же файл под другим именем в другой команде
|
||||
_command(conn, claimed, "claimed")
|
||||
for status in ("done", "cancelled", "failed", "skipped", "confirmed_by_state"):
|
||||
_command(conn, gone, status)
|
||||
assert files.sweep(conn, root) == [gone]
|
||||
assert files.exists(root, shared) and files.exists(root, claimed)
|
||||
|
||||
|
||||
def test_sweep_removes_stale_temp_and_handles_missing_dir(root, conn, tmp_path):
|
||||
assert files.sweep(conn, tmp_path / "нет") == []
|
||||
files.put(root, b"abc")
|
||||
tmp = root / f".{ABC}.x1y2.tmp" # брошен сбоем посреди записи
|
||||
tmp.write_bytes(b"ab")
|
||||
t = time.time() - 31 * DAY
|
||||
os.utime(tmp, (t, t))
|
||||
assert files.sweep(conn, root) == [tmp.name]
|
||||
assert files.exists(root, ABC)
|
||||
|
||||
|
||||
def test_sweep_uses_now(root, conn):
|
||||
sha = _file(root, b"N", 0)
|
||||
assert files.sweep(conn, root) == []
|
||||
assert files.sweep(conn, root, now=time.time() + 31 * DAY) == [sha]
|
||||
Loading…
x
Reference in New Issue
Block a user