eva-agent MVP: wiki_sync и wiki_fetch (#1) #2
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,3 +1,11 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
.bounded
|
.bounded
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
agent.json
|
||||||
|
*.jsonl
|
||||||
|
update.json
|
||||||
|
*.lock
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
agent.py.new
|
||||||
|
agent.py.prev
|
||||||
|
|||||||
@ -1,15 +1,48 @@
|
|||||||
# Архитектура
|
# Архитектура
|
||||||
|
|
||||||
{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}}
|
Агент — один файл `src/agent.py` и запускатель `src/run.py` (Python 3.9, только стандартная библиотека). Тесты в
|
||||||
|
`tests/` работают на фейках хаба и Eva. Спека: `../jira-hub/openspec/changes/wiki-module/`; решения агента:
|
||||||
|
`openspec/changes/eva-agent-mvp/design.md`. Общие части — копия jira-agent v14 (`../jira-agent/ARCHITECTURE.md`).
|
||||||
|
|
||||||
## Модули
|
## Разделы файла `src/agent.py`
|
||||||
|
|
||||||
| Модуль | Ответственность | Зависит от |
|
| Раздел | Классы и функции | Ответственность | Что использует |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| | | |
|
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля (`hub_url`, `hub_token`, `eva.base_url`, `eva.token`) | — |
|
||||||
|
| Самообновление (состояние) | `read_update_state`, `write_update_state` | `update.json` рядом с `agent.py` | `os`, `json` |
|
||||||
|
| HTTP | `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect` | запросы; `Http.send` — запрос с готовым телом; `fetch_bytes` — содержимое файла байтами, не больше предела; перенаправление на другой адрес — `RedirectAway` без перехода | `urllib` |
|
||||||
|
| Eva | `EvaClient`, `EvaError`, `ForeignHost`, `_quote_url_part` | клиент Eva (Bearer-токен): JSON-RPC 2.2 `call` (`POST /api/?m=<метод>`, `error` в ответе — `EvaError`), повторы 429/502/503/504 и сетевых сбоев (4 попытки, `Retry-After`), пауза `pacing` между вызовами, токен вырезается из текстов ошибок; `get_doc`, `list_docs` (страницы по 200, стоп, если Eva не соблюдает `slice`), `download` (только адрес своего хоста, иначе `ForeignHost`; путь кодируется перед запросом; предел 10 МБ); счётчики вызовов и 429, время последнего вызова | `Http` |
|
||||||
|
| Ссылки | `_LinkParser`, `_ParseError`, `classify_links` | `_LinkParser` (`html.parser`) собирает `img[src]` и `a[href]` в порядке появления, нестандартные `<![…]>` пропускает; `classify_links` делит их на вложения `{ref, url, name}` (свой хост, `/files/…`, миниатюра → файл) и ссылки на страницы `{ref, code}` (`DOC-<цифры>`); чужой хост, `static/` и битые адреса пропускает | `html.parser`, `urllib.parse` |
|
||||||
|
| Дерево | `build_children`, `walk`, `_ref_code`, `_ref_id`, `_orderno` | `build_children` — `{код родителя: [дети]}`: с проектом — один список документов проекта и связи `parent`/`tree_parent` (ссылка разрешается в код через карту id → код), без проекта — обход в ширину по `tree_parent.code`; соседи по `(orderno, name, code)`. `walk` — обход в глубину, корень первым, каждый код один раз (циклы, два родителя) | `EvaClient` |
|
||||||
|
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http` | `os`, `uuid` |
|
||||||
|
| Хаб | `HubClient` | `claim`, `release`, `settings`, `command`, `result`, `upload_file` (`POST /agent/files`), `wiki_pages` (`POST /agent/wiki/pages`); при `hub_url = folder:…` делегирует `FolderTransport` | `Http`, `FolderTransport` |
|
||||||
|
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `claimed` → `started` → `finished` → `acked`; восстановление; `prune` | файловая система |
|
||||||
|
| Исполнитель | `Executor`, `HubError` | по методу `do_<тип>` на команду. `do_wiki_sync`: корень, `build_children` + `walk`, по странице `CmfDocument.get` и `classify_links`, пачки в стадию хаба (50 страниц или 4 МБ; 413 — ошибка с кодом страницы/границами пачки), отчёт `{root_title, codes, eva_calls}`. `do_wiki_fetch`: `download` → `POST /agent/files`, отчёт `{attachment_id, file, size}`; «чужой хост», «файл больше 10 МБ», «файл не найден в Eva». Ошибки: сбой хаба — `HubError` («хаб: …»), `EvaError` — «Eva: …», 5xx и сеть — повтор через `Runner` | `EvaClient`, `HubClient`, `classify_links`, `build_children`, `walk` |
|
||||||
|
| Самообновление | `Updater`, `launcher_version` | после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией | `HubClient`, `subprocess` |
|
||||||
|
| Цикл | `Runner` | claim, пауза до команды (по времени последнего вызова Eva), журнал, выполнение, отчёт до подтверждения, восстановление при старте; выход `EXIT_UPDATE` (75) | `HubClient`, `EvaClient`, `Journal`, `Executor` |
|
||||||
|
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main`, `acquire_lock` | команды `run` (`--once` — выйти при первом пустом claim), `check` (хаб `GET /agent/settings` + Eva `CmfDocument.list`; код 0 или 3), `selftest`, `prune` (`run` и `prune` берут блокировку `<журнал>.lock`); сигналы | `Runner`, `Journal` |
|
||||||
|
|
||||||
|
## Запускатель `src/run.py`
|
||||||
|
|
||||||
|
Дословная копия запускателя jira-agent (`LAUNCHER_VERSION = 2`, переменная окружения `JIRA_AGENT_LAUNCHER`). Не импортирует
|
||||||
|
`agent.py`. Запускает `agent.py run` дочерним процессом; код 75 — подмена на `agent.py.new` с пробным периодом и
|
||||||
|
откатом; иначе перезапуск с паузой. Проверка идентичности — `test_run_py_is_copy`.
|
||||||
|
|
||||||
## Границы
|
## Границы
|
||||||
- Что не должно импортировать что.
|
- Код новой версии приходит только через API хаба (`GET /agent/release`); общая папка для кода не используется.
|
||||||
|
- Токены хаба и Eva — только в `agent.json`; в журнал, хаб, логи и тексты ошибок не попадают.
|
||||||
|
- Темп вызовов Eva задаёт хаб (`wiki_pacing` в `GET /agent/settings`), агент его не хранит.
|
||||||
|
- Одна команда в полёте: следующая берётся после выполнения и подтверждения отчёта.
|
||||||
|
- Отчёт до подтверждения: `send_result` повторяет каждые 10 с; 404/409/422 от хаба считаются закрытием.
|
||||||
|
- Команды только читают Eva, поэтому после перезапуска выполняются заново (design 9).
|
||||||
|
- Поля протокола `jira_calls`/`rate_limited` — общие для агентов хаба; агент кладёт в них число вызовов Eva и 429.
|
||||||
|
- Тесты не ходят во внешние сервисы.
|
||||||
|
|
||||||
|
## Сквозной прогон
|
||||||
|
`scripts/e2e-hub.sh`: настоящий хаб (`jira-hub serve` из worktree `jira-hub/.wt/wiki`, временный `JIRA_HUB_HOME`, свободный
|
||||||
|
порт) + `scripts/fake_eva_serve.py` + `agent.py run --once` по HTTP. Живой хаб и его данные не затрагиваются.
|
||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
-
|
- Живая проверка на виртуалке с настоящей Eva (после merge hub#92).
|
||||||
|
- Ответ Eva на протухший токен (перенаправление на SSO, страница входа с кодом 200 или 401) — проверить при первой
|
||||||
|
живой синхронизации (M-5, design 42).
|
||||||
|
|||||||
53
CLAUDE.md
53
CLAUDE.md
@ -1,28 +1,57 @@
|
|||||||
# eva-agent
|
# eva-agent
|
||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}}
|
Клиент модуля wiki хаба (sdlc-hub, `../jira-hub`) для виртуалки с доступом к Eva банка. Опрашивает хаб, по командам
|
||||||
|
`wiki_sync` (выгрузить поддерево страниц с корня) и `wiki_fetch` (скачать вложение) читает Eva и отдаёт данные хабу;
|
||||||
|
в Eva ничего не пишет. Главный сценарий: хаб ставит `wiki_sync` с кодом корня, агент обходит дерево страниц, шлёт
|
||||||
|
страницы пачками в стадию хаба и отчитывается списком кодов.
|
||||||
|
Спека и дизайн: `../jira-hub/openspec/changes/wiki-module/` (hub#91, PR hub#92); решения агента —
|
||||||
|
`openspec/changes/eva-agent-mvp/design.md`. Issue агента — aep/eva-agent#1.
|
||||||
|
|
||||||
## Стек и ограничения
|
## Стек и ограничения
|
||||||
- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}}
|
- Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей.
|
||||||
- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}}
|
- Запрещено: синтаксис 3.10+ и сторонние импорты. `tests/test_py39.py` ловит синтаксис 3.10+ и сторонние импорты;
|
||||||
|
`X | Y` в аннотациях функций ловит прогон на /usr/bin/python3 3.9.6.
|
||||||
|
- `agent.json` содержит токены хаба и Eva — не коммитить, не отправлять в хаб, не выносить с виртуалки.
|
||||||
|
Шаблон без секретов — `src/agent.example.json`.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью.
|
- `src/agent.py` — весь агент, один файл. Общие части скопированы из jira-agent v14 без изменения смысла (решение 1):
|
||||||
- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно.
|
`Http`/`HttpError`/`RedirectAway`/`TooBig`, `FolderTransport` (хаб через общую папку), `HubClient`, `Journal`,
|
||||||
- {{Ключевые интерфейсы/порты между ними, если есть.}}
|
`Updater` (самообновление), `Runner` (claim → пауза → журнал → выполнение → отчёт), `acquire_lock`, CLI
|
||||||
|
`run|check|selftest|prune`. Своё: `EvaClient` (клиент Eva), `Executor` (`do_wiki_sync`, `do_wiki_fetch`).
|
||||||
|
- `src/run.py` — запускатель, дословная копия `../jira-agent/src/run.py` (решение 2); `agent.py` не импортирует.
|
||||||
|
- Границы: `Executor` знает Eva и хаб (стадия страниц `wiki_pages`, файл `upload_file`); `Runner` знает хаб, Eva и
|
||||||
|
журнал; журнал не знает об Eva. Имена полей протокола хаба `jira_calls`/`rate_limited` общие для всех агентов:
|
||||||
|
в `jira_calls` идёт число вызовов Eva.
|
||||||
|
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация. Подробнее: `ARCHITECTURE.md`.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор.
|
- `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор.
|
||||||
- `{{npm run build / ...}}` — сборка.
|
- `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые
|
||||||
- `{{npm run dev / ...}}` — локальный запуск.
|
ресурсы (так проверяем перед коммитом).
|
||||||
|
- `/usr/bin/python3 src/agent.py check` — доступность хаба и Eva (`CmfDocument.list`, 1 запись), без записи; код 0 или 3.
|
||||||
|
- `/usr/bin/python3 src/agent.py run --once` — выполнить выданное и выйти при первом пустом claim.
|
||||||
|
- `bash scripts/e2e-hub.sh` — сквозной прогон с тестовым хабом из `../../../jira-hub/.wt/wiki` (временный `JIRA_HUB_HOME`, свободный порт, фейковая Eva); «e2e: ok». Живой хаб не трогать.
|
||||||
|
- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`).
|
||||||
|
- `/usr/bin/python3 src/agent.py prune` — очистка журнала (при работающем агенте откажет).
|
||||||
|
- `/usr/bin/python3 src/agent.py selftest` — версия + `check`; код 0 или 3.
|
||||||
|
- `/usr/bin/python3 src/run.py` — запуск через запускатель (самообновление).
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
|
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`,
|
||||||
- {{Где лежит тестовое окружение (vault, БД, песочница).}}
|
`tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py`; `scripts/fake_eva_serve.py` поднимает тот же фейк отдельным процессом для e2e.
|
||||||
|
- `tests/_common.py` — путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `eva_cfg()`, `no_sleep()`.
|
||||||
|
- Тесты общих частей перенесены из jira-agent: `test_clients.py` (HTTP), `test_folder.py` (папка), `test_journal.py`,
|
||||||
|
`test_runner.py` (цикл и восстановление; команда-заглушка `wiki_fetch`), `test_update.py` (самообновление),
|
||||||
|
`test_launcher.py` (запускатель + `test_run_py_is_copy`), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py`).
|
||||||
|
- Фикстуры страниц — `tests/fixtures/pages/`. Golden-сценариев нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический.
|
||||||
|
|
||||||
## Первая подсистема
|
## Первая подсистема
|
||||||
{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}}
|
MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева,
|
||||||
|
`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../jira-hub
|
||||||
|
jira-hub agent publish --module wiki src/agent.py` (из основного checkout; без `--module` версия уйдёт агенту jira);
|
||||||
|
порог — настройка хаба `wiki_min_agent_version`; токен — «агент wiki».
|
||||||
|
|
||||||
## OpenSpec и superpowers
|
## OpenSpec и superpowers
|
||||||
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.
|
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.
|
||||||
|
|||||||
84
README.md
Normal file
84
README.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# eva-agent
|
||||||
|
|
||||||
|
Агент модуля wiki хаба (sdlc-hub) для виртуалки банка с доступом к Eva. Опрашивает хаб, выгружает поддерево страниц
|
||||||
|
Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека,
|
||||||
|
один файл `src/agent.py` и запускатель `src/run.py`.
|
||||||
|
|
||||||
|
Агент работает на macOS (виртуалка банка, как jira-agent): блокировка журнала — через `fcntl`, Windows не поддерживается.
|
||||||
|
|
||||||
|
## Установка на виртуалку
|
||||||
|
|
||||||
|
1. Локальный каталог рядом с jira-agent, например `~/eva-agent/` (запускать из него, не из общей папки).
|
||||||
|
2. Положить в него `agent.py` и `run.py` (из `src/`) и `agent.json` (из `agent.example.json`):
|
||||||
|
- `hub_url` — тот же `folder:<путь>`, что у jira-agent (общая папка с хабом; при VPN виртуалка не видит Mac mini по
|
||||||
|
сети), либо `http://<хост>:8790`;
|
||||||
|
- `hub_token` — строка «агент wiki» из `jira-hub tokens` (токен именно агента wiki, не jira и не клиентский);
|
||||||
|
- `eva.base_url` — адрес Eva **только origin**: схема, хост и порт, без пути и без `/` в конце, например
|
||||||
|
`https://eva.example.ru`. Агент сам добавляет к нему `/api/` (JSON-RPC), `/files/…` (вложения) и
|
||||||
|
`/project/Document/<код>` (ссылка на страницу); «свой» адрес вложения сравнивается с ним же, поэтому путь в
|
||||||
|
`base_url` (`https://eva.example.ru/eva`) ломает и вызовы, и проверку хоста;
|
||||||
|
- `eva.token` — токен из профиля Eva (Безопасность);
|
||||||
|
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате.
|
||||||
|
`agent.json` содержит токены: не коммитить, не выносить с виртуалки.
|
||||||
|
3. Проверка: `python3 agent.py check` — печатает «хаб: доступен…» и «eva: ok»; код 0, при недоступности хаба или Eva — 3.
|
||||||
|
4. Запуск: `python3 run.py` (запускатель следит за версиями и самообновлением). `python3 agent.py run` — без запускателя.
|
||||||
|
5. Автозапуск — launchd, как у jira-agent, со своей меткой (см. ниже); запускатель сам перезапускает агента.
|
||||||
|
|
||||||
|
Другие команды: `python3 agent.py run --once` (выполнить выданное и выйти при первом пустом claim), `selftest`
|
||||||
|
(версия + `check`, код 0 или 3), `prune` (чистка журнала).
|
||||||
|
|
||||||
|
Проверить при первой живой синхронизации: как Eva отвечает на протухший токен — перенаправлением на SSO (агент
|
||||||
|
не идёт на чужой адрес и пишет «перенаправление … отклонено»), страницей входа с кодом 200 («ответ не JSON») или 401.
|
||||||
|
|
||||||
|
## Автозапуск через launchd
|
||||||
|
|
||||||
|
Пример `~/Library/LaunchAgents/ru.aepif.eva-agent.plist` (метка своя, не `ru.aepif.jira-agent`: агенты запускаются
|
||||||
|
и останавливаются независимо):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>ru.aepif.eva-agent</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/python3</string>
|
||||||
|
<string>-u</string>
|
||||||
|
<string>/Users/USER/eva-agent/run.py</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key><string>/Users/USER/eva-agent</string>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>KeepAlive</key><true/>
|
||||||
|
<key>StandardOutPath</key><string>/Users/USER/eva-agent/agent.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>/Users/USER/eva-agent/agent.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
```
|
||||||
|
|
||||||
|
Подставить свой путь вместо `USER`. Запуск и остановка:
|
||||||
|
|
||||||
|
```
|
||||||
|
launchctl load ~/Library/LaunchAgents/ru.aepif.eva-agent.plist
|
||||||
|
launchctl unload ~/Library/LaunchAgents/ru.aepif.eva-agent.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
`WorkingDirectory` нужен, чтобы `agent.json` и `journal.jsonl` лежали рядом с `agent.py`.
|
||||||
|
|
||||||
|
## Выкатка новых версий
|
||||||
|
|
||||||
|
Из основного checkout репозитория (не из worktree), при поднятой версии `AGENT_VERSION` в `src/agent.py`:
|
||||||
|
|
||||||
|
uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py
|
||||||
|
|
||||||
|
Агент подтянет её сам (`auto_update`); настройку хаба `wiki_min_agent_version` (не `min_agent_version` — та у агента
|
||||||
|
jira) поднимать после того, как `agent_status` покажет новую версию агента wiki.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
Настройки — `src/agent.example.json`. Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||||
|
|
||||||
|
Сквозной прогон с настоящим хабом и фейковой Eva: `bash scripts/e2e-hub.sh` (печатает «e2e: ok», ~1 мин). Хаб берётся из
|
||||||
|
`../../../jira-hub/.wt/wiki` (переменная `HUB_SRC`), запускается на свободном порту с временным `JIRA_HUB_HOME`;
|
||||||
|
живой хаб не затрагивается. Проверяет: проект с `wiki_root`, `wiki_sync` на 5 страниц, Markdown со ссылкой
|
||||||
|
`attachment:<id>`, `wiki_fetch` вложения и его содержимое.
|
||||||
202
docs/superpowers/plans/2026-10-24-eva-agent-mvp.md
Normal file
202
docs/superpowers/plans/2026-10-24-eva-agent-mvp.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# eva-agent MVP — план реализации
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** агент на виртуалке банка выполняет команды модуля `wiki` хаба: `wiki_sync` (дерево EVA Wiki под корнем → пачки страниц хабу) и `wiki_fetch` (вложение до 10 МБ → хранилище хаба), с журналом, транспортом через общую папку и самообновлением, как jira-agent.
|
||||||
|
|
||||||
|
**Architecture:** один файл `src/agent.py` (Python 3.9, stdlib) — копия общих частей jira-agent v14 (HTTP, `FolderTransport`, `HubClient`, `Journal`, `Runner`, `Updater`, CLI) плюс `EvaClient` (JSON-RPC 2.2), `LinkParser` (классификация ссылок HTML) и `Executor` с `do_wiki_sync`/`do_wiki_fetch`; `src/run.py` — дословная копия запускателя.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только stdlib, `unittest`.
|
||||||
|
|
||||||
|
**Spec:** контракт — `../../jira-hub/.wt/wiki/openspec/changes/wiki-module/` (worktree хаба, ветка `feat/91-wiki`, PR jira-hub#92): `specs/wiki/spec.md` («Синхронизация вики», «Вложения вики по запросу»), `design.md` решения 10, 11, 14 и Context; решения агента — `openspec/changes/eva-agent-mvp/design.md` этого репозитория (1–11). Код хаба, принимающий агента: `src/jira_hub/modules/wiki/{stage.py,sync.py,attachments.py,commands.py}` в том же worktree хаба.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Python 3.9.6, только стандартная библиотека; синтаксис 3.10+ (`match`, `X | Y` в аннотациях функций) и сторонние импорты запрещены — ловит `tests/test_py39.py`.
|
||||||
|
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёный весь набор после каждой задачи.
|
||||||
|
- `AGENT_VERSION = 1` — ровно одна строка `AGENT_VERSION = <целое>` в `src/agent.py`.
|
||||||
|
- `src/run.py` — байт в байт `../../jira-agent/src/run.py` (сверка `cmp` в тесте `test_run_py_is_copy`, путь к jira-agent — `../../jira-agent/src/run.py` от корня worktree; если файла нет — тест пропускается с причиной).
|
||||||
|
- Токен Eva — только в `agent.json` (не в git: `.gitignore` получает `agent.json`, `*.jsonl`, `update.json`, `*.lock`); в логах и отчётах его нет (тест `test_token_not_logged`).
|
||||||
|
- Протокол хаба: `POST /agent/claim`, `GET /agent/settings`, `GET /agent/commands/{id}`, `POST /agent/commands/{id}/result {status, result, error, jira_calls, rate_limited}`, `GET /agent/release`, `POST /agent/files {content}` → `{file, size}`, `POST /agent/wiki/pages {command_id, pages}`. Форма страницы — `stage._check` хаба.
|
||||||
|
- Не трогать репозитории `jira-agent` (только читать), `jira-hub` (только читать и запускать тестовый хаб во временном `JIRA_HUB_HOME`), живой хаб и `~/.local/share/jira-hub`.
|
||||||
|
- Коммиты — по-русски, последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. Дерево Eva с циклом (страница — родитель сама себе или через `tree_parent`) и страница с двумя родителями — обход конечен, страница в отчёте один раз: `test_tree_cycle_and_two_parents` (Task 4).
|
||||||
|
2. HTML с относительными и абсолютными адресами, миниатюрами `.meta/thumbnail.jpg`, `static/`, чужим хостом, `DOC-` в адресе другого хоста — классификация как в design 6: `tests/test_links.py` (Task 3).
|
||||||
|
3. Eva отвечает 429 с `Retry-After`, 503, обрывом соединения, ошибкой JSON-RPC — повторы и итоговый `failed` с текстом без токена: `tests/test_eva_client.py` (Task 2).
|
||||||
|
4. Вложение без `Content-Length` и больше 10 МБ — чтение обрывается на 10 МБ + 1 байт, `failed` с текстом спеки: `test_fetch_too_big_streaming` (Task 5).
|
||||||
|
5. Перезапуск агента посреди `wiki_sync` (журнал: started без finished) — команда выполняется заново, хаб получает полный набор пачек и один отчёт: `test_recover_resyncs` (Task 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Каркас — общие части jira-agent, конфиг, запускатель
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/agent.py`, `src/run.py` (копия), `src/agent.example.json`, `tests/{__init__.py,_common.py,fake_hub.py,fake_relay.py,test_py39.py,test_clients.py,test_folder.py,test_journal.py,test_runner.py,test_update.py,test_launcher.py,test_self_update_e2e.py}`, `.gitignore` (дописать)
|
||||||
|
- Modify: `CLAUDE.md`, `ARCHITECTURE.md` (заполнить шаблоны по образцу `../../jira-agent/CLAUDE.md` и `ARCHITECTURE.md`, для eva-agent), `README.md` (создать: назначение, установка — Task 6 допишет)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `load_config(path) -> dict` (обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`; `folder:` — абсолютный путь; умолчания как у jira-agent).
|
||||||
|
- Копия из jira-agent без изменений смысла: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `FolderTransport`, `Journal`, `Updater`, `acquire_lock`, `launcher_version`, `cmd_run`, `cmd_prune`, `main`.
|
||||||
|
- `HubClient` — без jira-маршрутов (`state`, `known_keys`, `history`), плюс `wiki_pages(command_id, pages) -> dict`.
|
||||||
|
- `Runner` — вызывает `Executor.run(cmd) -> dict` (`{status, result, error, jira_calls, rate_limited}`), как в jira-agent.
|
||||||
|
- `Executor(eva, hub, settings)` с `do_wiki_sync`/`do_wiki_fetch`, в этой задаче — `failed` «не реализовано».
|
||||||
|
- `cmd_check(cfg) -> int` проверяет только хаб; Eva добавит Task 6.
|
||||||
|
- Тесты общих частей переносятся из `../../jira-agent/tests/` с заменой: Jira → нет, `ensure_*` → команда-заглушка `wiki_fetch` с `failed`. Сценарии журнала, папки, 401/403-паузы, самообновления и запускателя сохраняются.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** перенести тесты общих частей, они упадут — `agent.py` нет.
|
||||||
|
- [ ] **Step 2:** собрать `src/agent.py` из jira-agent v14:
|
||||||
|
- вырезать `JiraClient`, jira-методы `Executor`, `ConfigGap` и проверки Jira;
|
||||||
|
- `AGENT_VERSION = 1`;
|
||||||
|
- `run.py` скопировать.
|
||||||
|
- [ ] **Step 3:** весь набор — PASS; `test_run_py_is_copy` и `test_py39` зелёные.
|
||||||
|
- [ ] **Step 4: Commit** «Каркас eva-agent: общие части jira-agent v14 (#1)».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Клиент Eva
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/agent.py` (класс `EvaClient(Http)` после `HubClient`)
|
||||||
|
- Create: `tests/fake_eva.py`, `tests/test_eva_client.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `EvaClient(cfg_eva: dict, timeout, verify_ssl, ca_bundle)`, его методы:
|
||||||
|
- `call(method, **kwargs) -> Any` — `POST <base>/api/?m=<method>`, `Bearer`; повторы по design 4; `error` в ответе — `EvaError("<method>: <error>")`;
|
||||||
|
- `get_doc(code, fields) -> dict | None`;
|
||||||
|
- `list_docs(flt, fields) -> list[dict]` — страницы по 200 через `slice`; обёрнутый результат `{items|result}` разворачивается;
|
||||||
|
- `download(url, limit) -> bytes` — поток с обрывом на `limit + 1`; больше лимита — `TooBig`.
|
||||||
|
- Свойства: `calls` (число вызовов), `rate_limited` (число 429), `pacing` (пауза, с; ставит Runner из `wiki_pacing`).
|
||||||
|
- `tests/fake_eva.py: FakeEva` — `http.server` на порту 0:
|
||||||
|
- хранит документы `{id, code, name, parent, tree_parent, project, orderno, text}`;
|
||||||
|
- поддерживает фильтры `code ==`, `project.code ==`, `tree_parent.code ==`, `slice`;
|
||||||
|
- сценарии сбоев: `fail_next(status, times, retry_after=None)`, `rpc_error_next(text)`;
|
||||||
|
- файлы `/files/...` с `Content-Length` или без него;
|
||||||
|
- проверяет `Authorization`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающие тесты:**
|
||||||
|
- `test_call_ok`;
|
||||||
|
- `test_retry_429_retry_after` — ждал `Retry-After` через подменённый `sleep`;
|
||||||
|
- `test_retry_503_then_ok`;
|
||||||
|
- `test_gives_up_after_4`;
|
||||||
|
- `test_rpc_error`;
|
||||||
|
- `test_list_pages_200` — 450 документов за три вызова;
|
||||||
|
- `test_pacing_between_calls`;
|
||||||
|
- `test_download_limit` — без `Content-Length`, 10 МБ + 1;
|
||||||
|
- `test_token_not_logged` — токен не встречается ни в логе, ни в тексте ошибок.
|
||||||
|
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
|
||||||
|
- [ ] **Step 5: Commit** «Клиент Eva JSON-RPC (#1)».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Разбор ссылок страницы
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/agent.py` (функция `classify_links(html, base_url) -> tuple[list, list]` и `class _LinkParser(HTMLParser)`)
|
||||||
|
- Create: `tests/test_links.py`, `tests/fixtures/pages/*.html` (синтетические, форматы адресов из design хаба, Context; текст ТЗ не копировать)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `classify_links(html: str, base_url: str) -> (attachments: [{ref, url, name}], links: [{ref, code}])` по design 6–7, в порядке появления, без склейки повторов.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающие тесты:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_image_relative_files() # src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/img.png?version=1" →
|
||||||
|
# url "https://eva.test/files/obj/…/img.png" (без query), name "img.png"
|
||||||
|
def test_thumbnail_maps_to_file() # ".../model.xlsx.meta/thumbnail.jpg?version=1" → url ".../model.xlsx", name из alt или "model.xlsx"
|
||||||
|
def test_file_link_same_url() # <a href=".../model.xlsx">model.xlsx</a> → тот же url, что у миниатюры
|
||||||
|
def test_static_ignored() # src="static/attach-file.png" → ни в одном списке
|
||||||
|
def test_page_link_relative() # href="project/Document/DOC-019834" → links [{ref, code: "DOC-019834"}]
|
||||||
|
def test_foreign_host() # href="https://other.test/files/x.png" и "https://other.test/project/Document/DOC-1" → пусто
|
||||||
|
def test_name_unquoted() # ".../%D0%9F%D0%9C%D0%9F.xlsx" без alt и текста → name "ПМП.xlsx"
|
||||||
|
def test_ref_exact() # ref равен атрибуту байт в байт (с & → как отдаёт HTMLParser: unescape; проверить, что хаб ищет так же — см. convert.py хаба)
|
||||||
|
```
|
||||||
|
|
||||||
|
Для `test_ref_exact` надо сверить, с чем хаб сравнивает `ref`: с сырым значением атрибута или с unescape. Это видно в `../../jira-hub/.wt/wiki/src/jira_hub/modules/wiki/convert.py`. Записать, как сделано, в отчёт и в design (решение 12).
|
||||||
|
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
|
||||||
|
- [ ] **Step 5: Commit** «Разбор ссылок страниц Eva (#1)».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Команда `wiki_sync`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/agent.py` (`Executor.do_wiki_sync`, функции `build_children(docs)`, `walk(root, children)`), `tests/fake_hub.py` (`POST /agent/wiki/pages` — записывает пачки; проверка формы по образцу `stage._check` хаба: `code`, `title`, `html` строки; ≤ 50 страниц)
|
||||||
|
- Create: `tests/test_wiki_sync.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `EvaClient` (Task 2), `classify_links` (Task 3), `HubClient.wiki_pages` (Task 1).
|
||||||
|
- Produces: `Executor.do_wiki_sync(params {label, root}) -> {status, result {root_title, codes, eva_calls}, error, jira_calls, rate_limited}` по design 5; пачки — `MAX_PAGES = 50`, `MAX_BATCH_BYTES = 4 * 1024 * 1024`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающие тесты:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_sync_tree_order() # корень DOC-1 (project P), дети DOC-3 (orderno 1), DOC-2 (orderno 2), внук DOC-4 под DOC-3 →
|
||||||
|
# codes [DOC-1, DOC-3, DOC-4, DOC-2]; parent_code; url ".../project/Document/DOC-3"
|
||||||
|
def test_sync_batches_50() # 120 страниц → пачки 50, 50, 20; codes — 120
|
||||||
|
def test_sync_batch_bytes() # страницы по 1,5 МБ → пачка не больше 4 МБ
|
||||||
|
def test_tree_cycle_and_two_parents() # A.parent=B, B.tree_parent=A; C с parent=корень и tree_parent=DOC-3 → каждый код один раз
|
||||||
|
def test_root_without_project() # у корня нет project → обход через tree_parent.code
|
||||||
|
def test_root_missing() # get_doc → None → failed «страница DOC-9 не найдена или нет прав»
|
||||||
|
def test_eva_failure_reported() # Eva 503 четыре раза подряд → failed, текст содержит "503", токена в тексте нет
|
||||||
|
def test_hub_rejects_batch() # fake_hub 422 на пачку → failed с detail хаба
|
||||||
|
def test_recover_resyncs() # журнал started без finished, команда claimed в хабе → выполняется заново: полные пачки, один done
|
||||||
|
def test_counts() # jira_calls == eva.calls, rate_limited == числу 429
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
|
||||||
|
- [ ] **Step 5: Commit** «Команда wiki_sync (#1)».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Команда `wiki_fetch`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/agent.py` (`Executor.do_wiki_fetch`)
|
||||||
|
- Create: `tests/test_wiki_fetch.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Executor.do_wiki_fetch(params {attachment_id, url}) -> {status, result {file, size}, …}` по design 8; использует `HubClient.upload_file` (как у jira-agent `fetch_attachment`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Падающие тесты:**
|
||||||
|
- `test_fetch_ok` — 300 КБ; `done`, `size` 307200; файл у fake_hub совпадает побайтно;
|
||||||
|
- `test_fetch_foreign_host` — `failed` «чужой хост», у fake_eva нет запросов;
|
||||||
|
- `test_fetch_too_big_header` — `Content-Length` 11 МБ, тело не читается;
|
||||||
|
- `test_fetch_too_big_streaming` — без `Content-Length`;
|
||||||
|
- `test_fetch_404` — «файл не найден в Eva»;
|
||||||
|
- `test_fetch_empty_file` — 0 байт; `done`, `size` 0: хаб принимает пустой файл от агента.
|
||||||
|
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
|
||||||
|
- [ ] **Step 5: Commit** «Команда wiki_fetch (#1)».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: `check`, документация, сквозной прогон с хабом
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/agent.py` (`cmd_check` — хаб и Eva по design 10), `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`, `openspec/changes/eva-agent-mvp/design.md` (решения исполнителей), `src/agent.example.json`
|
||||||
|
- Create: `scripts/e2e-hub.sh`, `tests/test_check.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `scripts/e2e-hub.sh`:
|
||||||
|
- поднимает тестовый хаб из `../../jira-hub/.wt/wiki`: `uv run --project <путь> jira-hub serve --port 0`, неудобно — тогда фиксированный свободный порт, например 8795, с `JIRA_HUB_HOME=$(mktemp -d)`;
|
||||||
|
- поднимает `FakeEva` с деревом из 5 страниц, картинкой и файлом;
|
||||||
|
- заводит проект `E2E` с `wiki_root` через `POST /projects` клиентским токеном из `jira-hub tokens`;
|
||||||
|
- запускает агент `src/agent.py run` с токеном агента `wiki` до первого `done`; нужен флаг `--once` или тайм-аут — решает исполнитель, без изменения протокола;
|
||||||
|
- проверяет `GET /wiki/tree?project=E2E` (5 страниц), `GET /wiki/pages/<код>` (Markdown со ссылкой `attachment:`), затем `POST /wiki/attachments/<id>/fetch` и `GET …/content`;
|
||||||
|
- печатает «e2e: ok»; хаб и фейк гасятся по `trap`.
|
||||||
|
- README — установка на виртуалку:
|
||||||
|
- каталог рядом с jira-agent (например `C:\…\eva-agent\` или `~/eva-agent/`);
|
||||||
|
- файлы `agent.py`, `run.py`, `agent.json` (из примера: `hub_url` = тот же `folder:<путь>`, что у jira-agent, `hub_token` = «агент wiki» из `jira-hub tokens`, `eva.base_url`, `eva.token` — профиль Eva → Безопасность);
|
||||||
|
- `python3 agent.py check`, затем `python3 run.py`;
|
||||||
|
- автозапуск — как у jira-agent;
|
||||||
|
- выкатка новых версий — `uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** тесты `test_check` (хаб ok и Eva ok → 0; Eva 401 → 3, «eva: …»). FAIL → реализовать → PASS.
|
||||||
|
- [ ] **Step 2:** `bash scripts/e2e-hub.sh` → «e2e: ok». Хаб — только из worktree `feat/91-wiki` и только во временном `JIRA_HUB_HOME`.
|
||||||
|
- [ ] **Step 3:** документация.
|
||||||
|
- [ ] **Step 4:** весь набор — PASS.
|
||||||
|
- [ ] **Step 5: Commit** «check, README, сквозной прогон с хабом (#1)».
|
||||||
2
openspec/changes/eva-agent-mvp/.openspec.yaml
Normal file
2
openspec/changes/eva-agent-mvp/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-24
|
||||||
279
openspec/changes/eva-agent-mvp/design.md
Normal file
279
openspec/changes/eva-agent-mvp/design.md
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
# eva-agent-mvp — решения агента
|
||||||
|
|
||||||
|
Спека и дизайн — в `jira-hub/openspec/changes/wiki-module/` (issue aep/jira-hub#91, PR aep/jira-hub#92):
|
||||||
|
- решения 10, 11, 14 и Context (форматы ссылок Eva);
|
||||||
|
- требования `wiki` «Синхронизация вики» и «Вложения вики по запросу»;
|
||||||
|
- `modules` «Агенты модулей»;
|
||||||
|
- `hub-api` «Маршруты агента» (общие `/agent/*`).
|
||||||
|
|
||||||
|
Issue агента — aep/eva-agent#1. Версия агента — 1. Агент работает за модуль `wiki`: имя агента в хабе `wiki`, токен —
|
||||||
|
`jira-hub tokens` → «агент wiki».
|
||||||
|
|
||||||
|
Владелец 24.10.2026 разрешил делать агента ночью без обсуждения («завтра хочу уже иметь рабочий mvp»). Поэтому все
|
||||||
|
решения ниже приняты агентом и перечислены в разделе «Decisions made without discussion».
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Источник для копирования — jira-agent v14** (`../jira-agent/src/agent.py`, `run.py`). Его части, не связанные с
|
||||||
|
Jira, отлажены с v2 по v14:
|
||||||
|
- HTTP-слой: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect`;
|
||||||
|
- транспорт `FolderTransport` и клиент хаба `HubClient`;
|
||||||
|
- журнал `Journal`, цикл `Runner`, самообновление `Updater`;
|
||||||
|
- `acquire_lock`, CLI `run|check|selftest|prune`, `launcher_version`.
|
||||||
|
- **Клиент Eva и обход дерева — по образцу** `../eva_wiki_export.py`:
|
||||||
|
- JSON-RPC 2.2: `POST <base>/api/?m=<метод>`, `Authorization: Bearer`, тело `{jsonrpc: "2.2", method, callid,
|
||||||
|
kwargs}`;
|
||||||
|
- повторы на 429/502/503/504 и сетевых сбоях;
|
||||||
|
- `CmfDocument.get`/`list`, страницы списка по 200;
|
||||||
|
- дерево — по `parent`/`tree_parent` из списка документов проекта (`project.code`), запасной путь — обход через
|
||||||
|
`tree_parent.code`.
|
||||||
|
- **Хаб принимает:**
|
||||||
|
- пачки `POST /agent/wiki/pages {command_id, pages}` — до 50 страниц, тело до 16 МБ (`MAX_BODY` хаба);
|
||||||
|
- форму страницы проверяет `modules/wiki/stage.py`;
|
||||||
|
- отчёт `wiki_sync done {root_title, codes, eva_calls}`: `codes[0]` должен быть кодом корня;
|
||||||
|
- отчёт `wiki_fetch done {file, size}`: файл должен лежать в хранилище хаба с этим размером.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
Дата всех решений — 2026-10-24.
|
||||||
|
|
||||||
|
1. **Скопировать общие части jira-agent v14, а не выделять библиотеку.**
|
||||||
|
Обоснование: решение владельца 23.10 — однофайловый агент на stdlib на виртуалке. Копия сохраняет отлаженное
|
||||||
|
поведение.
|
||||||
|
Цена ошибки: правки общих частей придётся повторять в двух агентах.
|
||||||
|
2. **`run.py` — дословная копия запускателя jira-agent** (`LAUNCHER_VERSION = 2`), вместе с переменной окружения
|
||||||
|
`JIRA_AGENT_LAUNCHER`.
|
||||||
|
Обоснование: в протоколе самообновления важны имена `agent.py`, `update.json` и `AGENT_VERSION`; переменная —
|
||||||
|
внутреннее дело пары запускатель–агент.
|
||||||
|
Цена ошибки: переименование переменной в обоих файлах.
|
||||||
|
3. **Конфиг `agent.json`:**
|
||||||
|
- обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`;
|
||||||
|
- необязательны `verify_ssl`, `ca_bundle`, `timeout` (60), `poll_seconds` (5), `auto_update` (true).
|
||||||
|
|
||||||
|
Обоснование: формат jira-agent, где ключ `jira` заменён на `eva`.
|
||||||
|
Цена ошибки: правка шаблона и `load_config`.
|
||||||
|
4. **Пауза между вызовами Eva — `wiki_pacing` из `GET /agent/settings`** (по умолчанию 0.2 с).
|
||||||
|
- 429/502/503/504 и сетевые сбои: до 4 попыток с ожиданием 1, 2, 4 с.
|
||||||
|
- При 429 учитывается `Retry-After` (не больше 60 с).
|
||||||
|
- Число 429 уходит в `rate_limited` отчёта, число вызовов Eva — в `jira_calls`: поле протокола хаба, имя общее
|
||||||
|
для всех агентов.
|
||||||
|
|
||||||
|
Цена ошибки: Eva режет запросы; правится настройкой без выпуска агента.
|
||||||
|
5. **`wiki_sync` — порядок работы:**
|
||||||
|
1. Корень берётся через `CmfDocument.get` по коду из `params.root`. Нет корня — `failed` «страница <код> не найдена
|
||||||
|
или нет прав».
|
||||||
|
2. Дерево строится как в `collect_subtree`.
|
||||||
|
3. Обход в глубину от корня с защитой от циклов (`seen` по `id`). Соседи — по `(orderno is None, orderno, name)`.
|
||||||
|
4. По каждой странице отдельный `CmfDocument.get(fields=["code","name","text"])`.
|
||||||
|
5. Страницы уходят пачками по мере обхода: 50 страниц или 4 МБ JSON, что наступит раньше.
|
||||||
|
6. Отчёт `codes` — коды в порядке обхода, корень первым.
|
||||||
|
- Поля страницы: `eva_id = str(id)`, `parent_code` — код родителя по обходу (у корня `null`), `orderno` — целое или
|
||||||
|
`null`, `url = urljoin(base_url + "/", "project/Document/<code>")`.
|
||||||
|
|
||||||
|
Цена ошибки: дерево не совпадёт с Eva; лечится правкой обхода и новой версией через самообновление.
|
||||||
|
6. **Ссылки разбирает `html.parser.HTMLParser` из stdlib.**
|
||||||
|
- Берутся `img[src]` и `a[href]`.
|
||||||
|
- Адрес разрешается `urljoin(base_url + "/", ref)`. «Свой» — тот же `scheme` и `netloc`, что у `eva.base_url`.
|
||||||
|
- **Вложение** — свой адрес с путём, начинающимся на `/files/`. Из `url` убираются query и fragment. Миниатюра
|
||||||
|
`<путь>.meta/thumbnail.jpg` означает файл `<путь>`.
|
||||||
|
- **Ссылка на страницу** — свой адрес с `DOC-<цифры>` в пути (код в верхнем регистре).
|
||||||
|
- **`static/…`** и прочие ссылки не передаются: значки убирает хаб, прочие остаются в тексте.
|
||||||
|
- **Имя вложения** — `alt` или `title`, затем текст ссылки, затем последний сегмент пути после `unquote`.
|
||||||
|
- Каждая ссылка передаётся отдельной записью `{ref, url, name}` или `{ref, code}`, `ref` — ровно значение
|
||||||
|
атрибута. Повторы по `url` склеивает хаб.
|
||||||
|
|
||||||
|
Обоснование: форматы из выгрузки ТЗ АРМ КА (design хаба, Context).
|
||||||
|
Цена ошибки: вложение продублируется или потеряется; проверяется на первой живой синхронизации, правится агентом.
|
||||||
|
7. **Без `?version=1` в `url` вложения.**
|
||||||
|
Обоснование: миниатюра и файл дают один адрес, хаб склеивает вложение.
|
||||||
|
Цена ошибки: если Eva без `version` не отдаёт файл, `wiki_fetch` падает; правка одной строки.
|
||||||
|
8. **`wiki_fetch`:**
|
||||||
|
- адрес не своего хоста — `failed` «чужой хост», запроса нет;
|
||||||
|
- файл скачивается с Bearer-токеном Eva потоком до 10 МБ + 1 байт;
|
||||||
|
- `Content-Length` больше 10 МБ или прочитано больше — `failed` «файл больше 10 МБ — откройте в Eva»;
|
||||||
|
- иначе `POST /agent/files` и `done {file, size}`;
|
||||||
|
- HTTP 404 — `failed` «файл не найден в Eva».
|
||||||
|
|
||||||
|
Цена ошибки: большие файлы только ссылкой.
|
||||||
|
9. **Обе команды только читают Eva, поэтому восстановление после перезапуска выполняет их заново.** Журнал нужен
|
||||||
|
только для досылки отчёта; `wiki_sync` не продолжается с середины. Хаб очищает стадию при повторной выдаче.
|
||||||
|
Цена ошибки: лишняя полная перекачка после перезапуска.
|
||||||
|
10. **`check`:**
|
||||||
|
- хаб: `GET /agent/settings` своим токеном;
|
||||||
|
- Eva: `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`;
|
||||||
|
- вывод «хаб: ok», «eva: ok» или текст ошибки, код 0 или 3, как у jira-agent;
|
||||||
|
- `selftest` — версия плюс `check`.
|
||||||
|
11. **Тесты:**
|
||||||
|
- `unittest` на `/usr/bin/python3` (3.9);
|
||||||
|
- фейки `tests/fake_hub.py` (как у jira-agent, плюс `POST /agent/wiki/pages`), `tests/fake_eva.py` (JSON-RPC на
|
||||||
|
`http.server`), `tests/fake_relay.py`;
|
||||||
|
- `tests/test_py39.py` — копия.
|
||||||
|
- Тесты общих частей переносятся из jira-agent и адаптируются: журнал, транспорт, самообновление, запускатель.
|
||||||
|
12. **`ref` вложений и ссылок — значение атрибута после unescape** (так отдаёт `html.parser`).
|
||||||
|
Обоснование: хаб (`convert.py`, BeautifulSoup `.get("src"/"href")`) ищет запись по тому же unescaped-значению.
|
||||||
|
Цена ошибки: адреса с `&` не сопоставятся, вложение останется неразрешённым; правка одной строки.
|
||||||
|
13. **При расхождении плана с кодом хаба по протоколу прав код хаба.** Поручение владельца; расхождения фиксируются
|
||||||
|
находками `O-n`, хаб не правится.
|
||||||
|
Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e.
|
||||||
|
14. **Каркас `EvaClient` создан в задаче 1; `Runner(cfg, hub, eva, journal)`.** В `EvaClient` — только Bearer-заголовок,
|
||||||
|
TLS-настройки, счётчики `calls`/`rate_limited` и время последнего вызова (`last_call_at`, `last_call_iso`).
|
||||||
|
Обоснование: `Runner` и `Executor` из jira-agent опираются на эти поля клиента (пауза между командами, отчёт); без
|
||||||
|
каркаса общие тесты цикла не перенести. JSON-RPC и повторы добавляют задачи 2–3.
|
||||||
|
Цена ошибки: перенос полей в другой класс.
|
||||||
|
15. **`claim` шлёт `last_call_at`, а не `last_jira_call_at`.**
|
||||||
|
Обоснование: хаб (`core/api.py`) читает `body.get("last_call_at", body.get("last_jira_call_at"))`; новое имя не
|
||||||
|
привязано к Jira. Поля отчёта `jira_calls`/`rate_limited` остаются как есть: хаб читает только их.
|
||||||
|
Цена ошибки: если хаб уберёт новое имя, время последнего вызова перестанет доходить (только статус).
|
||||||
|
16. **Jira-специфичные тесты jira-agent не переносились; `test_clients.py` написан заново на `MiniServer`.**
|
||||||
|
Исключены: `JiraClient` (multipart, 429/503), `Executor`, `refresh`, история, вложения Jira. Сценарии `Http`
|
||||||
|
(перенаправления, обрывы, не-JSON, предел `fetch_bytes`) сохранены на маленьком локальном сервере, фейка Eva в
|
||||||
|
задаче 1 нет (появляется в задаче 2).
|
||||||
|
Обоснование: брифом перенос ограничен общими частями; Jira в агенте нет.
|
||||||
|
Цена ошибки: пропущенный сценарий `Http`; виден по сравнению со списком тестов jira-agent.
|
||||||
|
17. **Тесты цикла работают на заглушке `wiki_fetch` (`failed` «не реализовано»); паузу и время вызова Eva тесты
|
||||||
|
задают вручную (`eva.last_call_at`, `eva._touch()`).** Результат `done` проверяется через подмену
|
||||||
|
`Executor.run`/`do_wiki_fetch`.
|
||||||
|
Обоснование: уточнение к задаче 1. Цена ошибки: тесты цикла придётся уточнить, когда команды заработают.
|
||||||
|
18. **`test_run_py_is_copy` ищет `jira-agent/src/run.py` на 1–3 уровня выше корня репозитория; нет файла — skip с
|
||||||
|
причиной.** Путь из плана (`../../jira-agent`) верен для основного checkout, из worktree `.wt/<срез>` — на уровень
|
||||||
|
глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов.
|
||||||
|
19. **`.gitignore` дополнен `agent.py.new`, `agent.py.prev`, `__pycache__/`, `*.pyc`** (рабочие файлы самообновления
|
||||||
|
и кэш). Цена ошибки: нет.
|
||||||
|
20. **`EvaClient(cfg)` остаётся с сигнатурой каркаса (по всему `cfg`), а не `(cfg_eva, timeout, verify_ssl, ca_bundle)` из брифа.**
|
||||||
|
Обоснование: `Runner`/`main` уже создают клиента как `EvaClient(cfg)` (решение 14).
|
||||||
|
Цена ошибки: нет, внутренняя сигнатура.
|
||||||
|
21. **`EvaClient.call(method, **kwargs)` переопределяет `Http.call(method, path, ...)`.** Транспортный вызов — через
|
||||||
|
`Http.call(self, ...)`; тесты общего `Http` в `test_clients.py` переведены на `agent.Http.call(client, ...)`.
|
||||||
|
Обоснование: интерфейс клиента Eva задан брифом (метод JSON-RPC первым аргументом).
|
||||||
|
Цена ошибки: путаница сигнатур; видна при чтении класса.
|
||||||
|
22. **Пауза между вызовами — перед вызовом (и перед повтором) по `last_call_at`; первый вызов после старта не
|
||||||
|
задерживается. `Runner.load_settings` ставит `eva.pacing` из `wiki_pacing` (по умолчанию 0.2).**
|
||||||
|
Обоснование: `last_call_at` общий с паузой между командами. Цена ошибки: лишняя или недостающая пауза, правится настройкой.
|
||||||
|
23. **Токен вырезается (`***`) из текстов `HttpError`/`ConnectionError`/`EvaError`, выходящих из `EvaClient`.**
|
||||||
|
Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе.
|
||||||
|
24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.**
|
||||||
|
Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет.
|
||||||
|
25. **`classify_links`: уточнения разбора (задача 3).**
|
||||||
|
- Миниатюра определяется по хвосту пути `.meta/thumbnail.jpg` (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется.
|
||||||
|
- Имя: `alt`/`title` → текст `<a>` (пробелы схлопнуты) → `unquote` последнего сегмента пути.
|
||||||
|
- Порядок записей — порядок открывающих тегов (для `<a><img></a>` сначала ссылка, потом картинка); повторы не склеиваются.
|
||||||
|
- Код страницы — первое `DOC-<цифры>` в пути (регистр не важен, результат в верхнем регистре); `/files/…` приоритетнее кода.
|
||||||
|
- `ref` — значение после unescape; сверено с хабом: `convert.py` ищет по `el.get("src"/"href")` BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет).
|
||||||
|
- В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`).
|
||||||
|
|
||||||
|
Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации.
|
||||||
|
|
||||||
|
26. **Обход дерева `wiki_sync` ведётся по коду страницы, а не по `id` (уточнение решения 5).**
|
||||||
|
- `children`, `seen` и `parent_code` ключуются кодом (`ref.code`): код уникален и есть в каждой ссылке `parent`/`tree_parent`; `id` берётся только для `eva_id`.
|
||||||
|
- Соседи сортируются по `(orderno is None, orderno, name без регистра, code)` — как `collect_subtree` экспорта; решение 5 говорит «name», хвост по коду добавлен для детерминизма.
|
||||||
|
- Дерево с проектом — один список документов проекта; без проекта — обход в ширину через `tree_parent.code` (защита от циклов тоже по коду).
|
||||||
|
|
||||||
|
Обоснование: ссылка Eva в фейке и в экспорте гарантированно несёт `code`; поле `id` в ссылке может не совпасть с `id` документа. Цена ошибки: если коды в Eva неуникальны (не бывает), страницы склеятся.
|
||||||
|
27. **Страница без названия получает `title = code`; корень без названия — `root_title = code`.**
|
||||||
|
Обоснование: `stage._check` хаба отклоняет пустой `title` (422 `<код>: title`), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени.
|
||||||
|
28. **Страница, исчезнувшая между списком и чтением (`get_doc` → `None`), — `failed` «страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается.**
|
||||||
|
Обоснование: отчёт `codes` обязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация.
|
||||||
|
29. **Размер пачки считается тем же кодированием, что у `Http.call` (`ensure_ascii=False`, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ).**
|
||||||
|
Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ.
|
||||||
|
30. **`fake_hub.POST /agent/wiki/pages` проверяет пачку как `stage.accept`/`_check` хаба (≤ 50 страниц, `command_id` целое, команда `wiki_sync` в статусе `claimed`, типы и пределы полей, форма `attachments`/`links`) и отвечает `{error, detail}` с 422/404/409.** Тесты `test_folder.py` приведены к валидным страницам и выданной команде.
|
||||||
|
Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e.
|
||||||
|
31. **Решение 26 уточнено (T4.1): ключ дерева — код страницы, но ссылку `parent`/`tree_parent` в код разрешает `build_children`.**
|
||||||
|
- Ссылка Eva приходит то объектом `{id, code, ...}`, то строкой-id, то объектом без `code` (так говорит `ref_id` эталонного экспорта).
|
||||||
|
- `build_children` строит карту `str(id) → code` по документам проекта и корню. Ссылка с `code` даёт код сразу; иначе берётся `id` (из dict или сама строка) и ищется в карте. Неразрешённая ссылка (родитель в другом проекте) пропускается.
|
||||||
|
- Без проекта (фильтр `tree_parent.code`) разрешение не нужно: родитель известен по запросу.
|
||||||
|
- `FakeEva`: id документов отличны от кодов; ссылки по умолчанию `{"id": "<id документа>", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`.
|
||||||
|
|
||||||
|
Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена).
|
||||||
|
|
||||||
|
32. **`wiki_fetch` (задача 5): уточнения.**
|
||||||
|
- Проверка `Content-Length` вынесена в `Http.fetch_bytes`: заявленная длина больше предела — `TooBig` до чтения тела; без заголовка работает потоковое чтение limit + 1 байт. Предел включительно: ровно 10 МБ принимается.
|
||||||
|
- Ошибки: чужой хост (`ValueError` из `download`) → «чужой хост»; `TooBig` → «файл больше 10 МБ — откройте в Eva» (как в спеке хаба); HTTP 404 → «файл не найден в Eva»; перенаправление на другой адрес → «Eva перенаправила скачивание на <хост>»; прочее — общие правила `Executor.run` (5xx и сеть повторяются).
|
||||||
|
- Пустой файл (0 байт): хаб принимает его от агента (`POST /agent/files` → `files.put(allow_empty=True)`, решение 13 хаба; `check_result` сверяет размер 0 с `files.size`, `None` — только «нет файла»), поэтому агент отчитывается `done {file, size: 0}` без особых случаев. Расхождения с брифом нет; `fake_hub` повторяет это и добавил 413 для файла > 10 МБ, как `files.put`.
|
||||||
|
- Результат `{attachment_id, file, size}`; `size` берётся из ответа хаба. Содержимое в лог не пишется (только размер); в памяти одна копия данных, base64 строится на время отправки.
|
||||||
|
- Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva).
|
||||||
|
|
||||||
|
Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой.
|
||||||
|
|
||||||
|
33. **`check` (задача 6): код возврата 0 или 3, оба узла проверяются всегда.**
|
||||||
|
- Хаб — `GET /agent/settings`, Eva — `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`; вывод «хаб: доступен…», «eva: ok» или «eva: НЕДОСТУПНА / ошибка — <текст>». Недоступность хаба не мешает проверить Eva.
|
||||||
|
- Код недоступности — 3 (design 10), а не 1, как у каркаса из jira-agent; `selftest` по-прежнему 0 или 3. Тесты каркаса приведены: `test_check_command` и `test_check_folder_silent_relay` ждут 3; `FolderIntegrationTest` и `SelfUpdateE2E` поднимают `FakeEva` (selftest = check, без доступной Eva самообновление откладывается).
|
||||||
|
- У Eva в `check` одна попытка (`ATTEMPTS = 1`): проверка отвечает сразу, без повторов с паузами 1+2+4 с. Токен Eva в тексте ошибки заменяется на `***` (решение 23).
|
||||||
|
|
||||||
|
Обоснование: design 10 и поручение владельца «коды как у jira-agent (0 или 3)». Цена ошибки: скрипт установки, ожидающий 1, увидит 3; на самообновление не влияет (там `selftest`).
|
||||||
|
|
||||||
|
34. **Флаг `agent.py run --once` для сквозного прогона.** Ставит `runner.idle_limit = 1`: агент выполняет выданное и выходит при первом пустом claim (код 0). Протокол с хабом не менялся, `Runner.idle_limit` уже был в каркасе (используют тесты).
|
||||||
|
Обоснование: прогон делает две фазы (`wiki_sync`, затем `wiki_fetch`, который клиент ставит после чтения страницы), агенту нужен выход между ними. Цена ошибки: нулевая для боевого режима (без флага поведение прежнее). Пустой claim хаб держит ~30 с, поэтому каждая фаза e2e длится около 30 с.
|
||||||
|
|
||||||
|
35. **Сквозной прогон: `scripts/e2e-hub.sh` + `scripts/fake_eva_serve.py`.**
|
||||||
|
- Хаб — `uv run --project <jira-hub/.wt/wiki> --no-sync jira-hub serve --host 127.0.0.1 --port <свободный>` с `JIRA_HUB_HOME=$(mktemp -d)/hub-home`; порт 8790 отвергается явно. `--no-sync`, чтобы прогон не менял `.venv` соседнего worktree.
|
||||||
|
- Токены берутся из `jira-hub tokens` (клиентский и «агент wiki»); транспорт агента — прямой HTTP, `auto_update=false`.
|
||||||
|
- Фейковая Eva (`tests/fake_eva.py`) запускается отдельным процессом, 5 страниц в проекте `E2E`, картинка `/files/obj/e2e/scheme.png`; проект создаётся `POST /projects {label, name, wiki_root}`, затем `wiki_sync`, `GET /wiki/status|tree|pages/DOC-2`, `POST /wiki/attachments/<id>/fetch`, `wiki_fetch`, `GET …/content` и сверка sha256.
|
||||||
|
- Завершение: `trap` останавливает дерево процессов (`uv run` порождает хаб дочерним процессом: простой `kill` оставлял хаб жить) с `kill -9` как запасным вариантом; временный каталог удаляется. Готовность хаба — опрос порта `nc -z` с шагом 0,1 с.
|
||||||
|
- Хаб принял всё, что требует его спека (расхождений с хабом не найдено).
|
||||||
|
|
||||||
|
Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на `POST /projects` или `tokens` — это сигнал, а не порча данных.
|
||||||
|
|
||||||
|
36. **`classify_links` не падает на нестандартном HTML и битых адресах (I-1).**
|
||||||
|
- На Python 3.9 `_markupbase` зовёт `error()` на `<![foo]>`, `<![ ]>` и подобном, а базовый `error()` бросает
|
||||||
|
`NotImplementedError`. `_LinkParser.error()` бросает своё `_ParseError`, а `parse_marked_section` ловит его и
|
||||||
|
пропускает конструкцию до `>` (как «мусорный комментарий»; нет `>` — ждёт данных, при `close()` её пропускает
|
||||||
|
`goahead`). Известные секции (`<![if …]>`, `<![endif]>`, `<![CDATA[…]]>`) разбираются как раньше.
|
||||||
|
- Простой no-op `error()` не годится: после него `parse_marked_section` падает на несвязанной переменной `match`, а
|
||||||
|
`_scan_name` — на распаковке `None`.
|
||||||
|
- Запасной путь: `feed`/`close` обёрнуты в `try` (`_ParseError`, `NotImplementedError`, `AssertionError`), собранное до
|
||||||
|
сбоя сохраняется, в лог — строка.
|
||||||
|
- Ссылка, на которой `urljoin`/`urlsplit` бросают `ValueError` (`http://[::1`), пропускается, остальные остаются.
|
||||||
|
|
||||||
|
Обоснование: одна битая конструкция в HTML страницы роняла весь `wiki_sync` «внутренней ошибкой агента».
|
||||||
|
Цена ошибки: ссылка внутри нераспознанной секции не попадёт в `attachments`/`links` (останется в HTML как есть).
|
||||||
|
37. **`wiki_fetch`: адрес кодируется перед запросом, отказ по хосту — `ForeignHost` (I-2).**
|
||||||
|
- Путь и запрос после `base_url` кодируются `quote(…, safe="/%:@!$&'()*+,;=-._~")` (у запроса ещё `?`); готовые
|
||||||
|
`%XX` не кодируются повторно, одиночный `%` без двух шестнадцатеричных цифр становится `%25`; фрагмент `#…`
|
||||||
|
отбрасывается.
|
||||||
|
- `download` бросает `ForeignHost("чужой хост")` (другие схема, хост, порт, userinfo, адрес не под `base_url`) или
|
||||||
|
`ForeignHost("некорректный адрес")` (`urlsplit` не разбирает: `http://[::1`, порт не числом). `do_wiki_fetch`
|
||||||
|
ловит только `ForeignHost`; прочие `ValueError` (в том числе `UnicodeEncodeError`) уходят в «внутреннюю ошибку
|
||||||
|
агента», а не маскируются под «чужой хост».
|
||||||
|
- `url` вложения в `classify_links` не перекодируется: это ключ склейки вложений в хабе, менять его форму — вне MVP.
|
||||||
|
|
||||||
|
Обоснование: имена файлов в Eva с пробелами и кириллицей; без кодирования `http.client` отказывал (`InvalidURL`
|
||||||
|
повторялся как обрыв связи, `UnicodeEncodeError` выдавался за «чужой хост»).
|
||||||
|
Цена ошибки: если Eva ждёт путь в другой кодировке (не UTF-8), файл не найдётся (404 «файл не найден в Eva»).
|
||||||
|
38. **`EvaError` в команде — `failed` «Eva: <текст>» без повтора (M-1).** Текст уже очищен от токена (решение 23).
|
||||||
|
Обоснование: ошибка JSON-RPC (нет прав, неверный фильтр) — ответ Eva, а не сбой агента; повтор не поможет.
|
||||||
|
Цена ошибки: временная ошибка Eva, пришедшая в `error`, не повторится автоматически — повторяет пользователь.
|
||||||
|
39. **413 хаба на пачку страниц — ошибка с кодом страницы или границами пачки (M-3).** «хаб: HTTP 413: страница DOC-x
|
||||||
|
больше 16 МБ — хаб не принял: <ответ хаба>» для пачки из одной страницы; «пачка страниц DOC-a…DOC-b (N шт.) больше
|
||||||
|
16 МБ …» иначе. Не повторяется. Пачка из нескольких страниц больше 16 МБ невозможна (пачка закрывается на 4 МБ), но
|
||||||
|
текст на этот случай есть.
|
||||||
|
Обоснование: по «HTTP 413» без кода не понять, какую страницу Eva чистить. Цена ошибки: нет.
|
||||||
|
40. **`list_docs` останавливается, если ответ страницы не приносит новых записей (M-4).** Записи склеиваются по
|
||||||
|
JSON-представлению (`sort_keys`); полная страница без новых записей — останов с записью в лог, собранное
|
||||||
|
возвращается. Так ловятся оба случая «Eva игнорирует `slice`»: весь список каждый раз и одна и та же первая страница.
|
||||||
|
Обоснование: иначе бесконечный цикл вызовов Eva. Цена ошибки: если Eva игнорирует `slice` и отдаёт только первые
|
||||||
|
N записей, дерево будет неполным без явного отказа (видно в логе).
|
||||||
|
41. **`Executor(eva, hub)`: удалены неиспользуемые `Executor.s` (настройки) и `Executor.sleep` (M-7).** Настройки,
|
||||||
|
нужные исполнителю (`wiki_pacing`), ставит `Runner.load_settings` прямо в `EvaClient.pacing`.
|
||||||
|
Цена ошибки: нет.
|
||||||
|
42. **Перенаправление Eva на SSO (M-5) не чинится до живой проверки.** Сейчас перенаправление на другой адрес
|
||||||
|
отклоняется (`RedirectAway` → «перенаправление … отклонено»), страница входа с кодом 200 даёт «ответ не JSON».
|
||||||
|
Как именно Eva банка отвечает на протухший токен, неизвестно; чинить вслепую — угадывать формат. В README и
|
||||||
|
ARCHITECTURE строка «проверить при первой живой синхронизации».
|
||||||
|
Цена ошибки: при протухшем токене текст ошибки менее понятен («ответ не JSON» вместо «токен Eva недействителен»).
|
||||||
|
43. **Документация (I-3, T6.1, T6.4, M-2, M-6).**
|
||||||
|
- Выкатка — `jira-hub agent publish --module wiki src/agent.py` (без `--module` хаб публикует версию агенту `jira`,
|
||||||
|
`registry.DEFAULT_AGENT`); порог — `wiki_min_agent_version` (ключ модуля wiki хаба, `agent_min_version_key`).
|
||||||
|
- `eva.base_url` — только origin: к нему добавляются `/api/`, `/files/…`, `/project/Document/<код>`, и с ним же
|
||||||
|
сравнивается адрес вложения. `agent.example.json` уже содержит origin без пути (`https://eva.example.ru`); JSON
|
||||||
|
без комментариев, поэтому пояснение — в README.
|
||||||
|
- Агент — только macOS (`fcntl` в `acquire_lock`): из README убраны `C:\…` и «планировщик задач», добавлен пример
|
||||||
|
launchd с меткой `ru.aepif.eva-agent` по образцу jira-agent.
|
||||||
|
|
||||||
|
Цена ошибки: нет.
|
||||||
|
44. **Финальная волна правок — один fixer, объём: I-1, I-2, I-3, M-1–M-4, M-6, M-7, T6.1, T6.4, T5.2; M-5 не чинится (R-14).**
|
||||||
|
Обоснование: M-5 (отказ на `text/html`) мог бы ложно отвергнуть настоящие `.html`-вложения; поведение Eva при
|
||||||
|
просроченном токене проверяется на первой живой синхронизации.
|
||||||
|
Цена ошибки: при SSO-редиректе страница входа сохранится как вложение; правится одной проверкой Content-Type.
|
||||||
|
45. **T2.3 и T5.1 отклонены по итогам финального ревью (R-15).** `Http.__init__` делает `base.rstrip("/")`; размер
|
||||||
|
из ответа хаба равен `len(decoded)`, и хаб сверяет его с `files.size`.
|
||||||
|
Цена ошибки: нет (оба подтверждены ревьюером по коду).
|
||||||
17
openspec/changes/eva-agent-mvp/proposal.md
Normal file
17
openspec/changes/eva-agent-mvp/proposal.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Модулю `wiki` хаба (jira-hub#91, PR #92) нужен агент на виртуалке банка: Eva доступна только из сети банка, токен Eva
|
||||||
|
не должен покидать виртуалку. Решение владельца 23.10.2026: отдельный агент, не jira-agent.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Новый агент `eva-agent`: `src/agent.py` (Python 3.9, stdlib) и `src/run.py`. Транспорт, журнал и самообновление
|
||||||
|
такие же, как у jira-agent v14.
|
||||||
|
- Команды хаба `wiki_sync` и `wiki_fetch`. Их контракт описан в `jira-hub/openspec/changes/wiki-module/specs/wiki/spec.md`.
|
||||||
|
- README по установке на виртуалку и сквозной прогон с тестовым хабом.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Запись в Eva и история изменений.
|
||||||
|
- Частичная перекачка дерева (нужна дата изменения документа в Eva).
|
||||||
|
- Общая библиотека с jira-agent.
|
||||||
108
scripts/e2e-hub.sh
Executable file
108
scripts/e2e-hub.sh
Executable file
@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Сквозной прогон eva-agent с настоящим хабом (модуль wiki) и фейковой Eva. Печатает «e2e: ok».
|
||||||
|
# Хаб — ТОЛЬКО из worktree jira-hub/.wt/wiki, ТОЛЬКО с временным JIRA_HUB_HOME и портом не 8790; живой хаб не трогаем.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HUB_SRC="${HUB_SRC:-$HERE/../../../jira-hub/.wt/wiki}"
|
||||||
|
HUB_SRC="$(cd "$HUB_SRC" && pwd)"
|
||||||
|
PORT="${E2E_PORT:-$(/usr/bin/python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')}"
|
||||||
|
[ "$PORT" != "8790" ] || { echo "e2e: порт 8790 занят живым хабом" >&2; exit 2; }
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
export JIRA_HUB_HOME="$TMP/hub-home"
|
||||||
|
mkdir -p "$JIRA_HUB_HOME"
|
||||||
|
PIDS=()
|
||||||
|
stop_tree() { # остановить процесс и его детей (uv run запускает хаб дочерним процессом)
|
||||||
|
local p="$1" c
|
||||||
|
for c in $(pgrep -P "$p" 2>/dev/null || true); do stop_tree "$c"; done
|
||||||
|
kill "$p" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
cleanup() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
for p in ${PIDS[@]+"${PIDS[@]}"}; do stop_tree "$p"; done
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
alive=0
|
||||||
|
for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -0 "$p" 2>/dev/null && alive=1; done
|
||||||
|
[ "$alive" = 0 ] && break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
for p in ${PIDS[@]+"${PIDS[@]}"}; do kill -9 "$p" 2>/dev/null || true; done
|
||||||
|
rm -rf "$TMP"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
hub() { uv run --project "$HUB_SRC" --no-sync jira-hub "$@"; }
|
||||||
|
fail() { echo "e2e: ОШИБКА — $*" >&2; echo "--- лог хаба ---" >&2; tail -n 30 "$TMP/hub.log" >&2 || true; exit 1; }
|
||||||
|
jget() { /usr/bin/python3 -c 'import json,sys; d=json.load(sys.stdin)
|
||||||
|
for k in sys.argv[1].split("."):
|
||||||
|
d = d[int(k)] if isinstance(d, list) else d[k]
|
||||||
|
print(d)' "$1"; }
|
||||||
|
|
||||||
|
# --- токены (создают базу во временном каталоге)
|
||||||
|
TOKENS="$(hub tokens)"
|
||||||
|
CLIENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^клиентский токен: //p')"
|
||||||
|
AGENT_TOKEN="$(printf '%s\n' "$TOKENS" | sed -n 's/^агент wiki: //p')"
|
||||||
|
[ -n "$CLIENT_TOKEN" ] && [ -n "$AGENT_TOKEN" ] || fail "токены не выданы: $TOKENS"
|
||||||
|
|
||||||
|
# --- фейковая Eva
|
||||||
|
EVA_TOKEN="eva-e2e-secret"
|
||||||
|
/usr/bin/python3 "$HERE/scripts/fake_eva_serve.py" "$EVA_TOKEN" >"$TMP/eva.out" 2>"$TMP/eva.err" &
|
||||||
|
PIDS+=($!)
|
||||||
|
for _ in $(seq 1 50); do grep -q '^PORT ' "$TMP/eva.out" 2>/dev/null && break; sleep 0.1; done
|
||||||
|
EVA_PORT="$(sed -n 's/^PORT //p' "$TMP/eva.out")"
|
||||||
|
[ -n "$EVA_PORT" ] || fail "фейковая Eva не поднялась: $(cat "$TMP/eva.err")"
|
||||||
|
|
||||||
|
# --- хаб
|
||||||
|
hub serve --host 127.0.0.1 --port "$PORT" >"$TMP/hub.log" 2>&1 &
|
||||||
|
PIDS+=($!)
|
||||||
|
HUB="http://127.0.0.1:$PORT"
|
||||||
|
for _ in $(seq 1 100); do
|
||||||
|
nc -z 127.0.0.1 "$PORT" 2>/dev/null && break
|
||||||
|
kill -0 "${PIDS[1]}" 2>/dev/null || fail "хаб завершился при старте"
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
nc -z 127.0.0.1 "$PORT" 2>/dev/null || fail "хаб не открыл порт $PORT"
|
||||||
|
|
||||||
|
api() { # api METHOD PATH [json]
|
||||||
|
local m="$1" p="$2"; shift 2
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" -H 'Content-Type: application/json' -d "$1" "$HUB$p"
|
||||||
|
else
|
||||||
|
curl -fsS -X "$m" -H "Authorization: Bearer $CLIENT_TOKEN" "$HUB$p"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- конфиг агента (прямой HTTP к тестовому хабу, без самообновления)
|
||||||
|
cat >"$TMP/agent.json" <<JSON
|
||||||
|
{"hub_url": "$HUB", "hub_token": "$AGENT_TOKEN",
|
||||||
|
"eva": {"base_url": "http://127.0.0.1:$EVA_PORT", "token": "$EVA_TOKEN"},
|
||||||
|
"verify_ssl": true, "ca_bundle": null, "timeout": 30, "poll_seconds": 0, "auto_update": false}
|
||||||
|
JSON
|
||||||
|
agent() { /usr/bin/python3 "$HERE/src/agent.py" --config "$TMP/agent.json" --journal "$TMP/journal.jsonl" "$@"; }
|
||||||
|
|
||||||
|
agent check || fail "agent.py check"
|
||||||
|
|
||||||
|
# --- проект с корнем вики -> пакет wiki_sync
|
||||||
|
api POST /projects '{"label":"E2E","name":"Сквозной прогон","wiki_root":"DOC-1"}' >"$TMP/project.json" || fail "POST /projects"
|
||||||
|
BATCH="$(jget batch_id <"$TMP/project.json")"
|
||||||
|
agent run --once || fail "agent.py run --once (wiki_sync)"
|
||||||
|
|
||||||
|
STATUS="$(api GET "/wiki/status?project=E2E")"
|
||||||
|
[ "$(printf '%s' "$STATUS" | jget pages)" = "5" ] || fail "wiki/status: ожидалось 5 страниц: $STATUS"
|
||||||
|
TREE="$(api GET "/wiki/tree?project=E2E")"
|
||||||
|
[ "$(printf '%s' "$TREE" | /usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin)["pages"]))')" = "5" ] || fail "wiki/tree: $TREE"
|
||||||
|
|
||||||
|
PAGE="$(api GET "/wiki/pages/DOC-2?project=E2E")"
|
||||||
|
printf '%s' "$PAGE" | jget markdown | grep -q 'attachment:[0-9]' || fail "в Markdown DOC-2 нет ссылки attachment:<id>: $PAGE"
|
||||||
|
ATT_ID="$(printf '%s' "$PAGE" | jget attachments.0.id)"
|
||||||
|
|
||||||
|
# --- вложение по запросу -> wiki_fetch
|
||||||
|
api POST "/wiki/attachments/$ATT_ID/fetch" '{}' >/dev/null || fail "POST attachments/$ATT_ID/fetch"
|
||||||
|
agent run --once || fail "agent.py run --once (wiki_fetch)"
|
||||||
|
api GET "/wiki/attachments/$ATT_ID/content" >"$TMP/content.bin" || fail "GET attachments/$ATT_ID/content"
|
||||||
|
EXPECT="$(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import fake_eva_serve as f; sys.stdout.buffer.write(f.PNG)' "$HERE/scripts" | shasum -a 256 | cut -d' ' -f1)"
|
||||||
|
GOT="$(shasum -a 256 <"$TMP/content.bin" | cut -d' ' -f1)"
|
||||||
|
[ "$EXPECT" = "$GOT" ] || fail "содержимое вложения не совпало"
|
||||||
|
|
||||||
|
echo "e2e: ok"
|
||||||
38
scripts/fake_eva_serve.py
Normal file
38
scripts/fake_eva_serve.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Фейковая Eva для сквозного прогона (scripts/e2e-hub.sh): дерево из 5 страниц, картинка и файл.
|
||||||
|
|
||||||
|
Печатает «PORT <порт>» и работает до сигнала. Использует tests/fake_eva.py; токен Eva — первым аргументом.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tests"))
|
||||||
|
from fake_eva import FakeEva # noqa: E402
|
||||||
|
|
||||||
|
PNG = bytes.fromhex("89504e470d0a1a0a") + b"e2e-attachment-content"
|
||||||
|
|
||||||
|
|
||||||
|
def build(eva: FakeEva) -> None:
|
||||||
|
img = '<p>Схема:</p><img alt="Схема" src="/files/obj/e2e/scheme.png">'
|
||||||
|
eva.add_doc(1, "DOC-1", "Корень e2e", project="E2E", text="<p>корень</p>")
|
||||||
|
eva.add_doc(2, "DOC-2", "Требования", parent="DOC-1", project="E2E", orderno=1, text=img)
|
||||||
|
eva.add_doc(3, "DOC-3", "Процессы", parent="DOC-1", project="E2E", orderno=2,
|
||||||
|
text='<p>см. <a href="/project/Document/DOC-2">требования</a></p>')
|
||||||
|
eva.add_doc(4, "DOC-4", "Подпроцесс", parent="DOC-3", project="E2E", text="<p>подпроцесс</p>")
|
||||||
|
eva.add_doc(5, "DOC-5", "Приложение", parent="DOC-1", project="E2E", orderno=3, text="<p>приложение</p>")
|
||||||
|
eva.add_file("/files/obj/e2e/scheme.png", PNG)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
eva = FakeEva()
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
eva.token = sys.argv[1]
|
||||||
|
build(eva)
|
||||||
|
print("PORT %d" % eva.server.server_port, flush=True)
|
||||||
|
threading.Event().wait()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
13
src/agent.example.json
Normal file
13
src/agent.example.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"hub_url": "folder:/путь/к/общей/папке/jira-bridge",
|
||||||
|
"hub_token": "токен агента wiki из `jira-hub tokens`",
|
||||||
|
"eva": {
|
||||||
|
"base_url": "https://eva.example.ru",
|
||||||
|
"token": "токен Eva (Bearer)"
|
||||||
|
},
|
||||||
|
"verify_ssl": true,
|
||||||
|
"ca_bundle": null,
|
||||||
|
"timeout": 60,
|
||||||
|
"poll_seconds": 5,
|
||||||
|
"auto_update": true
|
||||||
|
}
|
||||||
1444
src/agent.py
Normal file
1444
src/agent.py
Normal file
File diff suppressed because it is too large
Load Diff
319
src/run.py
Normal file
319
src/run.py
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""run.py — запускатель jira-agent: запускает agent.py дочерним процессом, ставит новую версию после самообновления
|
||||||
|
агента, откатывает её, если она упала или не вышла на связь, и поднимает агента после падения.
|
||||||
|
Только стандартная библиотека, Python 3.9. agent.py не импортирует: тот меняется, запускатель — нет.
|
||||||
|
Спека: jira-hub/openspec/specs/agent/spec.md, «Запускатель run.py».
|
||||||
|
|
||||||
|
python3 run.py [--config agent.json] [--journal journal.jsonl] # аргументы уходят в agent.py run
|
||||||
|
|
||||||
|
Обновляется вручную (редко): остановить, заменить файл, запустить.
|
||||||
|
"""
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
LAUNCHER_VERSION = 2
|
||||||
|
EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged»
|
||||||
|
EXIT_LOCKED = 73 # агент: «журнал занят другим процессом» — не сбой версии (design 6)
|
||||||
|
TRIAL_SECONDS = 600 # новая версия должна записать ok за это время
|
||||||
|
KILL_GRACE = 60 # после SIGTERM до SIGKILL
|
||||||
|
BACKOFF_START, BACKOFF_MAX = 5, 300
|
||||||
|
HEALTHY_UPTIME = 600 # проработал столько — пауза перезапуска с начала
|
||||||
|
STOP_CODES = (0, 130, -signal.SIGINT) # выход агента после Ctrl-C (-SIGINT — Ctrl-C до установки его обработчиков)
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
print("[%s] run: %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_state(path: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(path: str, state: str, base: Optional[dict], error=None) -> bool:
|
||||||
|
"""Атомарная запись update.json. False — не записано (диск полон): запускатель не падает (design 5)."""
|
||||||
|
base = base or {}
|
||||||
|
data = {"state": state, "from_version": base.get("from_version"), "to_version": base.get("to_version"),
|
||||||
|
"sha256": base.get("sha256"), "error": error,
|
||||||
|
"at": datetime.now(timezone.utc).replace(microsecond=0).isoformat()}
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError as e:
|
||||||
|
log("не удалось записать update.json: %s" % e)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return hashlib.sha256(f.read()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def remove(path: str) -> None:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lock(base_dir: str):
|
||||||
|
"""Один запускатель на каталог. None — уже занято."""
|
||||||
|
f = open(os.path.join(base_dir, "run.py.lock"), "a")
|
||||||
|
try:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
f.close()
|
||||||
|
return None
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
class Launcher:
|
||||||
|
def __init__(self, base_dir: str, args: List[str]):
|
||||||
|
self.dir, self.args = base_dir, list(args)
|
||||||
|
self.agent = os.path.join(base_dir, "agent.py")
|
||||||
|
self.new = os.path.join(base_dir, "agent.py.new")
|
||||||
|
self.prev = os.path.join(base_dir, "agent.py.prev")
|
||||||
|
self.state_path = os.path.join(base_dir, "update.json")
|
||||||
|
self.trial_seconds, self.kill_grace = TRIAL_SECONDS, KILL_GRACE
|
||||||
|
self.backoff = BACKOFF_START
|
||||||
|
self.stopping = False
|
||||||
|
self.interrupted = False # был Ctrl-C: выход агента с 0/130 — остановка человеком, а не сбой версии
|
||||||
|
self.child = None
|
||||||
|
|
||||||
|
# --- сигналы ---------------------------------------------------------------
|
||||||
|
def on_sigint(self, signum, frame):
|
||||||
|
"""Ctrl-C в терминале приходит и агенту (общая группа): пока агент жив — решает он, запускатель только запоминает,
|
||||||
|
что человек остановил (I-1). Без агента (пауза) — выход."""
|
||||||
|
self.interrupted = True
|
||||||
|
if self.child is None:
|
||||||
|
self.stopping = True
|
||||||
|
|
||||||
|
def on_sigterm(self, signum, frame):
|
||||||
|
self.stopping = True
|
||||||
|
child = self.child
|
||||||
|
if child is not None and hasattr(child, "send_signal"):
|
||||||
|
child.send_signal(signal.SIGTERM)
|
||||||
|
|
||||||
|
# --- файлы версии ----------------------------------------------------------
|
||||||
|
def reconcile(self) -> None:
|
||||||
|
"""Сверка update.json с файлами при старте (design 1): запускатель мог упасть посреди подмены или отката."""
|
||||||
|
for tmp in (self.new + ".tmp", self.prev + ".tmp", self.state_path + ".tmp"):
|
||||||
|
remove(tmp)
|
||||||
|
if not os.path.exists(self.agent) and os.path.exists(self.prev):
|
||||||
|
os.replace(self.prev, self.agent)
|
||||||
|
log("agent.py не найден — восстановлен из agent.py.prev")
|
||||||
|
st = read_state(self.state_path)
|
||||||
|
state, sha = (st or {}).get("state"), (st or {}).get("sha256")
|
||||||
|
if state == "staged":
|
||||||
|
if sha and sha256_file(self.agent) == sha: # подмена сделана, trial не записан (M-5)
|
||||||
|
remove(self.new)
|
||||||
|
write_state(self.state_path, "trial", st)
|
||||||
|
log("v%s уже на месте — пробный запуск" % st.get("to_version"))
|
||||||
|
elif sha and os.path.exists(self.new) and sha256_file(self.new) == sha:
|
||||||
|
log("найдена подготовленная версия — ставлю")
|
||||||
|
self.apply_update()
|
||||||
|
else:
|
||||||
|
remove(self.new)
|
||||||
|
write_state(self.state_path, "rejected", st, "подготовленный файл потерян")
|
||||||
|
log("подготовленная v%s потеряна — отвергнута" % st.get("to_version"))
|
||||||
|
return
|
||||||
|
remove(self.new)
|
||||||
|
if state == "trial" and (not sha or sha256_file(self.agent) != sha):
|
||||||
|
self.drop_stale_trial(st)
|
||||||
|
|
||||||
|
def in_trial(self) -> bool:
|
||||||
|
"""Пробный период для этого запуска: trial и agent.py — именно эта версия (design 2)."""
|
||||||
|
st = read_state(self.state_path)
|
||||||
|
if not (st and st.get("state") == "trial"):
|
||||||
|
return False
|
||||||
|
if not st.get("sha256") or sha256_file(self.agent) != st.get("sha256"):
|
||||||
|
self.drop_stale_trial(st)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def drop_stale_trial(self, st: dict) -> None:
|
||||||
|
"""agent.py не та версия, что в trial (прерван откат, ручная подмена): пробного периода нет, версию не ставить (M-3)."""
|
||||||
|
reason = "пробный период прерван: agent.py не v%s" % st.get("to_version")
|
||||||
|
write_state(self.state_path, "rolled_back", st, reason)
|
||||||
|
log(reason)
|
||||||
|
|
||||||
|
def apply_update(self) -> None:
|
||||||
|
st = read_state(self.state_path)
|
||||||
|
if not (st and st.get("state") == "staged" and os.path.exists(self.new) and sha256_file(self.new) == st.get("sha256")):
|
||||||
|
remove(self.new)
|
||||||
|
write_state(self.state_path, "rejected", st, "файл изменился после проверки")
|
||||||
|
log("обновление отвергнуто: файл изменился после проверки")
|
||||||
|
return
|
||||||
|
tmp = self.prev + ".tmp"
|
||||||
|
try:
|
||||||
|
with open(self.agent, "rb") as src, open(tmp, "wb") as dst:
|
||||||
|
dst.write(src.read())
|
||||||
|
os.replace(tmp, self.prev)
|
||||||
|
os.replace(self.new, self.agent) # agent.py существует на каждом шаге
|
||||||
|
except OSError as e:
|
||||||
|
self.fail_update(st, e)
|
||||||
|
return
|
||||||
|
if not write_state(self.state_path, "trial", st):
|
||||||
|
try:
|
||||||
|
os.replace(self.prev, self.agent) # без trial новая версия осталась бы без пробного периода
|
||||||
|
except OSError as e:
|
||||||
|
log("не удалось вернуть agent.py.prev: %s" % e)
|
||||||
|
self.fail_update(st, "update.json не записан")
|
||||||
|
return
|
||||||
|
log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version")))
|
||||||
|
|
||||||
|
def fail_update(self, st: dict, err) -> None:
|
||||||
|
"""Подмена не удалась (диск полон, права): текущая версия остаётся, эта — rejected навсегда (design 4)."""
|
||||||
|
remove(self.prev + ".tmp")
|
||||||
|
remove(self.new)
|
||||||
|
write_state(self.state_path, "rejected", st, "подмена не удалась: %s" % err)
|
||||||
|
log("подмена v%s не удалась: %s" % (st.get("to_version"), err))
|
||||||
|
|
||||||
|
def rollback(self, st: Optional[dict], reason: str) -> bool:
|
||||||
|
if not os.path.exists(self.prev):
|
||||||
|
log("откат невозможен: нет agent.py.prev")
|
||||||
|
return False
|
||||||
|
os.replace(self.prev, self.agent)
|
||||||
|
write_state(self.state_path, "rolled_back", st, reason)
|
||||||
|
log("откат v%s → v%s: %s" % ((st or {}).get("to_version"), (st or {}).get("from_version"), reason))
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --- процесс агента --------------------------------------------------------
|
||||||
|
def spawn(self):
|
||||||
|
env = dict(os.environ, JIRA_AGENT_LAUNCHER=str(LAUNCHER_VERSION))
|
||||||
|
return subprocess.Popen([sys.executable, "-u", self.agent, "run"] + self.args, env=env, cwd=self.dir)
|
||||||
|
|
||||||
|
def terminate(self, child) -> None:
|
||||||
|
child.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
child.wait(self.kill_grace)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
child.kill()
|
||||||
|
child.wait()
|
||||||
|
|
||||||
|
def wait_child(self, child, trial: bool):
|
||||||
|
"""(код выхода, «пробный период истёк», «версия подтверждена»). В пробный период ждём ok не дольше trial_seconds.
|
||||||
|
Подтверждение — любое состояние update.json, кроме trial этой же версии, пока агент жив: запускатель сам
|
||||||
|
update.json при живом агенте не пишет, значит это сделал подтвердивший агент (ok, затем rejected/staged для
|
||||||
|
следующей версии — I-1)."""
|
||||||
|
start = time.monotonic()
|
||||||
|
st0 = read_state(self.state_path) if trial else None
|
||||||
|
trial_sha = (st0 or {}).get("sha256")
|
||||||
|
confirmed = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return child.wait(1), False, confirmed
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
if trial and not confirmed:
|
||||||
|
st = read_state(self.state_path)
|
||||||
|
if st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha):
|
||||||
|
confirmed = True
|
||||||
|
elif time.monotonic() - start >= self.trial_seconds:
|
||||||
|
self.terminate(child)
|
||||||
|
return child.returncode, True, False
|
||||||
|
|
||||||
|
def pause(self, seconds: float) -> bool:
|
||||||
|
"""Пауза перед перезапуском; False — остановлены во время паузы."""
|
||||||
|
end = time.monotonic() + seconds
|
||||||
|
while not self.stopping:
|
||||||
|
left = end - time.monotonic() # одно чтение часов: остаток не уйдёт в минус между проверкой и sleep (M-1)
|
||||||
|
if left <= 0:
|
||||||
|
break
|
||||||
|
time.sleep(min(1.0, left))
|
||||||
|
return not self.stopping
|
||||||
|
|
||||||
|
def next_backoff(self, uptime: float) -> int:
|
||||||
|
if uptime >= HEALTHY_UPTIME:
|
||||||
|
self.backoff = BACKOFF_START
|
||||||
|
wait = self.backoff
|
||||||
|
self.backoff = min(self.backoff * 2, BACKOFF_MAX)
|
||||||
|
return wait
|
||||||
|
|
||||||
|
def run(self) -> int:
|
||||||
|
self.reconcile()
|
||||||
|
code = 0
|
||||||
|
while True:
|
||||||
|
if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2)
|
||||||
|
return code
|
||||||
|
trial = self.in_trial()
|
||||||
|
trial_sha = (read_state(self.state_path) or {}).get("sha256") if trial else None
|
||||||
|
started = time.monotonic()
|
||||||
|
self.interrupted = False # Ctrl-C относится к этому запуску агента
|
||||||
|
self.child = self.spawn()
|
||||||
|
if self.stopping: # SIGTERM между Popen и присваиванием child до агента не дошёл
|
||||||
|
self.child.send_signal(signal.SIGTERM)
|
||||||
|
code, timed_out, confirmed = self.wait_child(self.child, trial)
|
||||||
|
self.child = None
|
||||||
|
uptime = time.monotonic() - started
|
||||||
|
st = read_state(self.state_path)
|
||||||
|
ok = bool(st and st.get("state") == "ok")
|
||||||
|
if trial and not (ok or confirmed) and st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha) \
|
||||||
|
and sha256_file(self.agent) == trial_sha:
|
||||||
|
confirmed = True # ok и следующая запись агента случились между опросами (I-1)
|
||||||
|
ok = ok or confirmed
|
||||||
|
reason = "агент упал (код %s)" % code
|
||||||
|
if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3)
|
||||||
|
log("подтверждение пришло в последний момент — отката нет")
|
||||||
|
self.backoff = BACKOFF_START
|
||||||
|
continue
|
||||||
|
if timed_out:
|
||||||
|
if self.rollback(st, "не вышла на связь за 10 мин"):
|
||||||
|
continue
|
||||||
|
elif self.stopping or (self.interrupted and code in STOP_CODES):
|
||||||
|
return code
|
||||||
|
elif code == EXIT_UPDATE:
|
||||||
|
self.apply_update()
|
||||||
|
self.backoff = BACKOFF_START
|
||||||
|
if self.interrupted: # Ctrl-C во время подготовки: подмена сделана, trial — при следующем старте
|
||||||
|
return 0
|
||||||
|
continue
|
||||||
|
elif code == EXIT_LOCKED: # журнал держит другой процесс — версия ни при чём (M-7, design 6)
|
||||||
|
reason = "журнал занят другим процессом (агент уже запущен)"
|
||||||
|
elif trial and not ok:
|
||||||
|
if self.rollback(st, "код %s через %d с" % (code, uptime)):
|
||||||
|
continue
|
||||||
|
if self.stopping:
|
||||||
|
return code
|
||||||
|
wait = self.next_backoff(uptime)
|
||||||
|
log("%s, перезапуск через %d с" % (reason, wait))
|
||||||
|
if not self.pause(wait):
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
base = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
lock = acquire_lock(base)
|
||||||
|
if lock is None:
|
||||||
|
log("запускатель уже работает в %s — второй не стартует" % base)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
launcher = Launcher(base, sys.argv[1:] if argv is None else argv)
|
||||||
|
signal.signal(signal.SIGINT, launcher.on_sigint)
|
||||||
|
signal.signal(signal.SIGTERM, launcher.on_sigterm)
|
||||||
|
log("запускатель v%d: агент %s" % (LAUNCHER_VERSION, launcher.agent))
|
||||||
|
return launcher.run()
|
||||||
|
finally:
|
||||||
|
lock.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
7
tests/__init__.py
Normal file
7
tests/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Тесты импортируют соседние модули (_common, fake_hub) по простому имени — добавляем каталог в путь.
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if _HERE not in sys.path:
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
29
tests/_common.py
Normal file
29
tests/_common.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейки."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
|
|
||||||
|
import agent # noqa: E402
|
||||||
|
|
||||||
|
SETTINGS = {
|
||||||
|
"wiki_pacing": 0,
|
||||||
|
"min_agent_version": 1,
|
||||||
|
"settings_version": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
EVA_URL = "http://127.0.0.1:1" # заглушка Executor в Eva не ходит; фейк Eva появится вместе с её клиентом
|
||||||
|
|
||||||
|
|
||||||
|
def eva_cfg(eva_url=EVA_URL, **over):
|
||||||
|
cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token",
|
||||||
|
"eva": {"base_url": eva_url, "token": "eva-secret"},
|
||||||
|
"verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0}
|
||||||
|
cfg.update(over)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def no_sleep(_seconds):
|
||||||
|
pass
|
||||||
158
tests/fake_eva.py
Normal file
158
tests/fake_eva.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list) и /files/... на http.server."""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
def _field(doc, path):
|
||||||
|
"""Значение поля по пути `project.code`: ссылка project хранится как {"id", "code"}, parent/tree_parent — кодом."""
|
||||||
|
if path in ("parent.code", "tree_parent.code"):
|
||||||
|
return doc.get(path.split(".")[0])
|
||||||
|
cur = doc
|
||||||
|
for part in path.split("."):
|
||||||
|
if not isinstance(cur, dict):
|
||||||
|
return None
|
||||||
|
cur = cur.get(part)
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEva:
|
||||||
|
token = "eva-secret"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.docs = []
|
||||||
|
# форма ссылок parent/tree_parent в ответах: "full" — {"id": "<id документа>", "code"} (по умолчанию, как в Eva),
|
||||||
|
# "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка)
|
||||||
|
self.ref_form = "full"
|
||||||
|
self.files = {} # путь → (байты, Content-Length: True/False/заявленное число)
|
||||||
|
self.requests = [] # {"method", "path", "body", "auth"}
|
||||||
|
self.failures = [] # [status, retry_after] — по одному на ответ
|
||||||
|
self.rpc_errors = []
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
eva = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
eva.handle(self, "GET")
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
eva.handle(self, "POST")
|
||||||
|
|
||||||
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self):
|
||||||
|
return "http://127.0.0.1:%d" % self.server.server_port
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
def add_doc(self, id, code, name="", parent=None, tree_parent=None, project=None, orderno=None, text=""):
|
||||||
|
# parent/tree_parent хранятся кодами родителей; в ответе они превращаются в ссылку по self.ref_form
|
||||||
|
ref = lambda c: {"id": c, "code": c} if c is not None else None # noqa: E731
|
||||||
|
self.docs.append({"id": id, "code": code, "name": name, "parent": parent,
|
||||||
|
"tree_parent": tree_parent, "project": ref(project), "orderno": orderno,
|
||||||
|
"text": text})
|
||||||
|
|
||||||
|
def _ref(self, code):
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
target = next((d for d in self.docs if d["code"] == code), None)
|
||||||
|
rid = str(target["id"]) if target else code
|
||||||
|
form = self.ref_form
|
||||||
|
if form == "id_only":
|
||||||
|
return {"id": rid}
|
||||||
|
if form == "string":
|
||||||
|
return rid
|
||||||
|
if form == "code_id":
|
||||||
|
return {"id": code, "code": code}
|
||||||
|
return {"id": rid, "code": code}
|
||||||
|
|
||||||
|
def _out(self, d, fields):
|
||||||
|
d = dict(d, parent=self._ref(d["parent"]), tree_parent=self._ref(d["tree_parent"]))
|
||||||
|
return {k: d.get(k) for k in fields} if fields else d
|
||||||
|
|
||||||
|
def add_file(self, path, data, length=True):
|
||||||
|
self.files[path] = (data, length)
|
||||||
|
|
||||||
|
def fail_next(self, status, times=1, retry_after=None):
|
||||||
|
self.failures.extend([[status, retry_after]] * times)
|
||||||
|
|
||||||
|
def rpc_error_next(self, text):
|
||||||
|
self.rpc_errors.append(text)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_calls(self):
|
||||||
|
return [r for r in self.requests if r["path"].startswith("/api/")]
|
||||||
|
|
||||||
|
def handle(self, h, verb):
|
||||||
|
u = urlsplit(h.path)
|
||||||
|
n = int(h.headers.get("Content-Length") or 0)
|
||||||
|
raw = h.rfile.read(n) if n else b""
|
||||||
|
auth = h.headers.get("Authorization")
|
||||||
|
with self.lock:
|
||||||
|
self.requests.append({"method": verb, "path": h.path,
|
||||||
|
"body": json.loads(raw) if raw else None, "auth": auth})
|
||||||
|
failure = self.failures.pop(0) if self.failures else None
|
||||||
|
rpc_error = self.rpc_errors.pop(0) if self.rpc_errors else None
|
||||||
|
if auth != "Bearer " + self.token:
|
||||||
|
return self._reply(h, 401, b'{"error": "unauthorized"}')
|
||||||
|
if failure:
|
||||||
|
extra = {"Retry-After": str(failure[1])} if failure[1] is not None else {}
|
||||||
|
return self._reply(h, failure[0], b'{"error": "busy"}', extra)
|
||||||
|
try:
|
||||||
|
if u.path == "/api/" and verb == "POST":
|
||||||
|
body = json.loads(raw)
|
||||||
|
if rpc_error is not None:
|
||||||
|
out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error}
|
||||||
|
else:
|
||||||
|
out = {"jsonrpc": "2.2", "callid": body.get("callid"),
|
||||||
|
"result": self.rpc(body["method"], body.get("kwargs") or {})}
|
||||||
|
return self._reply(h, 200, json.dumps(out).encode("utf-8"))
|
||||||
|
if u.path.startswith("/files/") and verb == "GET":
|
||||||
|
if u.path not in self.files:
|
||||||
|
return self._reply(h, 404, b'{"error": "not found"}')
|
||||||
|
data, with_length = self.files[u.path]
|
||||||
|
h.send_response(200)
|
||||||
|
h.send_header("Content-Type", "application/octet-stream")
|
||||||
|
if with_length: # True — настоящая длина; число — заявленная (ложь, для проверки заголовка)
|
||||||
|
h.send_header("Content-Length", str(len(data) if with_length is True else with_length))
|
||||||
|
h.end_headers() # без длины HTTP/1.0 отдаёт тело до закрытия соединения
|
||||||
|
h.wfile.write(data)
|
||||||
|
return
|
||||||
|
self._reply(h, 404, b"{}")
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _reply(self, h, status, body, headers=None):
|
||||||
|
h.send_response(status)
|
||||||
|
h.send_header("Content-Type", "application/json")
|
||||||
|
h.send_header("Content-Length", str(len(body)))
|
||||||
|
for k, v in (headers or {}).items():
|
||||||
|
h.send_header(k, v)
|
||||||
|
h.end_headers()
|
||||||
|
h.wfile.write(body)
|
||||||
|
|
||||||
|
def rpc(self, method, kw):
|
||||||
|
found = [d for d in self.docs if self._match(d, kw.get("filter") or [])]
|
||||||
|
fields = kw.get("fields")
|
||||||
|
pick = lambda d: self._out(d, fields) # noqa: E731
|
||||||
|
if method == "CmfDocument.get":
|
||||||
|
return pick(found[0]) if found else None
|
||||||
|
if method == "CmfDocument.list":
|
||||||
|
lo, hi = kw.get("slice") or [0, len(found)]
|
||||||
|
return [pick(d) for d in found[lo:hi]]
|
||||||
|
raise ValueError("метод %s не поддержан" % method)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match(doc, flt):
|
||||||
|
for field, op, value in flt:
|
||||||
|
if op != "==" or _field(doc, field) != value:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
214
tests/fake_hub.py
Normal file
214
tests/fake_hub.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
MAX_PAGES = 50
|
||||||
|
MAX_FILE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def check_pages(command, command_id, pages):
|
||||||
|
"""Форма пачки как в stage.accept/_check хаба: (код, тело ошибки) или None. 422 — {"error", "detail"}."""
|
||||||
|
def bad(detail, status=422, error="bad_pages"):
|
||||||
|
return status, {"error": error, "detail": detail}
|
||||||
|
|
||||||
|
if isinstance(command_id, bool) or not isinstance(command_id, int):
|
||||||
|
return bad("command_id — целое", error="bad_request")
|
||||||
|
if not isinstance(pages, list) or not pages or len(pages) > MAX_PAGES:
|
||||||
|
return bad("pages — список от 1 до %d страниц" % MAX_PAGES)
|
||||||
|
if command is None:
|
||||||
|
return bad("command %s" % command_id, 404, "not_found")
|
||||||
|
if command["type"] != "wiki_sync" or command["status"] != "claimed":
|
||||||
|
return bad("команда %s не выдана агенту как wiki_sync" % command_id, 409, "not_claimed")
|
||||||
|
limits = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200}
|
||||||
|
for i, page in enumerate(pages, 1):
|
||||||
|
if not isinstance(page, dict):
|
||||||
|
return bad("страница %d: не объект" % i)
|
||||||
|
code = page.get("code")
|
||||||
|
if not isinstance(code, str) or not code.strip() or len(code) > limits["code"]:
|
||||||
|
return bad("страница %d: code" % i)
|
||||||
|
for key, required in (("title", True), ("parent_code", False), ("url", False)):
|
||||||
|
v = page.get(key)
|
||||||
|
if v is None and not required:
|
||||||
|
continue
|
||||||
|
if not isinstance(v, str) or (required and not v.strip()) or len(v) > limits[key]:
|
||||||
|
return bad("%s: %s" % (code, key))
|
||||||
|
if not isinstance(page.get("html"), str):
|
||||||
|
return bad("%s: html" % code)
|
||||||
|
o = page.get("orderno")
|
||||||
|
if o is not None and (not isinstance(o, int) or isinstance(o, bool)):
|
||||||
|
return bad("%s: orderno" % code)
|
||||||
|
e = page.get("eva_id")
|
||||||
|
if e is not None and (not isinstance(e, str) or len(e) > limits["eva_id"]):
|
||||||
|
return bad("%s: eva_id" % code)
|
||||||
|
att, links = page.get("attachments") or [], page.get("links") or []
|
||||||
|
if not isinstance(att, list) or not all(
|
||||||
|
isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str)
|
||||||
|
and isinstance(a.get("name") or "", str) for a in att):
|
||||||
|
return bad("%s: attachments" % code)
|
||||||
|
if not isinstance(links, list) or not all(
|
||||||
|
isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str)
|
||||||
|
for l in links):
|
||||||
|
return bad("%s: links" % code)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHub:
|
||||||
|
token = "agent-token"
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = dict(settings)
|
||||||
|
self.queue = []
|
||||||
|
self.commands = {}
|
||||||
|
self.results = {}
|
||||||
|
self.claims = 0
|
||||||
|
self.claim_bodies = []
|
||||||
|
self.fail_results = 0
|
||||||
|
self.reject_results = {}
|
||||||
|
self.in_flight = None
|
||||||
|
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
||||||
|
self.release_requests = 0
|
||||||
|
self.files = {} # sha256 → содержимое (хранилище файлов хаба)
|
||||||
|
self.file_requests = 0
|
||||||
|
self.pages = [] # тела POST /agent/wiki/pages
|
||||||
|
self.reject_pages = None # (код, тело) — ответ на /agent/wiki/pages ошибкой
|
||||||
|
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
||||||
|
self.uploaded = [] # содержимое, присланное агентом POST /agent/files
|
||||||
|
self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
hub = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
hub.handle(self, "GET")
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
hub.handle(self, "POST")
|
||||||
|
|
||||||
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self):
|
||||||
|
return "http://127.0.0.1:%d" % self.server.server_port
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
def enqueue(self, ctype, params, pause=0):
|
||||||
|
cid = len(self.commands) + 1
|
||||||
|
self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"}
|
||||||
|
self.queue.append(cid)
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def put_file(self, data):
|
||||||
|
sha = hashlib.sha256(data).hexdigest()
|
||||||
|
self.files[sha] = data
|
||||||
|
return sha
|
||||||
|
|
||||||
|
def _issued(self, c):
|
||||||
|
return dict(c["params"])
|
||||||
|
|
||||||
|
def _release_ref(self):
|
||||||
|
r = self.release
|
||||||
|
return {"version": r["version"], "sha256": r["sha256"]} if r else None
|
||||||
|
|
||||||
|
def handle(self, h, method):
|
||||||
|
length = int(h.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
|
||||||
|
if h.headers.get("Authorization") != "Bearer " + self.token:
|
||||||
|
return self._send(h, 401, {"error": "unauthorized"})
|
||||||
|
path = h.path.split("?")[0]
|
||||||
|
if path == "/agent/files" and method == "POST" and self.reject_files == "drop":
|
||||||
|
h.close_connection = True
|
||||||
|
h.connection.shutdown(socket.SHUT_RDWR)
|
||||||
|
return
|
||||||
|
if path == "/agent/claim" and self.claim_wait:
|
||||||
|
end = time.monotonic() + self.claim_wait
|
||||||
|
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
|
||||||
|
with self.lock:
|
||||||
|
if self.queue:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
with self.lock:
|
||||||
|
code, payload = self.route(method, path, body)
|
||||||
|
self._send(h, code, payload)
|
||||||
|
|
||||||
|
def _send(self, h, code, payload):
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
h.send_response(code)
|
||||||
|
h.send_header("Content-Type", "application/json")
|
||||||
|
h.send_header("Content-Length", str(len(data)))
|
||||||
|
h.end_headers()
|
||||||
|
h.wfile.write(data)
|
||||||
|
|
||||||
|
def route(self, method, path, body):
|
||||||
|
if path == "/agent/claim":
|
||||||
|
self.claim_bodies.append(body or {})
|
||||||
|
self.claims += 1
|
||||||
|
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
|
||||||
|
return 200, {"command": None, "settings_version": self.settings["settings_version"],
|
||||||
|
"agent_release": self._release_ref()}
|
||||||
|
if not self.queue:
|
||||||
|
return 200, {"command": None, "settings_version": self.settings["settings_version"],
|
||||||
|
"agent_release": self._release_ref()}
|
||||||
|
cid = self.queue.pop(0)
|
||||||
|
self.commands[cid]["status"] = "claimed"
|
||||||
|
self.in_flight = cid
|
||||||
|
c = self.commands[cid]
|
||||||
|
return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
|
||||||
|
"settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
|
||||||
|
if path == "/agent/release":
|
||||||
|
self.release_requests += 1
|
||||||
|
return (200, self.release) if self.release else (404, {"error": "no_release"})
|
||||||
|
if path == "/agent/settings":
|
||||||
|
return 200, self.settings
|
||||||
|
if path == "/agent/wiki/pages" and method == "POST":
|
||||||
|
if self.reject_pages:
|
||||||
|
return self.reject_pages
|
||||||
|
cid = body.get("command_id")
|
||||||
|
bad = check_pages(self.commands.get(cid) if isinstance(cid, int) else None, cid, body.get("pages"))
|
||||||
|
if bad:
|
||||||
|
return bad
|
||||||
|
self.pages.append(body)
|
||||||
|
return 200, {"accepted": len(body.get("pages") or [])}
|
||||||
|
if path == "/agent/files" and method == "POST":
|
||||||
|
if self.reject_files:
|
||||||
|
return self.reject_files
|
||||||
|
data = base64.b64decode(body.get("content") or "", validate=True)
|
||||||
|
if len(data) > MAX_FILE: # как files.put хаба; пустой файл от агента принимается (allow_empty)
|
||||||
|
return 413, {"error": "too_large", "detail": "файл больше %d байт (%d)" % (MAX_FILE, len(data))}
|
||||||
|
self.uploaded.append(data)
|
||||||
|
return 200, {"file": self.put_file(data), "size": len(data)}
|
||||||
|
m = re.match(r"^/agent/commands/(\d+)$", path)
|
||||||
|
if m:
|
||||||
|
c = self.commands.get(int(m.group(1)))
|
||||||
|
return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"})
|
||||||
|
m = re.match(r"^/agent/commands/(\d+)/result$", path)
|
||||||
|
if m:
|
||||||
|
cid = int(m.group(1))
|
||||||
|
if self.fail_results > 0:
|
||||||
|
self.fail_results -= 1
|
||||||
|
return 503, {"error": "busy"}
|
||||||
|
if cid in self.reject_results:
|
||||||
|
return self.reject_results[cid], {"error": "rejected"}
|
||||||
|
if cid not in self.commands:
|
||||||
|
return 404, {"error": "not_found"}
|
||||||
|
if self.commands[cid]["status"] in ("done", "failed", "cancelled"):
|
||||||
|
return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб)
|
||||||
|
self.results[cid] = body
|
||||||
|
self.commands[cid]["status"] = body.get("status")
|
||||||
|
if self.in_flight == cid:
|
||||||
|
self.in_flight = None
|
||||||
|
return 200, self.commands[cid]
|
||||||
|
return 404, {"error": "not_found", "detail": path}
|
||||||
74
tests/fake_relay.py
Normal file
74
tests/fake_relay.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""Фейковое реле хаба для тестов агента: забирает req/<id>.json из папки и отвечает resp/<id>.json.
|
||||||
|
Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать;
|
||||||
|
garbage — положить нечитаемый ответ; late — ответить через delay секунд."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(directory, name, payload):
|
||||||
|
tmp = os.path.join(directory, "." + name + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
|
||||||
|
os.replace(tmp, os.path.join(directory, name))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRelay:
|
||||||
|
def __init__(self, root, target_base_url="", mode="forward", delay=0.0):
|
||||||
|
self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay
|
||||||
|
self.requests = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||||
|
for sub in ("req", "resp"):
|
||||||
|
os.makedirs(os.path.join(root, sub), exist_ok=True)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
self._thread.join(5)
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
|
||||||
|
while not self._stop.is_set():
|
||||||
|
for name in sorted(os.listdir(req_dir)):
|
||||||
|
if name.startswith(".") or not name.endswith(".json"):
|
||||||
|
continue
|
||||||
|
path = os.path.join(req_dir, name)
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
req = json.load(f)
|
||||||
|
os.remove(path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
self.requests.append(req)
|
||||||
|
if self.mode == "silent":
|
||||||
|
continue
|
||||||
|
if self.mode == "garbage":
|
||||||
|
write_atomic(resp_dir, name, "{не json")
|
||||||
|
continue
|
||||||
|
if self.mode == "late":
|
||||||
|
time.sleep(self.delay)
|
||||||
|
status, body = self._forward(req)
|
||||||
|
write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body})
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
def _forward(self, req):
|
||||||
|
data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None
|
||||||
|
r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"])
|
||||||
|
r.add_header("Authorization", req["authorization"])
|
||||||
|
if data is not None:
|
||||||
|
r.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||||
|
return resp.status, resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, e.read().decode("utf-8")
|
||||||
|
finally:
|
||||||
|
e.close()
|
||||||
1
tests/fixtures/pages/amp.html
vendored
Normal file
1
tests/fixtures/pages/amp.html
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/a.png?version=1&x=2"><a href="project/Document/DOC-7?a=1&b=2">к</a>
|
||||||
1
tests/fixtures/pages/image.html
vendored
Normal file
1
tests/fixtures/pages/image.html
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<p>Схема:</p><img alt="Схема" src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/img.png?version=1">
|
||||||
3
tests/fixtures/pages/links.html
vendored
Normal file
3
tests/fixtures/pages/links.html
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<p><a href="project/Document/DOC-019834">см. раздел</a> <a href="https://eva.test/project/Document/doc-5">абс.</a>
|
||||||
|
<a href="https://other.test/files/x.png">чужой файл</a> <a href="https://other.test/project/Document/DOC-1">чужая</a>
|
||||||
|
<a href="https://eva.test/other/page">прочее</a></p>
|
||||||
1
tests/fixtures/pages/name_quoted.html
vendored
Normal file
1
tests/fixtures/pages/name_quoted.html
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/%D0%9F%D0%9C%D0%9F.xlsx?version=1">
|
||||||
2
tests/fixtures/pages/thumb_and_link.html
vendored
Normal file
2
tests/fixtures/pages/thumb_and_link.html
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<p><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/model.xlsx.meta/thumbnail.jpg?version=1" alt="model.xlsx"> <img src="static/attach-file.png"> <a href="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/model.xlsx?version=1">model.xlsx</a></p>
|
||||||
|
<p><img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001/data.dbml.meta/thumbnail.jpg?version=1"></p>
|
||||||
77
tests/test_check.py
Normal file
77
tests/test_check.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent, eva_cfg
|
||||||
|
from fake_eva import FakeEva
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
|
||||||
|
|
||||||
|
class CheckTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.eva = FakeEva()
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
self.addCleanup(self.eva.stop)
|
||||||
|
self.addCleanup(self.hub.stop)
|
||||||
|
self.eva.add_doc(1, "DOC-1", "К", project="P")
|
||||||
|
|
||||||
|
def check(self, **over):
|
||||||
|
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, **over)
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
code = agent.cmd_check(cfg)
|
||||||
|
return code, out.getvalue()
|
||||||
|
|
||||||
|
def test_all_ok(self):
|
||||||
|
code, out = self.check()
|
||||||
|
self.assertEqual(code, 0, out)
|
||||||
|
self.assertIn("хаб: ", out)
|
||||||
|
self.assertIn("eva: ok", out)
|
||||||
|
call = self.eva.api_calls[-1]["body"]
|
||||||
|
self.assertEqual(call["method"], "CmfDocument.list")
|
||||||
|
self.assertEqual(call["kwargs"], {"filter": [], "fields": ["code"], "slice": [0, 1]})
|
||||||
|
|
||||||
|
def test_eva_401(self):
|
||||||
|
self.eva.token = "другой"
|
||||||
|
code, out = self.check()
|
||||||
|
self.assertEqual(code, 3)
|
||||||
|
self.assertIn("eva: ", out)
|
||||||
|
self.assertIn("401", out)
|
||||||
|
self.assertNotIn("eva-secret", out)
|
||||||
|
|
||||||
|
def test_eva_unreachable(self):
|
||||||
|
cfg = eva_cfg("http://127.0.0.1:1", hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(agent.cmd_check(cfg), 3)
|
||||||
|
self.assertIn("eva: НЕДОСТУПНА", out.getvalue())
|
||||||
|
|
||||||
|
def test_hub_down_but_eva_checked(self):
|
||||||
|
cfg = eva_cfg(self.eva.base_url) # хаб: порт 1
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(agent.cmd_check(cfg), 3)
|
||||||
|
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
|
||||||
|
self.assertIn("eva: ok", out.getvalue())
|
||||||
|
|
||||||
|
def test_selftest_zero_when_ok(self):
|
||||||
|
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertEqual(agent.cmd_selftest(cfg), 0)
|
||||||
|
|
||||||
|
def test_run_once_exits_on_idle(self):
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token, auto_update=False)
|
||||||
|
path = os.path.join(d, "agent.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f)
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
code = agent.main(["--config", path, "--journal", os.path.join(d, "j.jsonl"), "run", "--once"])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
254
tests/test_clients.py
Normal file
254
tests/test_clients.py
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"""HTTP-слой (Http, EvaClient, HubClient) и конфиг. Перенос из jira-agent: сценарии Http без Jira-специфики."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from _common import agent, eva_cfg
|
||||||
|
|
||||||
|
|
||||||
|
class MiniServer:
|
||||||
|
"""Крошечный сервер: routes — путь → (код, заголовки, тело-байты); requests — что пришло."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.routes = {}
|
||||||
|
self.requests = []
|
||||||
|
self.last_headers = {}
|
||||||
|
srv = self
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
srv.requests.append(self.path)
|
||||||
|
srv.last_headers = {k.lower(): v for k, v in self.headers.items()}
|
||||||
|
code, headers, body = srv.routes.get(self.path, (404, {}, b"{}"))
|
||||||
|
self.send_response(code)
|
||||||
|
for k, v in headers.items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
if "Content-Length" not in headers:
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), H)
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self):
|
||||||
|
return "http://127.0.0.1:%d" % self.server.server_port
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
class ClientsTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.srv = MiniServer()
|
||||||
|
self.eva = agent.EvaClient(eva_cfg(self.srv.base_url))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.srv.stop()
|
||||||
|
|
||||||
|
def test_get_and_counters(self):
|
||||||
|
self.srv.routes["/x"] = (200, {"Content-Type": "application/json"}, b'{"a": 1}')
|
||||||
|
self.assertEqual(agent.Http.call(self.eva, "GET", "/x"), {"a": 1})
|
||||||
|
self.assertEqual(self.eva.calls, 1)
|
||||||
|
self.assertIsNotNone(self.eva.last_call_at)
|
||||||
|
self.assertIsNotNone(self.eva.last_call_iso)
|
||||||
|
self.eva.reset_counters()
|
||||||
|
self.assertEqual((self.eva.calls, self.eva.rate_limited), (0, 0))
|
||||||
|
|
||||||
|
def test_bearer_header_and_no_secret_in_repr(self):
|
||||||
|
self.assertEqual(self.eva.headers["Authorization"], "Bearer eva-secret")
|
||||||
|
self.assertNotIn("eva-secret", repr(self.eva))
|
||||||
|
self.srv.routes["/x"] = (200, {}, b"{}")
|
||||||
|
agent.Http.call(self.eva, "GET", "/x")
|
||||||
|
self.assertEqual(self.srv.last_headers["authorization"], "Bearer eva-secret")
|
||||||
|
|
||||||
|
def test_secret_not_in_log_output(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
self.srv.routes["/x"] = (200, {}, b"{}")
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
agent.Http.call(self.eva, "GET", "/x")
|
||||||
|
agent.log("проверка")
|
||||||
|
self.assertNotIn("eva-secret", buf.getvalue())
|
||||||
|
|
||||||
|
def test_http_error_carries_code_text_headers(self):
|
||||||
|
self.srv.routes["/x"] = (429, {"Retry-After": "7"}, b"slow down")
|
||||||
|
with self.assertRaises(agent.HttpError) as cm:
|
||||||
|
agent.Http.call(self.eva, "GET", "/x")
|
||||||
|
self.assertEqual(cm.exception.code, 429)
|
||||||
|
self.assertEqual(cm.exception.headers.get("retry-after"), "7")
|
||||||
|
self.assertIn("slow down", cm.exception.text)
|
||||||
|
self.assertEqual(self.eva.calls, 1)
|
||||||
|
|
||||||
|
def test_fetch_bytes(self):
|
||||||
|
self.srv.routes["/f"] = (200, {}, b"\x89PNG\x00\x01")
|
||||||
|
self.assertEqual(self.eva.fetch_bytes("/f", 100), b"\x89PNG\x00\x01")
|
||||||
|
self.assertEqual(self.srv.last_headers["authorization"], "Bearer eva-secret")
|
||||||
|
self.assertEqual(self.eva.calls, 1)
|
||||||
|
|
||||||
|
def test_fetch_bytes_limit(self):
|
||||||
|
self.srv.routes["/f"] = (200, {}, b"x" * 20)
|
||||||
|
with self.assertRaises(agent.TooBig):
|
||||||
|
self.eva.fetch_bytes("/f", 10)
|
||||||
|
self.assertEqual(self.eva.fetch_bytes("/f", 20), b"x" * 20)
|
||||||
|
|
||||||
|
def test_fetch_bytes_redirect_same_host(self):
|
||||||
|
self.srv.routes["/f"] = (302, {"Location": self.srv.base_url + "/moved"}, b"")
|
||||||
|
self.srv.routes["/moved"] = (200, {}, b"abc")
|
||||||
|
self.assertEqual(self.eva.fetch_bytes("/f", 100), b"abc")
|
||||||
|
|
||||||
|
def test_fetch_bytes_redirect_other_host(self):
|
||||||
|
port = self.srv.server.server_port
|
||||||
|
self.srv.routes["/f"] = (302, {"Location": "http://localhost:%d/moved" % port}, b"")
|
||||||
|
self.srv.routes["/moved"] = (200, {}, b"abc")
|
||||||
|
with self.assertRaises(agent.RedirectAway) as e:
|
||||||
|
self.eva.fetch_bytes("/f", 100)
|
||||||
|
self.assertEqual(e.exception.host, "localhost")
|
||||||
|
self.assertNotIn("/moved", self.srv.requests)
|
||||||
|
|
||||||
|
def test_fetch_bytes_cut_body_is_connection_error(self):
|
||||||
|
self.srv.routes["/f"] = (200, {"Content-Length": "40"}, b"x" * 10) # обещано 40, отдано 10
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
self.eva.fetch_bytes("/f", 1000)
|
||||||
|
|
||||||
|
def test_json_call_redirect_other_host_refused(self):
|
||||||
|
port = self.srv.server.server_port
|
||||||
|
self.srv.routes["/a"] = (302, {"Location": "http://localhost:%d/b" % port}, b"")
|
||||||
|
self.srv.routes["/b"] = (200, {}, b"{}")
|
||||||
|
with self.assertRaises(agent.HttpError) as e:
|
||||||
|
agent.Http.call(self.eva, "GET", "/a")
|
||||||
|
self.assertIn("localhost", e.exception.text)
|
||||||
|
self.assertNotIn("/b", self.srv.requests)
|
||||||
|
|
||||||
|
def test_json_call_redirect_same_host_followed(self):
|
||||||
|
self.srv.routes["/a"] = (302, {"Location": self.srv.base_url + "/b"}, b"")
|
||||||
|
self.srv.routes["/b"] = (200, {}, b'{"ok": true}')
|
||||||
|
self.assertEqual(agent.Http.call(self.eva, "GET", "/a"), {"ok": True})
|
||||||
|
|
||||||
|
def test_error_body_read_failure_is_connection_error(self):
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
class Broken(io.BytesIO):
|
||||||
|
def read(self, *a):
|
||||||
|
raise TimeoutError("timed out")
|
||||||
|
|
||||||
|
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
|
||||||
|
with mock.patch("urllib.request.OpenerDirector.open", side_effect=err):
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
agent.Http.call(self.eva, "GET", "/x")
|
||||||
|
self.assertEqual(self.eva.calls, 1)
|
||||||
|
|
||||||
|
def test_incomplete_read_is_connection_error(self):
|
||||||
|
import http.client
|
||||||
|
with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)):
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
agent.Http.call(self.eva, "GET", "/x")
|
||||||
|
|
||||||
|
def test_incomplete_read_inside_response_body(self):
|
||||||
|
srv = socket.socket()
|
||||||
|
srv.bind(("127.0.0.1", 0))
|
||||||
|
srv.listen(1)
|
||||||
|
|
||||||
|
def serve():
|
||||||
|
c, _ = srv.accept()
|
||||||
|
c.recv(4096)
|
||||||
|
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{\"a\":")
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=serve, daemon=True)
|
||||||
|
t.start()
|
||||||
|
client = agent.EvaClient(eva_cfg("http://127.0.0.1:%d" % srv.getsockname()[1]))
|
||||||
|
try:
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
agent.Http.call(client, "GET", "/x")
|
||||||
|
finally:
|
||||||
|
t.join(2)
|
||||||
|
srv.close()
|
||||||
|
|
||||||
|
def test_unreachable_raises_connection_error(self):
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
agent.Http.call(agent.EvaClient(eva_cfg("http://127.0.0.1:1")), "GET", "/x")
|
||||||
|
|
||||||
|
def test_non_json_200_becomes_http_error(self):
|
||||||
|
self.srv.routes["/x"] = (200, {}, b"<html>\xff\xfe not json")
|
||||||
|
with self.assertRaises(agent.HttpError) as cm:
|
||||||
|
agent.Http.call(self.eva, "GET", "/x")
|
||||||
|
self.assertEqual(cm.exception.code, 200)
|
||||||
|
self.assertEqual(self.eva.calls, 1)
|
||||||
|
|
||||||
|
def test_hub_client_claim_and_wiki_pages_routes(self):
|
||||||
|
calls = []
|
||||||
|
hub = agent.HubClient(eva_cfg(hub_url="http://x", hub_token="t"))
|
||||||
|
hub.call = lambda method, path, body=None, params=None, timeout=None: (calls.append((method, path, body, timeout)), {})[1]
|
||||||
|
hub.claim(1, 2, None)
|
||||||
|
hub.wiki_pages(7, [{"code": "DOC-1"}])
|
||||||
|
hub.upload_file(b"abc")
|
||||||
|
self.assertEqual(calls[0][:2], ("POST", "/agent/claim"))
|
||||||
|
self.assertEqual(calls[0][2]["last_call_at"], None)
|
||||||
|
self.assertGreaterEqual(calls[0][3], 45)
|
||||||
|
self.assertEqual(calls[1], ("POST", "/agent/wiki/pages", {"command_id": 7, "pages": [{"code": "DOC-1"}]}, None))
|
||||||
|
self.assertEqual(calls[2][:2], ("POST", "/agent/files"))
|
||||||
|
self.assertEqual(calls[2][2], {"content": "YWJj"})
|
||||||
|
|
||||||
|
def test_load_config_requires_fields(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = os.path.join(d, "agent.json")
|
||||||
|
|
||||||
|
def write(cfg):
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f)
|
||||||
|
|
||||||
|
for bad in ({"hub_url": "http://x", "hub_token": "t"},
|
||||||
|
{"hub_url": "http://x", "hub_token": "t", "eva": {"base_url": "https://e"}},
|
||||||
|
{"hub_url": "http://x", "hub_token": "t", "eva": {"token": "z"}},
|
||||||
|
{"hub_url": "http://x", "hub_token": "t", "eva": "https://e"}):
|
||||||
|
write(bad)
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
agent.load_config(path)
|
||||||
|
write({"hub_url": "http://x", "hub_token": "t", "eva": {"base_url": "https://e", "token": "z"}})
|
||||||
|
cfg = agent.load_config(path)
|
||||||
|
self.assertEqual((cfg["timeout"], cfg["poll_seconds"], cfg["verify_ssl"]), (60, 5, True))
|
||||||
|
self.assertNotIn("auto_update", cfg) # умолчание true применяет _run: `is not False`
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
agent.load_config(os.path.join(d, "нет.json"))
|
||||||
|
|
||||||
|
def test_example_config_loads(self):
|
||||||
|
cfg = agent.load_config(os.path.join(agent.HERE, "agent.example.json"))
|
||||||
|
self.assertEqual(cfg["auto_update"], True)
|
||||||
|
self.assertIn("base_url", cfg["eva"])
|
||||||
|
|
||||||
|
def test_check_command(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = os.path.join(d, "agent.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(eva_cfg(), f)
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(agent.main(["--config", path, "check"]), 3) # хаб и Eva недоступны (порт 1)
|
||||||
|
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
|
||||||
|
|
||||||
|
def test_selftest_command(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = os.path.join(d, "agent.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(eva_cfg(), f)
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
self.assertEqual(agent.main(["--config", path, "selftest"]), 3) # недоступность — 3, а не 1
|
||||||
|
self.assertIn("eva-agent v%d" % agent.AGENT_VERSION, out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
189
tests/test_eva_client.py
Normal file
189
tests/test_eva_client.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import io
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
|
||||||
|
from tests import _common as c
|
||||||
|
from tests.fake_eva import FakeEva
|
||||||
|
|
||||||
|
agent = c.agent
|
||||||
|
|
||||||
|
|
||||||
|
class EvaClientTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.eva = FakeEva()
|
||||||
|
self.addCleanup(self.eva.stop)
|
||||||
|
self.cli = agent.EvaClient(c.eva_cfg(self.eva.base_url, timeout=10))
|
||||||
|
self.sleeps = []
|
||||||
|
self.cli.sleep = self.sleeps.append
|
||||||
|
|
||||||
|
def test_call_ok(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
doc = self.cli.get_doc("DOC-1", ["code", "name"])
|
||||||
|
self.assertEqual(doc, {"code": "DOC-1", "name": "Корень"})
|
||||||
|
self.assertIsNone(self.cli.get_doc("DOC-404", ["code"]))
|
||||||
|
req = self.eva.api_calls[0]
|
||||||
|
self.assertEqual(req["path"], "/api/?m=CmfDocument.get")
|
||||||
|
self.assertEqual(req["auth"], "Bearer eva-secret")
|
||||||
|
self.assertEqual(req["body"]["jsonrpc"], "2.2")
|
||||||
|
self.assertEqual(req["body"]["kwargs"]["filter"], [["code", "==", "DOC-1"]])
|
||||||
|
self.assertEqual(self.cli.calls, 2)
|
||||||
|
|
||||||
|
def test_retry_429_retry_after(self):
|
||||||
|
self.eva.fail_next(429, 1, retry_after=7)
|
||||||
|
self.assertEqual(self.cli.call("CmfDocument.list", filter=[], fields=["code"]), [])
|
||||||
|
self.assertEqual(self.sleeps, [7])
|
||||||
|
self.assertEqual(self.cli.rate_limited, 1)
|
||||||
|
|
||||||
|
def test_retry_after_capped(self):
|
||||||
|
self.eva.fail_next(429, 1, retry_after=600)
|
||||||
|
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
|
||||||
|
self.assertEqual(self.sleeps, [60])
|
||||||
|
|
||||||
|
def test_retry_503_then_ok(self):
|
||||||
|
self.eva.fail_next(503, 2)
|
||||||
|
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
|
||||||
|
self.assertEqual(self.sleeps, [1, 2])
|
||||||
|
self.assertEqual(self.cli.rate_limited, 0)
|
||||||
|
|
||||||
|
def test_gives_up_after_4(self):
|
||||||
|
self.eva.fail_next(503, 10)
|
||||||
|
with self.assertRaises(agent.HttpError) as cm:
|
||||||
|
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
|
||||||
|
self.assertEqual(cm.exception.code, 503)
|
||||||
|
self.assertEqual(len(self.eva.api_calls), 4)
|
||||||
|
self.assertEqual(self.sleeps, [1, 2, 4])
|
||||||
|
|
||||||
|
def test_no_retry_on_401(self):
|
||||||
|
self.cli.headers["Authorization"] = "Bearer wrong"
|
||||||
|
with self.assertRaises(agent.HttpError):
|
||||||
|
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
|
||||||
|
self.assertEqual(len(self.eva.api_calls), 1)
|
||||||
|
|
||||||
|
def test_rpc_error(self):
|
||||||
|
self.eva.rpc_error_next("нет прав")
|
||||||
|
with self.assertRaises(agent.EvaError) as cm:
|
||||||
|
self.cli.call("CmfDocument.get", filter=[], fields=["code"])
|
||||||
|
self.assertEqual(str(cm.exception), "CmfDocument.get: нет прав")
|
||||||
|
|
||||||
|
def test_list_pages_200(self):
|
||||||
|
for i in range(450):
|
||||||
|
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
|
||||||
|
docs = self.cli.list_docs([["project.code", "==", "P"]], ["code"])
|
||||||
|
self.assertEqual(len(docs), 450)
|
||||||
|
self.assertEqual(len(self.eva.api_calls), 3)
|
||||||
|
self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls],
|
||||||
|
[[0, 200], [200, 400], [400, 600]])
|
||||||
|
|
||||||
|
def test_list_slice_ignored_returns_all(self):
|
||||||
|
"""M-4: Eva не поддерживает slice и отдаёт весь список каждый раз — второй ответ без новых записей, стоп."""
|
||||||
|
orig = self.eva.rpc
|
||||||
|
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None))
|
||||||
|
for i in range(450):
|
||||||
|
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"])
|
||||||
|
self.assertEqual(len(docs), 450)
|
||||||
|
self.assertEqual(len(self.eva.api_calls), 2)
|
||||||
|
self.assertIn("slice не соблюдается", out.getvalue())
|
||||||
|
|
||||||
|
def test_list_slice_ignored_same_page(self):
|
||||||
|
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
|
||||||
|
orig = self.eva.rpc
|
||||||
|
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=[0, 200]))
|
||||||
|
for i in range(450):
|
||||||
|
self.eva.add_doc(i + 1, "DOC-%d" % (i + 1), project="P")
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
docs = self.cli.list_docs([["project.code", "==", "P"]], ["id", "code"])
|
||||||
|
self.assertEqual([d["code"] for d in docs], ["DOC-%d" % (i + 1) for i in range(200)])
|
||||||
|
self.assertEqual(len(self.eva.api_calls), 2)
|
||||||
|
|
||||||
|
def test_list_unwraps_dict(self):
|
||||||
|
orig = self.eva.rpc
|
||||||
|
self.eva.rpc = lambda m, kw: {"items": orig(m, kw)}
|
||||||
|
self.eva.add_doc(1, "DOC-1")
|
||||||
|
self.assertEqual(self.cli.list_docs([], ["code"]), [{"code": "DOC-1"}])
|
||||||
|
|
||||||
|
def test_tree_parent_filter(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1")
|
||||||
|
self.eva.add_doc(2, "DOC-2", tree_parent="DOC-1")
|
||||||
|
self.eva.add_doc(3, "DOC-3", tree_parent="DOC-2")
|
||||||
|
got = self.cli.list_docs([["tree_parent.code", "==", "DOC-1"]], ["code"])
|
||||||
|
self.assertEqual(got, [{"code": "DOC-2"}])
|
||||||
|
|
||||||
|
def test_pacing_between_calls(self):
|
||||||
|
self.cli.pacing = 0.5
|
||||||
|
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
|
||||||
|
self.assertEqual(self.sleeps, []) # перед первым вызовом паузы нет
|
||||||
|
self.cli.call("CmfDocument.list", filter=[], fields=["code"])
|
||||||
|
self.assertEqual(len(self.sleeps), 1)
|
||||||
|
self.assertTrue(0 < self.sleeps[0] <= 0.5)
|
||||||
|
|
||||||
|
def test_download_ok(self):
|
||||||
|
self.eva.add_file("/files/a.bin", b"abc")
|
||||||
|
self.assertEqual(self.cli.download(self.eva.base_url + "/files/a.bin", 10), b"abc")
|
||||||
|
self.assertEqual(self.eva.requests[-1]["auth"], "Bearer eva-secret")
|
||||||
|
|
||||||
|
def test_download_foreign_host(self):
|
||||||
|
with self.assertRaises(agent.ForeignHost):
|
||||||
|
self.cli.download("http://example.invalid/files/a.bin", 10)
|
||||||
|
self.assertEqual(self.eva.requests, [])
|
||||||
|
|
||||||
|
def test_download_lookalikes(self):
|
||||||
|
"""T5.2: похожий хост, userinfo, другой порт и схема — ForeignHost до запроса (адрес Eva — https://eva.test)."""
|
||||||
|
cli = agent.EvaClient(c.eva_cfg("https://eva.test"))
|
||||||
|
cli.fetch_bytes = lambda *a, **kw: self.fail("запроса быть не должно")
|
||||||
|
for url in ("https://eva.test.evil/files/a.bin", "https://eva.test@evil/files/a.bin",
|
||||||
|
"https://eva.test:8443/files/a.bin", "http://eva.test/files/a.bin",
|
||||||
|
"https://eva.testx/files/a.bin", "https://user@eva.test/files/a.bin"):
|
||||||
|
with self.subTest(url=url):
|
||||||
|
with self.assertRaises(agent.ForeignHost) as cm:
|
||||||
|
cli.download(url, 10)
|
||||||
|
self.assertEqual(str(cm.exception), "чужой хост")
|
||||||
|
with self.assertRaises(agent.ForeignHost) as cm:
|
||||||
|
cli.download("https://eva.test:abc/files/a.bin", 10)
|
||||||
|
self.assertEqual(str(cm.exception), "некорректный адрес")
|
||||||
|
|
||||||
|
def test_download_limit(self):
|
||||||
|
limit = 10 * 1024 * 1024
|
||||||
|
self.eva.add_file("/files/big.bin", b"x" * (limit + 1), length=False)
|
||||||
|
self.eva.add_file("/files/ok.bin", b"x" * limit, length=False)
|
||||||
|
with self.assertRaises(agent.TooBig):
|
||||||
|
self.cli.download(self.eva.base_url + "/files/big.bin", limit)
|
||||||
|
self.assertEqual(len(self.cli.download(self.eva.base_url + "/files/ok.bin", limit)), limit)
|
||||||
|
|
||||||
|
def test_download_404(self):
|
||||||
|
with self.assertRaises(agent.HttpError) as cm:
|
||||||
|
self.cli.download(self.eva.base_url + "/files/none", 10)
|
||||||
|
self.assertEqual(cm.exception.code, 404)
|
||||||
|
|
||||||
|
def test_token_not_logged(self):
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
texts = []
|
||||||
|
with redirect_stdout(out), redirect_stderr(err):
|
||||||
|
self.eva.fail_next(503, 8)
|
||||||
|
for call in (lambda: self.cli.call("CmfDocument.list", filter=[], fields=["code"]),
|
||||||
|
lambda: self.cli.download(self.eva.base_url + "/files/none", 10)):
|
||||||
|
try:
|
||||||
|
call()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
texts.append(str(e))
|
||||||
|
self.eva.rpc_error_next("сбой eva-secret")
|
||||||
|
try:
|
||||||
|
self.cli.call("CmfDocument.get", filter=[], fields=["code"])
|
||||||
|
except agent.EvaError as e:
|
||||||
|
texts.append(str(e))
|
||||||
|
dead = agent.EvaClient(c.eva_cfg("http://127.0.0.1:1"))
|
||||||
|
dead.sleep = lambda s: None
|
||||||
|
try:
|
||||||
|
dead.call("CmfDocument.get", filter=[], fields=["code"])
|
||||||
|
except ConnectionError as e:
|
||||||
|
texts.append(str(e))
|
||||||
|
self.assertEqual(len(texts), 4)
|
||||||
|
for t in texts + [out.getvalue(), err.getvalue()]:
|
||||||
|
self.assertNotIn("eva-secret", t)
|
||||||
|
self.assertNotIn("eva-secret", repr(self.cli))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
251
tests/test_folder.py
Normal file
251
tests/test_folder.py
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent, eva_cfg, no_sleep
|
||||||
|
from fake_eva import FakeEva
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
from fake_relay import FakeRelay
|
||||||
|
|
||||||
|
|
||||||
|
class FolderTransportTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge") # пробелы в пути
|
||||||
|
os.makedirs(self.root)
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
self.relay = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self.relay:
|
||||||
|
self.relay.stop()
|
||||||
|
self.hub.stop()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def client(self, timeout=5):
|
||||||
|
cfg = {"hub_url": "folder:" + self.root, "hub_token": self.hub.token, "timeout": timeout}
|
||||||
|
return agent.HubClient(cfg)
|
||||||
|
|
||||||
|
def run_relay(self, mode="forward", delay=0.0):
|
||||||
|
self.relay = FakeRelay(self.root, self.hub.base_url, mode, delay).start()
|
||||||
|
return self.relay
|
||||||
|
|
||||||
|
def listing(self, sub):
|
||||||
|
return sorted(os.listdir(os.path.join(self.root, sub)))
|
||||||
|
|
||||||
|
def test_settings_through_folder(self):
|
||||||
|
self.run_relay()
|
||||||
|
hub = self.client()
|
||||||
|
self.assertIsNotNone(hub.folder)
|
||||||
|
self.assertEqual(hub.settings()["settings_version"], 1)
|
||||||
|
self.assertEqual(self.listing("resp"), [])
|
||||||
|
self.assertEqual(self.listing("req"), [])
|
||||||
|
req = self.relay.requests[0]
|
||||||
|
self.assertEqual(req["method"], "GET")
|
||||||
|
self.assertEqual(req["path"], "/agent/settings")
|
||||||
|
self.assertEqual(req["authorization"], "Bearer " + self.hub.token)
|
||||||
|
self.assertEqual(len(req["id"]), 32)
|
||||||
|
|
||||||
|
def test_error_status_raises_http_error(self):
|
||||||
|
self.run_relay()
|
||||||
|
with self.assertRaises(agent.HttpError) as e:
|
||||||
|
self.client().result(999, {"status": "done"})
|
||||||
|
self.assertEqual(e.exception.code, 404)
|
||||||
|
|
||||||
|
def test_claim_deadline_at_least_45_seconds(self):
|
||||||
|
self.run_relay()
|
||||||
|
before = time.time()
|
||||||
|
self.client(timeout=5).claim(2, 1, None)
|
||||||
|
deadline = datetime.fromisoformat(self.relay.requests[0]["deadline"])
|
||||||
|
self.assertIsNotNone(deadline.tzinfo)
|
||||||
|
self.assertGreaterEqual(deadline.timestamp() - before, 44.5)
|
||||||
|
|
||||||
|
def test_no_answer_gives_connection_error_and_removes_request(self):
|
||||||
|
hub = self.client(timeout=0.5)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
with self.assertRaises(ConnectionError) as e:
|
||||||
|
hub.settings()
|
||||||
|
self.assertLess(time.monotonic() - t0, 2)
|
||||||
|
self.assertIn("не ответил через папку", str(e.exception))
|
||||||
|
self.assertEqual([n for n in self.listing("req") if not n.startswith(".")], [])
|
||||||
|
|
||||||
|
def test_missing_folder_gives_connection_error(self):
|
||||||
|
cfg = {"hub_url": "folder:" + os.path.join(self.tmp.name, "нет"), "hub_token": "t", "timeout": 5}
|
||||||
|
with self.assertRaises(ConnectionError) as e:
|
||||||
|
agent.HubClient(cfg).settings()
|
||||||
|
self.assertIn("нет папки", str(e.exception))
|
||||||
|
|
||||||
|
def test_bad_json_response_gives_connection_error(self):
|
||||||
|
self.run_relay("garbage")
|
||||||
|
hub = self.client()
|
||||||
|
hub.folder.BAD_JSON_WAIT = 0.3
|
||||||
|
with self.assertRaises(ConnectionError) as e:
|
||||||
|
hub.settings()
|
||||||
|
self.assertIn("нечитаемый ответ", str(e.exception))
|
||||||
|
|
||||||
|
def _bad_response(self, payload):
|
||||||
|
path = os.path.join(self.root, "resp-bad.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f)
|
||||||
|
ft = agent.FolderTransport(self.root, "Bearer x")
|
||||||
|
ft.BAD_JSON_WAIT = 0.3
|
||||||
|
return ft, path
|
||||||
|
|
||||||
|
def test_wrong_status_type_is_unreadable(self):
|
||||||
|
for payload in ({"id": "x", "status": "ok", "body": "{}"}, {"id": "x", "status": True, "body": "{}"},
|
||||||
|
{"id": "x", "status": 200, "body": 5}):
|
||||||
|
ft, path = self._bad_response(payload)
|
||||||
|
with self.assertRaises(ConnectionError) as e:
|
||||||
|
ft._take(path)
|
||||||
|
self.assertIn("нечитаемый ответ", str(e.exception))
|
||||||
|
|
||||||
|
def test_no_answer_message_has_no_hub_prefix(self):
|
||||||
|
hub = self.client(timeout=0.3)
|
||||||
|
with self.assertRaises(ConnectionError) as e:
|
||||||
|
hub.settings()
|
||||||
|
self.assertFalse(str(e.exception).startswith("хаб")) # цикл сам пишет «хаб: …» (O-3 agent#8)
|
||||||
|
|
||||||
|
def test_late_response_is_not_reused(self):
|
||||||
|
self.run_relay("late", delay=1.0)
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
self.client(timeout=0.3).settings()
|
||||||
|
time.sleep(1.3) # опоздавший ответ лёг в resp/
|
||||||
|
late = self.listing("resp")
|
||||||
|
self.assertEqual(len(late), 1)
|
||||||
|
self.relay.mode = "forward"
|
||||||
|
self.assertEqual(self.client().settings()["settings_version"], 1)
|
||||||
|
self.assertEqual(self.listing("resp"), late) # чужой (опоздавший) ответ не тронут
|
||||||
|
|
||||||
|
def test_foreign_files_untouched(self):
|
||||||
|
resp_dir = os.path.join(self.root, "resp")
|
||||||
|
os.makedirs(resp_dir, exist_ok=True)
|
||||||
|
foreign = os.path.join(resp_dir, "0" * 32 + ".json")
|
||||||
|
with open(foreign, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"id": "0" * 32, "status": 200, "body": "{}"}, f)
|
||||||
|
self.run_relay()
|
||||||
|
self.client().settings()
|
||||||
|
self.assertTrue(os.path.exists(foreign))
|
||||||
|
|
||||||
|
def test_http_url_keeps_http_transport(self):
|
||||||
|
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
|
||||||
|
hub = agent.HubClient(cfg)
|
||||||
|
self.assertIsNone(hub.folder)
|
||||||
|
self.assertEqual(hub.settings()["settings_version"], 1)
|
||||||
|
|
||||||
|
def claimed_sync(self):
|
||||||
|
cid = self.hub.enqueue("wiki_sync", {"label": "x", "root": "DOC-1"})
|
||||||
|
self.hub.queue.remove(cid)
|
||||||
|
self.hub.commands[cid]["status"] = "claimed"
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def test_wiki_pages_through_folder(self):
|
||||||
|
self.run_relay()
|
||||||
|
cid = self.claimed_sync()
|
||||||
|
pages = [{"code": "DOC-1", "title": "Корень", "html": "<p>" + "я" * 200000 + "</p>"}]
|
||||||
|
got = self.client().wiki_pages(cid, pages)
|
||||||
|
self.assertEqual(got, {"accepted": 1})
|
||||||
|
self.assertEqual(self.hub.pages, [{"command_id": cid, "pages": pages}])
|
||||||
|
self.assertEqual(self.relay.requests[0]["path"], "/agent/wiki/pages")
|
||||||
|
|
||||||
|
def test_wiki_pages_over_http(self):
|
||||||
|
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
|
||||||
|
cid = self.claimed_sync()
|
||||||
|
self.assertEqual(hub.wiki_pages(cid, [{"code": "DOC-1", "title": "T", "html": ""}]), {"accepted": 1})
|
||||||
|
self.assertEqual(self.hub.pages[0]["command_id"], cid)
|
||||||
|
|
||||||
|
def test_wiki_pages_rejected_is_http_error(self):
|
||||||
|
self.hub.reject_pages = (422, {"error": "bad_page"})
|
||||||
|
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
|
||||||
|
with self.assertRaises(agent.HttpError) as e:
|
||||||
|
hub.wiki_pages(5, [{}])
|
||||||
|
self.assertEqual(e.exception.code, 422)
|
||||||
|
|
||||||
|
def test_upload_file_through_folder(self):
|
||||||
|
self.run_relay()
|
||||||
|
data = os.urandom(300 * 1024)
|
||||||
|
got = self.client().upload_file(data)
|
||||||
|
self.assertEqual(got, {"file": hashlib.sha256(data).hexdigest(), "size": len(data)})
|
||||||
|
self.assertEqual(self.hub.uploaded, [data])
|
||||||
|
self.assertEqual(self.relay.requests[0]["path"], "/agent/files")
|
||||||
|
|
||||||
|
def test_upload_file_over_http(self):
|
||||||
|
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
|
||||||
|
self.assertEqual(hub.upload_file(b""), {"file": hashlib.sha256(b"").hexdigest(), "size": 0})
|
||||||
|
self.assertEqual(self.hub.uploaded, [b""])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FolderIntegrationTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = os.path.join(self.tmp.name, "My Shared Files", "jira-bridge")
|
||||||
|
os.makedirs(self.root)
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
self.eva = FakeEva() # check ходит и в Eva (design 10)
|
||||||
|
self.relay = FakeRelay(self.root, self.hub.base_url).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.relay.stop()
|
||||||
|
self.hub.stop()
|
||||||
|
self.eva.stop()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def cfg(self):
|
||||||
|
return eva_cfg(self.eva.base_url, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||||
|
|
||||||
|
def test_version(self):
|
||||||
|
self.assertEqual(agent.AGENT_VERSION, 1)
|
||||||
|
|
||||||
|
def test_runner_through_folder(self):
|
||||||
|
cid = self.hub.enqueue("wiki_fetch", {"url": "https://eva.example.ru/files/a.png", "name": "a.png"})
|
||||||
|
cfg = self.cfg()
|
||||||
|
journal = agent.Journal(os.path.join(self.tmp.name, "journal.jsonl"))
|
||||||
|
runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), journal)
|
||||||
|
runner.sleep = lambda s: None
|
||||||
|
runner.eva.sleep = no_sleep
|
||||||
|
runner.idle_limit = 1
|
||||||
|
runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[cid]["status"], "failed") # заглушка Executor
|
||||||
|
self.assertEqual(self.hub.results[cid]["error"], "чужой хост")
|
||||||
|
self.assertEqual(self.hub.results[cid]["agent_version"], agent.AGENT_VERSION)
|
||||||
|
self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])
|
||||||
|
|
||||||
|
def test_check_reports_folder(self):
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
code = agent.cmd_check(self.cfg())
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertIn("хаб: доступен через папку %s, настройки v1" % self.root, out.getvalue())
|
||||||
|
|
||||||
|
def test_check_folder_silent_relay(self):
|
||||||
|
self.relay.stop()
|
||||||
|
self.relay = FakeRelay(self.root, self.hub.base_url, "silent").start()
|
||||||
|
out = io.StringIO()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
code = agent.cmd_check(self.cfg())
|
||||||
|
self.assertEqual(code, 3)
|
||||||
|
self.assertLess(time.monotonic() - t0, 13)
|
||||||
|
self.assertIn("хаб: НЕДОСТУПЕН", out.getvalue())
|
||||||
|
self.assertIn("не ответил через папку", out.getvalue())
|
||||||
|
|
||||||
|
def test_relative_folder_url_rejected(self):
|
||||||
|
path = os.path.join(self.tmp.name, "agent.json")
|
||||||
|
for url in ("folder:relative/path", "folder:"):
|
||||||
|
cfg = self.cfg()
|
||||||
|
cfg["hub_url"] = url
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f)
|
||||||
|
with self.assertRaises(SystemExit) as e:
|
||||||
|
agent.load_config(path)
|
||||||
|
self.assertIn("абсолютный путь", str(e.exception.code))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
154
tests/test_journal.py
Normal file
154
tests/test_journal.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from _common import agent
|
||||||
|
|
||||||
|
|
||||||
|
class JournalTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.path = os.path.join(self.dir.name, "journal.jsonl")
|
||||||
|
self.j = agent.Journal(self.path)
|
||||||
|
patcher = mock.patch.object(agent, "log")
|
||||||
|
self.log = patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.dir.cleanup()
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
self.assertEqual(self.j.records(), [])
|
||||||
|
self.assertEqual(self.j.unacked_finished(), [])
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [])
|
||||||
|
|
||||||
|
def test_lifecycle(self):
|
||||||
|
self.j.started(1)
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [1])
|
||||||
|
self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0)
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [])
|
||||||
|
pending = self.j.unacked_finished()
|
||||||
|
self.assertEqual(len(pending), 1)
|
||||||
|
self.assertEqual(pending[0]["command_id"], 1)
|
||||||
|
self.assertEqual(pending[0]["result"], {"key": "A-1"})
|
||||||
|
self.assertEqual(pending[0]["jira_calls"], 2)
|
||||||
|
self.j.acked(1)
|
||||||
|
self.assertEqual(self.j.unacked_finished(), [])
|
||||||
|
self.j.started(2)
|
||||||
|
self.j.started(3)
|
||||||
|
self.j.finished(3, "failed", None, "HTTP 400", 1, 0)
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [2])
|
||||||
|
self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3])
|
||||||
|
|
||||||
|
def test_broken_line_is_skipped(self):
|
||||||
|
self.j.started(1)
|
||||||
|
with open(self.path, "a", encoding="utf-8") as f:
|
||||||
|
f.write("{не json\n")
|
||||||
|
self.j.finished(1, "done", None, None, 1, 0)
|
||||||
|
self.assertEqual(len(self.j.records()), 2)
|
||||||
|
self.log.assert_called_with("журнал: строка 2 не читается, пропускаю")
|
||||||
|
|
||||||
|
def test_prune_keeps_unacked_and_recent(self):
|
||||||
|
self.j.started(1)
|
||||||
|
self.j.finished(1, "done", None, None, 1, 0)
|
||||||
|
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
|
||||||
|
self.j.started(2)
|
||||||
|
self.j.finished(2, "done", None, None, 1, 0)
|
||||||
|
self.j.acked(2)
|
||||||
|
self.j.started(3)
|
||||||
|
removed = self.j.prune(days=30)
|
||||||
|
self.assertEqual(removed, 3)
|
||||||
|
ids = [(r["kind"], r["command_id"]) for r in self.j.records()]
|
||||||
|
self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)])
|
||||||
|
self.assertEqual(self.j.prune(days=30), 0)
|
||||||
|
|
||||||
|
def test_no_secrets_in_records(self):
|
||||||
|
self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0)
|
||||||
|
with open(self.path, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
self.assertNotIn("password", text)
|
||||||
|
self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished")
|
||||||
|
|
||||||
|
|
||||||
|
def test_torn_tail_is_not_glued(self):
|
||||||
|
with open(self.path, "wb") as f:
|
||||||
|
f.write('{"kind": "star'.encode("utf-8"))
|
||||||
|
self.j.started(1)
|
||||||
|
recs = self.j.records()
|
||||||
|
self.assertEqual([(r["kind"], r["command_id"]) for r in recs], [("started", 1)])
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [1])
|
||||||
|
|
||||||
|
def test_restart_after_acked_reopens(self):
|
||||||
|
self.j.started(1)
|
||||||
|
self.j.finished(1, "done", None, None, 1, 0)
|
||||||
|
self.j.acked(1)
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [])
|
||||||
|
self.j.started(1)
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [1])
|
||||||
|
|
||||||
|
def test_duplicate_started_is_deduplicated_in_first_open_order(self):
|
||||||
|
self.j.started(2)
|
||||||
|
self.j.started(1)
|
||||||
|
self.j.started(1)
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [2, 1])
|
||||||
|
self.j.finished(2, "done", None, None, 1, 0)
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [1])
|
||||||
|
|
||||||
|
def test_prune_keeps_id_with_newer_records(self):
|
||||||
|
self.j.started(1)
|
||||||
|
self.j.finished(1, "done", None, None, 1, 0)
|
||||||
|
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
|
||||||
|
self.j.started(1)
|
||||||
|
self.j.finished(1, "done", None, None, 1, 0)
|
||||||
|
before = self.j.records()
|
||||||
|
self.assertEqual(self.j.prune(days=30), 0)
|
||||||
|
self.assertEqual(self.j.records(), before)
|
||||||
|
|
||||||
|
def test_non_dict_and_no_command_id_lines_are_skipped(self):
|
||||||
|
with open(self.path, "wb") as f:
|
||||||
|
f.write(b'[1, 2]\n5\n{"kind": "started"}\n')
|
||||||
|
self.j.started(1)
|
||||||
|
self.j.finished(1, "done", None, None, 1, 0)
|
||||||
|
self.j.acked(1)
|
||||||
|
self.assertEqual(len(self.j.records()), 3)
|
||||||
|
self.assertEqual(self.j.unacked_finished(), [])
|
||||||
|
self.assertEqual(self.j.unfinished_started(), [])
|
||||||
|
self.assertEqual(self.j.prune(days=30), 0)
|
||||||
|
self.log.assert_any_call("журнал: удаляю битых строк 3")
|
||||||
|
self.assertEqual(len(self.j.records()), 3)
|
||||||
|
with open(self.path, encoding="utf-8") as f:
|
||||||
|
self.assertNotIn("[1, 2]", f.read())
|
||||||
|
|
||||||
|
def test_naive_and_invalid_at_in_prune(self):
|
||||||
|
self.j.started(1)
|
||||||
|
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00"})
|
||||||
|
self.j.started(2)
|
||||||
|
self.j.append({"kind": "acked", "command_id": 2, "at": "не дата"})
|
||||||
|
self.assertEqual(self.j.prune(days=30), 2)
|
||||||
|
self.assertEqual([(r["kind"], r["command_id"]) for r in self.j.records()], [("started", 2), ("acked", 2)])
|
||||||
|
|
||||||
|
def test_journal_file_is_private(self):
|
||||||
|
self.j.started(1)
|
||||||
|
mode = os.stat(self.path).st_mode & 0o777
|
||||||
|
self.assertEqual(mode & 0o077, 0)
|
||||||
|
|
||||||
|
def test_last_at_is_latest_record_time(self):
|
||||||
|
self.assertIsNone(self.j.last_at())
|
||||||
|
self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"})
|
||||||
|
self.j.append({"kind": "finished", "command_id": 1, "at": "2026-10-13T10:05:00+00:00"})
|
||||||
|
self.j.append({"kind": "acked", "command_id": 1, "at": "2026-10-13T10:04:00+00:00"})
|
||||||
|
self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 5, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
def test_last_at_skips_bad_and_missing_times(self):
|
||||||
|
self.j.append({"kind": "claimed", "command_id": 1, "at": "2026-10-13T10:00:00+00:00"})
|
||||||
|
self.j.append({"kind": "started", "command_id": 1, "at": "не время"})
|
||||||
|
self.j.append({"kind": "started", "command_id": 1, "at": "2026-10-13T11:00:00"}) # без зоны — пропуск
|
||||||
|
with open(self.path, "a", encoding="utf-8") as f:
|
||||||
|
f.write("битая строка\n")
|
||||||
|
self.assertEqual(self.j.last_at(), datetime(2026, 10, 13, 10, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
503
tests/test_launcher.py
Normal file
503
tests/test_launcher.py
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from _common import SRC
|
||||||
|
|
||||||
|
if SRC not in sys.path:
|
||||||
|
sys.path.insert(0, SRC)
|
||||||
|
import run # noqa: E402
|
||||||
|
|
||||||
|
# Поддельные агенты: маленькие скрипты, которые ведут себя как agent.py по контракту (коды выхода, update.json).
|
||||||
|
STATE_HELPERS = textwrap.dedent('''
|
||||||
|
import json, os, sys
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STATE = os.path.join(HERE, "update.json")
|
||||||
|
def state():
|
||||||
|
try:
|
||||||
|
return json.load(open(STATE, encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
def write(**kw):
|
||||||
|
json.dump(kw, open(STATE, "w", encoding="utf-8"))
|
||||||
|
def mark(name):
|
||||||
|
open(os.path.join(HERE, "runs.log"), "a").write(name + "\\n")
|
||||||
|
def human_stop(code=0):
|
||||||
|
# Ctrl-C в терминале получает вся группа: запускатель тоже видит SIGINT, затем агент выходит
|
||||||
|
import signal
|
||||||
|
os.kill(os.getppid(), signal.SIGINT)
|
||||||
|
sys.exit(code)
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
def fake(name, body):
|
||||||
|
return STATE_HELPERS + "mark(%r)\n" % name + textwrap.dedent(body)
|
||||||
|
|
||||||
|
|
||||||
|
# v5 «нашёл» новую версию: кладёт agent.py.new из next.py, пишет staged и выходит с 75; после отката/отказа — выход 0.
|
||||||
|
V5_UPDATES = fake("v5", '''
|
||||||
|
import hashlib
|
||||||
|
st = state()
|
||||||
|
if st is None:
|
||||||
|
data = open(os.path.join(HERE, "next.py"), "rb").read()
|
||||||
|
open(os.path.join(HERE, "agent.py.new"), "wb").write(data)
|
||||||
|
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
|
||||||
|
sys.exit(75)
|
||||||
|
human_stop()
|
||||||
|
''')
|
||||||
|
# v5 с подменённым после проверки файлом: sha в staged не сходится с agent.py.new.
|
||||||
|
V5_BAD_STAGE = fake("v5", '''
|
||||||
|
st = state()
|
||||||
|
if st is None:
|
||||||
|
open(os.path.join(HERE, "agent.py.new"), "w").write("мусор")
|
||||||
|
write(state="staged", from_version=5, to_version=6, sha256="чужой")
|
||||||
|
sys.exit(75)
|
||||||
|
human_stop()
|
||||||
|
''')
|
||||||
|
V6_OK_THEN_STOP = fake("v6", '''
|
||||||
|
st = state()
|
||||||
|
write(**dict(st, state="ok"))
|
||||||
|
human_stop()
|
||||||
|
''')
|
||||||
|
V6_CRASH = fake("v6", 'sys.exit(1)\n')
|
||||||
|
V6_HANG = fake("v6", 'import time\ntime.sleep(60)\n')
|
||||||
|
V6_SEES_STATE = fake("v6", '''
|
||||||
|
mark("seen:" + (state() or {}).get("state", "-"))
|
||||||
|
write(**dict(state(), state="ok"))
|
||||||
|
human_stop()
|
||||||
|
''')
|
||||||
|
V6_OK_ON_TERM = fake("v6", '''
|
||||||
|
import signal, time
|
||||||
|
if len(open(os.path.join(HERE, "runs.log")).read().split()) > 2:
|
||||||
|
human_stop()
|
||||||
|
def term(*a):
|
||||||
|
write(**dict(state(), state="ok")) # ответ claim пришёл в последнюю секунду пробного периода
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGTERM, term)
|
||||||
|
time.sleep(60)
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
# v6 подтвердился (ok), через delay с записал rejected для v10 с другим sha (как Updater._reject) и упал с кодом 1.
|
||||||
|
V6_OK_THEN_REJECT_NEXT = fake("v6", '''
|
||||||
|
import time
|
||||||
|
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||||
|
if n > 2:
|
||||||
|
human_stop()
|
||||||
|
st = state()
|
||||||
|
write(**dict(st, state="ok"))
|
||||||
|
time.sleep(DELAY)
|
||||||
|
write(state="rejected", from_version=6, to_version=10, sha256="чужой", error="отвергнута")
|
||||||
|
sys.exit(1)
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
class LauncherTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.dir = self.tmp.name
|
||||||
|
self.l = run.Launcher(self.dir, ["--journal", "j.jsonl"])
|
||||||
|
self.pauses = []
|
||||||
|
self.l.pause = lambda s: (self.pauses.append(s), True)[1]
|
||||||
|
self.old_sigint = signal.signal(signal.SIGINT, self.l.on_sigint) # как в run.main: Ctrl-C видит и запускатель
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
signal.signal(signal.SIGINT, self.old_sigint)
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def put(self, name, text):
|
||||||
|
with open(os.path.join(self.dir, name), "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
def read(self, name):
|
||||||
|
with open(os.path.join(self.dir, name), encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def state(self):
|
||||||
|
return json.loads(self.read("update.json"))
|
||||||
|
|
||||||
|
def runs(self):
|
||||||
|
return self.read("runs.log").split()
|
||||||
|
|
||||||
|
def next_version(self, text):
|
||||||
|
self.put("next.py", text)
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def stage(self, new_text, to_version=6):
|
||||||
|
self.put("agent.py.new", new_text)
|
||||||
|
sha = hashlib.sha256(new_text.encode("utf-8")).hexdigest()
|
||||||
|
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": to_version, "sha256": sha}))
|
||||||
|
return sha
|
||||||
|
|
||||||
|
def sha(self, text):
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def path(self, name):
|
||||||
|
return os.path.join(self.dir, name)
|
||||||
|
|
||||||
|
def test_update_applied_and_confirmed(self):
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
sha = self.next_version(V6_OK_THEN_STOP)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP)
|
||||||
|
self.assertEqual(self.read("agent.py.prev"), V5_UPDATES)
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 5, 6, sha))
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v6"])
|
||||||
|
|
||||||
|
def test_crash_in_trial_rolls_back(self):
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
sha = self.next_version(V6_CRASH)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V5_UPDATES)
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual((st["state"], st["to_version"], st["sha256"]), ("rolled_back", 6, sha))
|
||||||
|
self.assertRegex(st["error"], r"^код 1 через \d+ с$")
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v6", "v5"])
|
||||||
|
self.assertEqual(self.pauses, []) # откат без паузы
|
||||||
|
|
||||||
|
def test_hang_in_trial_rolls_back(self):
|
||||||
|
self.l.trial_seconds = 1
|
||||||
|
self.l.kill_grace = 5
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
self.next_version(V6_HANG)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V5_UPDATES)
|
||||||
|
self.assertEqual(self.state()["error"], "не вышла на связь за 10 мин")
|
||||||
|
|
||||||
|
def test_crash_after_ok_restarts_with_backoff(self):
|
||||||
|
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
|
||||||
|
self.put("agent.py", fake("v6", '''
|
||||||
|
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||||
|
human_stop() if n > 2 else sys.exit(1)
|
||||||
|
'''))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.pauses, [5, 10])
|
||||||
|
self.assertEqual(self.state()["state"], "ok") # отката нет
|
||||||
|
|
||||||
|
def test_backoff_caps_and_resets(self):
|
||||||
|
self.assertEqual([self.l.next_backoff(10) for _ in range(8)], [5, 10, 20, 40, 80, 160, 300, 300])
|
||||||
|
self.assertEqual(self.l.next_backoff(600), 5) # проработал 10 минут — с начала
|
||||||
|
|
||||||
|
def test_75_without_staged_rejected(self):
|
||||||
|
self.put("agent.py", V5_BAD_STAGE)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V5_BAD_STAGE)
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v5"])
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual((st["state"], st["error"]), ("rejected", "файл изменился после проверки"))
|
||||||
|
|
||||||
|
def test_staged_at_start_is_applied(self):
|
||||||
|
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||||
|
self.stage(V6_OK_THEN_STOP)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.runs(), ["v6"])
|
||||||
|
self.assertEqual(self.state()["state"], "ok")
|
||||||
|
|
||||||
|
def test_stray_new_removed_at_start(self):
|
||||||
|
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||||
|
self.put("agent.py.new", "остаток")
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "agent.py.new")))
|
||||||
|
|
||||||
|
def test_missing_agent_restored_from_prev(self):
|
||||||
|
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.runs(), ["v5"])
|
||||||
|
|
||||||
|
def test_garbage_update_json_is_no_state(self):
|
||||||
|
self.put("update.json", '{"state": "tri')
|
||||||
|
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
|
||||||
|
def test_env_and_args_passed(self):
|
||||||
|
self.put("agent.py", fake("v5", '''
|
||||||
|
open(os.path.join(HERE, "env.txt"), "w").write(os.environ.get("JIRA_AGENT_LAUNCHER", "") + " " + " ".join(sys.argv[1:]))
|
||||||
|
human_stop(130)
|
||||||
|
'''))
|
||||||
|
self.assertEqual(self.l.run(), 130)
|
||||||
|
self.assertEqual(self.read("env.txt"), "2 run --journal j.jsonl")
|
||||||
|
|
||||||
|
def test_sigint_without_child_stops(self):
|
||||||
|
self.l.on_sigint(signal.SIGINT, None)
|
||||||
|
self.assertTrue(self.l.stopping)
|
||||||
|
|
||||||
|
def test_sigint_with_child_ignored(self):
|
||||||
|
self.l.child = object()
|
||||||
|
self.l.on_sigint(signal.SIGINT, None)
|
||||||
|
self.assertFalse(self.l.stopping)
|
||||||
|
self.assertTrue(self.l.interrupted) # выход агента с 0 — остановка человеком
|
||||||
|
|
||||||
|
def test_exit_0_without_signal_in_trial_rolls_back(self):
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
self.next_version(fake("v6", "sys.exit(0)\n")) # сама вышла, никто не нажимал Ctrl-C (I-1)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V5_UPDATES)
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual(st["state"], "rolled_back")
|
||||||
|
self.assertRegex(st["error"], r"^код 0 через \d+ с$")
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v6", "v5"])
|
||||||
|
|
||||||
|
def test_exit_0_without_signal_after_ok_restarts(self):
|
||||||
|
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
|
||||||
|
self.put("agent.py", fake("v6", '''
|
||||||
|
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||||
|
human_stop() if n > 1 else sys.exit(0)
|
||||||
|
'''))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.pauses, [5]) # выход 0 без сигнала — как падение
|
||||||
|
self.assertEqual(self.runs(), ["v6", "v6"])
|
||||||
|
|
||||||
|
def test_ctrl_c_with_75_applies_and_stops(self):
|
||||||
|
self.put("next.py", V6_OK_THEN_STOP)
|
||||||
|
self.put("agent.py", fake("v5", '''
|
||||||
|
import hashlib, signal
|
||||||
|
data = open(os.path.join(HERE, "next.py"), "rb").read()
|
||||||
|
open(os.path.join(HERE, "agent.py.new"), "wb").write(data)
|
||||||
|
write(state="staged", from_version=5, to_version=6, sha256=hashlib.sha256(data).hexdigest())
|
||||||
|
os.kill(os.getppid(), signal.SIGINT) # Ctrl-C пришёл, когда версия уже подготовлена
|
||||||
|
sys.exit(75)
|
||||||
|
'''))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V6_OK_THEN_STOP) # подмена сделана, новая не запущена
|
||||||
|
self.assertEqual(self.state()["state"], "trial") # пробный период начнётся при старте
|
||||||
|
self.assertEqual(self.runs(), ["v5"])
|
||||||
|
|
||||||
|
def test_stale_interrupt_does_not_hide_exit_0(self):
|
||||||
|
self.put("update.json", json.dumps({"state": "ok", "from_version": 5, "to_version": 6, "sha256": "s"}))
|
||||||
|
self.put("agent.py", fake("v6", '''
|
||||||
|
import signal
|
||||||
|
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||||
|
if n == 1:
|
||||||
|
os.kill(os.getppid(), signal.SIGINT) # Ctrl-C до установки обработчиков агента
|
||||||
|
sys.exit(1)
|
||||||
|
human_stop() if n > 2 else sys.exit(0)
|
||||||
|
'''))
|
||||||
|
self.l.stopping = False
|
||||||
|
orig = self.l.on_sigint
|
||||||
|
def sigint_child_alive(signum, frame): # агент жив — запускатель только запоминает Ctrl-C
|
||||||
|
self.l.child = self.l.child or object()
|
||||||
|
orig(signum, frame)
|
||||||
|
signal.signal(signal.SIGINT, sigint_child_alive)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.runs(), ["v6", "v6", "v6"]) # второй выход 0 без сигнала — перезапуск
|
||||||
|
self.assertEqual(len(self.pauses), 2)
|
||||||
|
|
||||||
|
def test_stop_before_spawn(self):
|
||||||
|
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||||
|
self.l.stopping = True # SIGTERM пришёл между запусками (M-2)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
|
||||||
|
|
||||||
|
def test_pause_never_sleeps_negative(self):
|
||||||
|
clock = iter([0.0, 0.5, 1.5, 1.6]) # остаток перешёл через ноль между вызовами (M-1)
|
||||||
|
slept = []
|
||||||
|
orig_m, orig_s = run.time.monotonic, run.time.sleep
|
||||||
|
run.time.monotonic, run.time.sleep = (lambda: next(clock)), slept.append
|
||||||
|
try:
|
||||||
|
self.assertTrue(run.Launcher.pause(self.l, 1.0))
|
||||||
|
finally:
|
||||||
|
run.time.monotonic, run.time.sleep = orig_m, orig_s
|
||||||
|
self.assertTrue(all(x >= 0 for x in slept), slept)
|
||||||
|
|
||||||
|
def test_second_launcher_refused(self):
|
||||||
|
with open(os.path.join(SRC, "run.py"), encoding="utf-8") as f:
|
||||||
|
self.put("run.py", f.read())
|
||||||
|
self.put("agent.py", fake("v5", "sys.exit(0)\n"))
|
||||||
|
lock = run.acquire_lock(self.dir)
|
||||||
|
try:
|
||||||
|
p = subprocess.run([sys.executable, os.path.join(self.dir, "run.py")], stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, timeout=30)
|
||||||
|
finally:
|
||||||
|
lock.close()
|
||||||
|
self.assertEqual(p.returncode, 1)
|
||||||
|
self.assertIn("запускатель уже работает", p.stdout.decode("utf-8"))
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.dir, "runs.log")))
|
||||||
|
|
||||||
|
def test_staged_already_replaced_resumes_trial(self):
|
||||||
|
"""M-5: запускатель упал между os.replace(new, agent) и записью trial."""
|
||||||
|
self.stage(V6_SEES_STATE)
|
||||||
|
os.replace(self.path("agent.py.new"), self.path("agent.py"))
|
||||||
|
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.runs(), ["v6", "seen:trial"]) # пробный период, а не staged навсегда
|
||||||
|
self.assertEqual(self.state()["state"], "ok")
|
||||||
|
|
||||||
|
def test_staged_without_file_rejected(self):
|
||||||
|
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||||
|
self.put("update.json", json.dumps({"state": "staged", "from_version": 5, "to_version": 6, "sha256": "a" * 64}))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual((st["state"], st["error"]), ("rejected", "подготовленный файл потерян"))
|
||||||
|
self.assertEqual(self.runs(), ["v5"])
|
||||||
|
|
||||||
|
def test_stale_trial_marked_rolled_back(self):
|
||||||
|
"""M-3: откат прерван — agent.py уже прежняя версия, update.json всё ещё trial."""
|
||||||
|
self.l.trial_seconds = 1
|
||||||
|
self.put("agent.py", fake("v8", "import time\ntime.sleep(2)\nhuman_stop()\n")) # дольше пробного периода
|
||||||
|
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9, "sha256": "a" * 64}))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual((st["state"], st["to_version"], st["error"]),
|
||||||
|
("rolled_back", 9, "пробный период прерван: agent.py не v9"))
|
||||||
|
self.assertEqual(self.runs(), ["v8"]) # не остановлен через пробный период
|
||||||
|
|
||||||
|
def test_trial_without_sha_is_stale(self):
|
||||||
|
self.put("agent.py", fake("v8", "human_stop()\n"))
|
||||||
|
self.put("update.json", json.dumps({"state": "trial", "from_version": 8, "to_version": 9}))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.state()["state"], "rolled_back")
|
||||||
|
|
||||||
|
def test_trial_survives_launcher_restart(self):
|
||||||
|
"""M-10 #22: trial при перезапуске run.py — пробный период продолжается, падение даёт откат."""
|
||||||
|
self.put("agent.py", V6_CRASH)
|
||||||
|
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
|
||||||
|
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(V6_CRASH)}))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.runs(), ["v6", "v5"])
|
||||||
|
self.assertEqual(self.state()["state"], "rolled_back")
|
||||||
|
|
||||||
|
def test_ok_at_last_moment_no_rollback(self):
|
||||||
|
"""M-4: пробный период истёк, но агент записал ok, получив SIGTERM."""
|
||||||
|
self.l.trial_seconds = 1
|
||||||
|
self.l.kill_grace = 5
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
self.next_version(V6_OK_ON_TERM)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V6_OK_ON_TERM)
|
||||||
|
self.assertEqual(self.state()["state"], "ok")
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
|
||||||
|
self.assertEqual(self.pauses, []) # перезапуск без паузы
|
||||||
|
|
||||||
|
def _confirmed_then_rejected_next(self, delay):
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
self.next_version(V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay)))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay)))
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v6", "v6"])
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual((st["state"], st["to_version"]), ("rejected", 10)) # не rolled_back: v10 не заблокирована
|
||||||
|
|
||||||
|
def test_confirmed_then_next_rejected_no_rollback(self):
|
||||||
|
"""I-1: агент записал ok, потом сам перезаписал update.json (rejected для v10) и упал — v6 здоровая, откат не нужен."""
|
||||||
|
self._confirmed_then_rejected_next(2.5)
|
||||||
|
|
||||||
|
def test_confirmed_and_rejected_within_poll_no_rollback(self):
|
||||||
|
"""I-1: ok и rejected записаны быстрее, чем запускатель опрашивает файл раз в секунду."""
|
||||||
|
self._confirmed_then_rejected_next(0)
|
||||||
|
|
||||||
|
def test_locked_in_trial_no_rollback(self):
|
||||||
|
"""M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию."""
|
||||||
|
v6 = fake("v6", '''
|
||||||
|
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||||
|
human_stop() if n > 1 else sys.exit(73)
|
||||||
|
''')
|
||||||
|
self.put("agent.py", v6)
|
||||||
|
self.put("agent.py.prev", fake("v5", "human_stop()\n"))
|
||||||
|
self.put("update.json", json.dumps({"state": "trial", "from_version": 5, "to_version": 6, "sha256": self.sha(v6)}))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.runs(), ["v6", "v6"])
|
||||||
|
self.assertEqual(self.pauses, [5])
|
||||||
|
self.assertEqual(self.read("agent.py"), v6)
|
||||||
|
self.assertEqual(self.state()["state"], "trial")
|
||||||
|
|
||||||
|
def test_locked_outside_trial_backs_off(self):
|
||||||
|
self.put("agent.py", fake("v6", '''
|
||||||
|
n = len(open(os.path.join(HERE, "runs.log")).read().split())
|
||||||
|
human_stop() if n > 3 else sys.exit(73)
|
||||||
|
'''))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.pauses, [5, 10, 20])
|
||||||
|
|
||||||
|
def test_os_error_before_replace_rejected(self):
|
||||||
|
"""M-6: копия в agent.py.prev.tmp не удалась (здесь — на месте файла каталог)."""
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
self.next_version(V6_OK_THEN_STOP)
|
||||||
|
os.mkdir(self.path("agent.py.prev.tmp"))
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V5_UPDATES)
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual(st["state"], "rejected")
|
||||||
|
self.assertTrue(st["error"].startswith("подмена не удалась: "), st["error"])
|
||||||
|
self.assertFalse(os.path.exists(self.path("agent.py.new")))
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v5"])
|
||||||
|
|
||||||
|
def test_trial_not_written_reverts(self):
|
||||||
|
orig = run.write_state
|
||||||
|
def flaky(path, state, base, error=None):
|
||||||
|
return False if state == "trial" else orig(path, state, base, error)
|
||||||
|
run.write_state = flaky
|
||||||
|
self.addCleanup(setattr, run, "write_state", orig)
|
||||||
|
self.put("agent.py", V5_UPDATES)
|
||||||
|
self.next_version(V6_OK_THEN_STOP)
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
self.assertEqual(self.read("agent.py"), V5_UPDATES) # agent.py.prev вернулся на место
|
||||||
|
st = self.state()
|
||||||
|
self.assertEqual((st["state"], st["error"]), ("rejected", "подмена не удалась: update.json не записан"))
|
||||||
|
self.assertEqual(self.runs(), ["v5", "v5"])
|
||||||
|
|
||||||
|
def test_write_state_error_does_not_raise(self):
|
||||||
|
self.assertFalse(run.write_state(self.path("нет/update.json"), "ok", {}))
|
||||||
|
|
||||||
|
def test_sha256_file_unreadable_is_none(self):
|
||||||
|
self.assertIsNone(run.sha256_file(self.path("нет-файла")))
|
||||||
|
|
||||||
|
def test_tmp_files_removed_at_start(self):
|
||||||
|
self.put("agent.py", fake("v5", "human_stop()\n"))
|
||||||
|
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
|
||||||
|
self.put(name, "остаток")
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
for name in ("agent.py.new.tmp", "agent.py.prev.tmp", "update.json.tmp"):
|
||||||
|
self.assertFalse(os.path.exists(self.path(name)), name)
|
||||||
|
|
||||||
|
def test_sigterm_forwarded_and_no_restart(self):
|
||||||
|
"""M-10 #22: on_sigterm — пересылка агенту, нового запуска нет."""
|
||||||
|
import threading
|
||||||
|
self.put("agent.py", fake("v5", '''
|
||||||
|
import signal, time
|
||||||
|
def term(*a):
|
||||||
|
mark("term")
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGTERM, term)
|
||||||
|
open(os.path.join(HERE, "ready"), "w").close()
|
||||||
|
time.sleep(30)
|
||||||
|
'''))
|
||||||
|
def send():
|
||||||
|
for _ in range(300):
|
||||||
|
if os.path.exists(self.path("ready")):
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.l.on_sigterm(signal.SIGTERM, None)
|
||||||
|
t = threading.Thread(target=send)
|
||||||
|
t.start()
|
||||||
|
self.assertEqual(self.l.run(), 0)
|
||||||
|
t.join()
|
||||||
|
self.assertEqual(self.runs(), ["v5", "term"])
|
||||||
|
|
||||||
|
|
||||||
|
class RunPyIsCopyTest(unittest.TestCase):
|
||||||
|
"""run.py — дословная копия запускателя jira-agent (design 2): правки общих частей делаются в обоих репозиториях."""
|
||||||
|
|
||||||
|
def test_run_py_is_copy(self):
|
||||||
|
# от worktree (<корень>/eva-agent/.wt/<срез>) и от основного checkout (<корень>/eva-agent) — разная глубина
|
||||||
|
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
candidates = [os.path.normpath(os.path.join(here, up, "jira-agent", "src", "run.py"))
|
||||||
|
for up in ("..", os.path.join("..", ".."), os.path.join("..", "..", ".."))]
|
||||||
|
origin = next((c for c in candidates if os.path.exists(c)), None)
|
||||||
|
if origin is None:
|
||||||
|
self.skipTest("рядом нет jira-agent/src/run.py (искал: %s)" % ", ".join(candidates))
|
||||||
|
with open(origin, "rb") as a, open(os.path.join(SRC, "run.py"), "rb") as b:
|
||||||
|
self.assertEqual(a.read(), b.read(), "src/run.py разошёлся с %s" % origin)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
106
tests/test_links.py
Normal file
106
tests/test_links.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""Разбор ссылок страницы Eva: вложения и ссылки на страницы (design 6–7, 12)."""
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from tests import _common # noqa: F401
|
||||||
|
import agent
|
||||||
|
|
||||||
|
BASE = "https://eva.test"
|
||||||
|
OBJ = "/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A0b1c2d3e-0000-4000-8000-000000000001"
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def page(name):
|
||||||
|
with open(os.path.join(HERE, "fixtures", "pages", name), encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyLinksTest(unittest.TestCase):
|
||||||
|
def test_image_relative_files(self):
|
||||||
|
att, links = agent.classify_links(page("image.html"), BASE)
|
||||||
|
self.assertEqual(att, [{"ref": OBJ + "/img.png?version=1", "url": BASE + OBJ + "/img.png", "name": "Схема"}])
|
||||||
|
self.assertEqual(links, [])
|
||||||
|
|
||||||
|
def test_thumbnail_maps_to_file(self):
|
||||||
|
att, _ = agent.classify_links(page("thumb_and_link.html"), BASE)
|
||||||
|
self.assertEqual(att[0]["url"], BASE + OBJ + "/model.xlsx")
|
||||||
|
self.assertEqual(att[0]["name"], "model.xlsx")
|
||||||
|
self.assertEqual(att[0]["ref"], OBJ + "/model.xlsx.meta/thumbnail.jpg?version=1")
|
||||||
|
self.assertEqual(att[2]["url"], BASE + OBJ + "/data.dbml")
|
||||||
|
self.assertEqual(att[2]["name"], "data.dbml")
|
||||||
|
|
||||||
|
def test_file_link_same_url(self):
|
||||||
|
att, _ = agent.classify_links(page("thumb_and_link.html"), BASE)
|
||||||
|
self.assertEqual(len(att), 3) # повторы не склеиваются
|
||||||
|
self.assertEqual(att[1]["url"], att[0]["url"])
|
||||||
|
self.assertEqual(att[1]["ref"], OBJ + "/model.xlsx?version=1")
|
||||||
|
self.assertEqual(att[1]["name"], "model.xlsx")
|
||||||
|
|
||||||
|
def test_static_ignored(self):
|
||||||
|
att, links = agent.classify_links('<img src="static/attach-file.png">', BASE)
|
||||||
|
self.assertEqual((att, links), ([], []))
|
||||||
|
|
||||||
|
def test_page_link_relative(self):
|
||||||
|
_, links = agent.classify_links(page("links.html"), BASE)
|
||||||
|
self.assertEqual(links[0], {"ref": "project/Document/DOC-019834", "code": "DOC-019834"})
|
||||||
|
self.assertEqual(links[1], {"ref": "https://eva.test/project/Document/doc-5", "code": "DOC-5"})
|
||||||
|
|
||||||
|
def test_foreign_host(self):
|
||||||
|
att, links = agent.classify_links(page("links.html"), BASE)
|
||||||
|
self.assertEqual(att, [])
|
||||||
|
self.assertEqual([l["code"] for l in links], ["DOC-019834", "DOC-5"])
|
||||||
|
att, links = agent.classify_links(
|
||||||
|
'<a href="https://other.test/files/x.png">x</a><a href="https://other.test/project/Document/DOC-1">y</a>',
|
||||||
|
BASE)
|
||||||
|
self.assertEqual((att, links), ([], []))
|
||||||
|
|
||||||
|
def test_name_unquoted(self):
|
||||||
|
att, _ = agent.classify_links(page("name_quoted.html"), BASE)
|
||||||
|
self.assertEqual(att[0]["name"], "ПМП.xlsx")
|
||||||
|
self.assertEqual(att[0]["url"], BASE + OBJ + "/%D0%9F%D0%9C%D0%9F.xlsx")
|
||||||
|
|
||||||
|
def test_ref_exact(self):
|
||||||
|
att, links = agent.classify_links(page("amp.html"), BASE)
|
||||||
|
self.assertEqual(att[0]["ref"], OBJ + "/a.png?version=1&x=2") # после unescape, как ищет хаб
|
||||||
|
self.assertEqual(links[0]["ref"], "project/Document/DOC-7?a=1&b=2")
|
||||||
|
self.assertEqual(att[0]["url"], BASE + OBJ + "/a.png")
|
||||||
|
|
||||||
|
def test_base_with_trailing_slash_and_title(self):
|
||||||
|
att, _ = agent.classify_links('<a href="%s/f.bin" title="Файл">текст</a>' % OBJ, BASE + "/")
|
||||||
|
self.assertEqual(att[0]["name"], "Файл")
|
||||||
|
|
||||||
|
def test_name_from_link_text(self):
|
||||||
|
att, _ = agent.classify_links('<a href="%s/f.bin"> мой <b>файл</b> </a>' % OBJ, BASE)
|
||||||
|
self.assertEqual(att[0]["name"], "мой файл")
|
||||||
|
|
||||||
|
def test_order_nested_img_in_link(self):
|
||||||
|
att, _ = agent.classify_links(
|
||||||
|
'<a href="%s/a.bin"><img src="%s/b.png"></a><img src="%s/c.png">' % (OBJ, OBJ, OBJ), BASE)
|
||||||
|
self.assertEqual([a["name"] for a in att], ["a.bin", "b.png", "c.png"])
|
||||||
|
|
||||||
|
def test_unknown_marked_section_does_not_crash(self):
|
||||||
|
"""I-1: на 3.9 `<![foo]>` уходит в ParserBase.error() → NotImplementedError; ссылки до и после сохраняются."""
|
||||||
|
html = ('<img src="%s/a.png"><![foo]><p>текст</p><![if !supportLists]>x<![endif]>'
|
||||||
|
'<a href="/project/Document/DOC-3">c</a><img src="%s/b.png">' % (OBJ, OBJ))
|
||||||
|
att, links = agent.classify_links(html, BASE)
|
||||||
|
self.assertEqual([a["name"] for a in att], ["a.png", "b.png"])
|
||||||
|
self.assertEqual(links, [{"ref": "/project/Document/DOC-3", "code": "DOC-3"}])
|
||||||
|
|
||||||
|
def test_marked_section_bad_name_and_unclosed(self):
|
||||||
|
for html in ('<img src="%s/a.png"><![ ]><img src="%s/b.png">' % (OBJ, OBJ),
|
||||||
|
'<img src="%s/a.png"><img src="%s/b.png"><![foo' % (OBJ, OBJ)):
|
||||||
|
with self.subTest(html=html):
|
||||||
|
att, _ = agent.classify_links(html, BASE)
|
||||||
|
self.assertEqual([a["name"] for a in att], ["a.png", "b.png"])
|
||||||
|
|
||||||
|
def test_bad_url_skipped(self):
|
||||||
|
"""I-1: `http://[::1` и порт не числом дают ValueError в urljoin/urlsplit — ссылка пропускается, остальные целы."""
|
||||||
|
html = ('<img src="http://[::1"><a href="https://[zz/files/x">x</a><img src="https://eva.test:abc/files/y.png">'
|
||||||
|
'<img src="%s/b.png"><a href="/project/Document/DOC-4">d</a>' % OBJ)
|
||||||
|
att, links = agent.classify_links(html, BASE)
|
||||||
|
self.assertEqual([a["name"] for a in att], ["b.png"])
|
||||||
|
self.assertEqual([l["code"] for l in links], ["DOC-4"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
33
tests/test_py39.py
Normal file
33
tests/test_py39.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from _common import SRC
|
||||||
|
|
||||||
|
# Ограничение проверки: ast.parse(feature_version=(3, 9)) ловит синтаксис 3.10+ (match и т. п.) и сторонние
|
||||||
|
# импорты; API и конструкции новее 3.9 (например, `X | Y` — синтаксис 3.10: аннотации функций
|
||||||
|
# вычисляются при импорте и их ловит прогон на /usr/bin/python3 3.9.6, аннотации локальных переменных не вычисляются), не проверяет.
|
||||||
|
|
||||||
|
|
||||||
|
class Py39Test(unittest.TestCase):
|
||||||
|
FILES = ("agent.py", "run.py")
|
||||||
|
|
||||||
|
def test_parses_as_python_39(self):
|
||||||
|
for name in self.FILES:
|
||||||
|
with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f:
|
||||||
|
ast.parse(f.read(), feature_version=(3, 9))
|
||||||
|
|
||||||
|
def test_no_third_party_imports(self):
|
||||||
|
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl", "hashlib", "re", "html", "subprocess"}
|
||||||
|
for name in self.FILES:
|
||||||
|
with self.subTest(name), open(os.path.join(SRC, name), encoding="utf-8") as f:
|
||||||
|
tree = ast.parse(f.read())
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for a in node.names:
|
||||||
|
self.assertIn(a.name.split(".")[0], allowed, "%s: %s" % (name, a.name))
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
self.assertIn((node.module or "").split(".")[0], allowed, "%s: %s" % (name, node.module))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
564
tests/test_runner.py
Normal file
564
tests/test_runner.py
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent, eva_cfg, no_sleep
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
|
||||||
|
|
||||||
|
FETCH = {"url": "https://eva.example.ru/files/a.png", "name": "a.png"}
|
||||||
|
STUB_ERROR = "чужой хост" # FETCH указывает на чужой хост: wiki_fetch отказывает без запросов к Eva
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl"))
|
||||||
|
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), self.journal)
|
||||||
|
self.sleeps = []
|
||||||
|
self.runner.sleep = self.sleeps.append
|
||||||
|
self.runner.eva.sleep = no_sleep
|
||||||
|
self.runner.idle_limit = 1
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.hub.stop()
|
||||||
|
self.dir.cleanup()
|
||||||
|
|
||||||
|
def kinds(self):
|
||||||
|
return [(r["kind"], r["command_id"]) for r in self.journal.records()]
|
||||||
|
|
||||||
|
def claim_in_flight(self, ctype, params, pause=0):
|
||||||
|
cid = self.hub.enqueue(ctype, params, pause=pause)
|
||||||
|
self.hub.queue.remove(cid)
|
||||||
|
self.hub.commands[cid]["status"] = "claimed"
|
||||||
|
self.hub.in_flight = cid
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def test_processes_queue(self):
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
self.assertEqual(self.hub.results[1]["error"], STUB_ERROR)
|
||||||
|
self.assertEqual(self.hub.results[1]["jira_calls"], 0) # поле протокола хаба: число вызовов Eva
|
||||||
|
self.assertEqual(self.hub.results[2]["status"], "failed")
|
||||||
|
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)])
|
||||||
|
self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0
|
||||||
|
|
||||||
|
def test_claim_sends_update_state_and_launcher(self):
|
||||||
|
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
|
||||||
|
agent.write_update_state(self.runner.updater.state_path, "trial", 4, agent.AGENT_VERSION, "s")
|
||||||
|
self.runner.loop()
|
||||||
|
body = self.hub.claim_bodies[0]
|
||||||
|
self.assertEqual(body["launcher_version"], 1)
|
||||||
|
self.assertEqual(body["update_state"]["state"], "trial")
|
||||||
|
self.assertEqual(agent.read_update_state(self.runner.updater.state_path)["state"], "ok") # первый успешный claim
|
||||||
|
self.assertEqual(self.hub.claim_bodies[0]["agent_version"], agent.AGENT_VERSION)
|
||||||
|
|
||||||
|
def test_claim_without_updater_sends_nulls(self):
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertIsNone(self.hub.claim_bodies[0]["update_state"])
|
||||||
|
self.assertIsNone(self.hub.claim_bodies[0]["launcher_version"])
|
||||||
|
|
||||||
|
def test_idle_with_release_exits_for_update(self):
|
||||||
|
rel = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"}
|
||||||
|
self.hub.release = rel
|
||||||
|
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
|
||||||
|
self.runner.updater.check = lambda r: r == {"version": agent.AGENT_VERSION + 1, "sha256": "s"}
|
||||||
|
self.runner.idle_limit = 5
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
|
||||||
|
self.assertEqual(self.hub.claims, 1)
|
||||||
|
|
||||||
|
def test_stop_during_check_exits_0(self):
|
||||||
|
self.hub.release = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"}
|
||||||
|
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
|
||||||
|
def check(r):
|
||||||
|
self.runner.stop = True # Ctrl-C пришёл, когда версия уже staged
|
||||||
|
return True
|
||||||
|
self.runner.updater.check = check
|
||||||
|
self.runner.idle_limit = 5
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.runner.exit_code, 0) # остановка человеком; staged доделает run.py при старте
|
||||||
|
|
||||||
|
def test_command_first_then_update(self):
|
||||||
|
self.hub.release = {"version": agent.AGENT_VERSION + 1, "sha256": "s", "content": "x"}
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.updater = agent.Updater(self.runner.hub, self.dir.name, 1)
|
||||||
|
seen = []
|
||||||
|
self.runner.updater.check = lambda r: (seen.append(r), True)[1]
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed") # команда выполнена и подтверждена
|
||||||
|
self.assertEqual(len(seen), 1) # проверка — только после claim без команды
|
||||||
|
self.assertEqual(self.runner.exit_code, agent.EXIT_UPDATE)
|
||||||
|
|
||||||
|
def test_restart_pace_from_old_finished(self):
|
||||||
|
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
|
||||||
|
self.journal.append({"kind": "finished", "command_id": 99, "status": "done", "result": None, "error": None,
|
||||||
|
"jira_calls": 1, "rate_limited": 0, "at": at})
|
||||||
|
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH, pause=30)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertAlmostEqual(sum(self.sleeps), 20, delta=1.5)
|
||||||
|
|
||||||
|
def test_future_journal_time_means_full_pause(self):
|
||||||
|
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
|
||||||
|
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH, pause=30)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # часы ушли назад — пауза полная (M-6 #12)
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
|
||||||
|
def test_claim_after_restart_sends_no_journal_estimate(self):
|
||||||
|
at = (datetime.now(timezone.utc) - timedelta(seconds=10)).replace(microsecond=0).isoformat()
|
||||||
|
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
eva = self.runner.eva
|
||||||
|
with mock.patch.object(agent.Executor, "do_wiki_fetch",
|
||||||
|
lambda ex, p, cmd: (eva._touch(), ex._out("done", {"file": "abc", "size": 3}))[1]):
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertIsNone(self.hub.claim_bodies[0].get("last_call_at")) # оценка журнала — только для паузы (M-7 #12)
|
||||||
|
self.assertIsNotNone(self.hub.claim_bodies[-1].get("last_call_at")) # после вызова Eva — настоящее время
|
||||||
|
self.assertIsNotNone(self.runner.eva.last_call_at)
|
||||||
|
|
||||||
|
def test_restore_pace_keeps_existing_last_call(self):
|
||||||
|
at = (datetime.now(timezone.utc) - timedelta(seconds=100)).replace(microsecond=0).isoformat()
|
||||||
|
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
|
||||||
|
mark = time.monotonic()
|
||||||
|
self.runner.eva.last_call_at = mark
|
||||||
|
self.runner.restore_pace()
|
||||||
|
self.assertEqual(self.runner.eva.last_call_at, mark)
|
||||||
|
|
||||||
|
def test_pause_waits_from_last_eva_call(self):
|
||||||
|
self.runner.eva.last_call_at = time.monotonic()
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH, pause=3)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(len(self.sleeps), 3)
|
||||||
|
self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1)
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
|
||||||
|
def test_result_retried_when_hub_down(self):
|
||||||
|
self.hub.fail_results = 2
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
self.assertEqual(self.sleeps, [10, 10])
|
||||||
|
self.assertEqual(self.journal.unacked_finished(), [])
|
||||||
|
|
||||||
|
def test_result_retried_on_connection_error(self):
|
||||||
|
calls = []
|
||||||
|
real = self.runner.hub.result
|
||||||
|
|
||||||
|
def flaky(cid, payload):
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) < 3:
|
||||||
|
raise ConnectionError("нет связи")
|
||||||
|
return real(cid, payload)
|
||||||
|
|
||||||
|
self.runner.hub.result = flaky
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.sleeps, [10, 10])
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
|
||||||
|
def test_result_rejected_is_acked(self):
|
||||||
|
for cid, code in ((1, 404), (2, 409), (3, 422)):
|
||||||
|
self.hub.reject_results = {cid: code}
|
||||||
|
self.journal.finished(cid, "done", {}, None, 0, 0)
|
||||||
|
self.assertTrue(self.runner.send_result(self.journal.unacked_finished()[0]))
|
||||||
|
self.assertEqual(self.journal.unacked_finished(), [])
|
||||||
|
|
||||||
|
def test_result_rejected_in_loop(self):
|
||||||
|
self.hub.reject_results = {1: 404}
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertNotIn(1, self.hub.results)
|
||||||
|
self.assertEqual(self.journal.unacked_finished(), [])
|
||||||
|
self.assertEqual(self.kinds()[-1], ("acked", 1))
|
||||||
|
|
||||||
|
def test_recover_unfinished_started(self):
|
||||||
|
cid = self.claim_in_flight("wiki_fetch", FETCH, pause=99)
|
||||||
|
self.journal.started(cid)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[cid]["error"], STUB_ERROR) # команда выполнена заново, как решено в design 9
|
||||||
|
self.assertEqual(self.journal.unfinished_started(), [])
|
||||||
|
self.assertAlmostEqual(sum(self.sleeps), 99, delta=1) # пауза из хаба, от записи started (M-9)
|
||||||
|
|
||||||
|
def test_recover_does_not_journal_claimed_twice(self):
|
||||||
|
cid = self.claim_in_flight("wiki_fetch", FETCH, pause=0)
|
||||||
|
self.journal.claimed(cid)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.kinds(), [("claimed", cid), ("started", cid), ("finished", cid), ("acked", cid)])
|
||||||
|
|
||||||
|
def test_recover_skips_command_not_claimed_anymore(self):
|
||||||
|
cid = self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.hub.queue.remove(cid)
|
||||||
|
self.hub.commands[cid]["status"] = "cancelled"
|
||||||
|
self.journal.started(cid)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertNotIn(cid, self.hub.results)
|
||||||
|
self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)])
|
||||||
|
|
||||||
|
def test_recover_unacked_finished(self):
|
||||||
|
cid = self.claim_in_flight("wiki_fetch", FETCH)
|
||||||
|
self.journal.started(cid)
|
||||||
|
self.journal.finished(cid, "done", {"file": "abc", "size": 3}, None, 2, 0)
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[cid]["result"], {"file": "abc", "size": 3})
|
||||||
|
self.assertEqual(self.hub.results[2]["status"], "failed")
|
||||||
|
self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])
|
||||||
|
|
||||||
|
def test_stop_during_long_claim(self):
|
||||||
|
self.hub.claim_wait = 1.0 # хаб держит claim при пустой очереди (как claim_wait = 30)
|
||||||
|
self.runner.idle_limit = None
|
||||||
|
threading.Timer(0.3, lambda: setattr(self.runner, "stop", True)).start()
|
||||||
|
start = time.monotonic()
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertGreaterEqual(time.monotonic() - start, 0.9) # дождался ответа claim
|
||||||
|
self.assertEqual(self.hub.claims, 1)
|
||||||
|
self.assertEqual(self.journal.records(), [])
|
||||||
|
|
||||||
|
def test_command_arrives_during_long_claim(self):
|
||||||
|
self.hub.claim_wait = 5.0
|
||||||
|
|
||||||
|
def arrive():
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.hub.claim_wait = 0 # следующий claim (после отчёта) — без ожидания
|
||||||
|
threading.Timer(0.3, arrive).start()
|
||||||
|
start = time.monotonic()
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertLess(time.monotonic() - start, 4) # ответ пришёл с командой, а не по таймауту
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
|
||||||
|
def test_stop_during_pause(self):
|
||||||
|
self.runner.eva.last_call_at = time.monotonic()
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH, pause=5)
|
||||||
|
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"]) # started нет
|
||||||
|
self.assertEqual(self.hub.results, {})
|
||||||
|
self.assertEqual(self.hub.commands[1]["status"], "claimed")
|
||||||
|
|
||||||
|
def test_stop_during_pause_then_restart_executes_once(self):
|
||||||
|
self.runner.eva.last_call_at = time.monotonic()
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH, pause=5)
|
||||||
|
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.journal.unfinished_started(), [1])
|
||||||
|
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
second = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), agent.Journal(self.journal.path))
|
||||||
|
second.sleep = self.sleeps.append
|
||||||
|
second.idle_limit = 1
|
||||||
|
second.loop()
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
self.assertEqual(len([r for r in self.journal.records() if r["kind"] == "finished"]), 1)
|
||||||
|
self.assertEqual(self.journal.unfinished_started(), [])
|
||||||
|
self.assertAlmostEqual(sum(self.sleeps), 5, delta=1) # пауза из хаба от записи claimed (M-9)
|
||||||
|
|
||||||
|
def test_stop_between_claim_and_execute_is_recorded(self):
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
real = self.runner.hub.claim
|
||||||
|
|
||||||
|
def claim_then_stop(*a):
|
||||||
|
resp = real(*a)
|
||||||
|
self.runner.stop = True
|
||||||
|
return resp
|
||||||
|
|
||||||
|
self.runner.hub.claim = claim_then_stop
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"])
|
||||||
|
self.assertEqual(self.journal.unfinished_started(), [1])
|
||||||
|
|
||||||
|
def test_stop_during_execution_finishes_and_reports(self):
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.load_settings()
|
||||||
|
run = self.runner.executor.run
|
||||||
|
|
||||||
|
def run_and_signal(cmd):
|
||||||
|
self.runner.stop = True # сигнал пришёл во время выполнения
|
||||||
|
return run(cmd)
|
||||||
|
|
||||||
|
self.runner.executor.run = run_and_signal
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
self.assertNotIn(2, self.hub.results) # вторая команда не начата
|
||||||
|
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1)])
|
||||||
|
|
||||||
|
def test_stop_during_execution_hub_down_single_attempt(self):
|
||||||
|
self.hub.fail_results = 5
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.load_settings()
|
||||||
|
run = self.runner.executor.run
|
||||||
|
self.runner.executor.run = lambda cmd: (setattr(self.runner, "stop", True), run(cmd))[1]
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.sleeps, [])
|
||||||
|
self.assertEqual([r["command_id"] for r in self.journal.unacked_finished()], [1])
|
||||||
|
|
||||||
|
def test_unexpected_exception_becomes_failed(self):
|
||||||
|
self.runner.load_settings()
|
||||||
|
|
||||||
|
def boom(cmd):
|
||||||
|
raise KeyError("summary")
|
||||||
|
|
||||||
|
self.runner.executor.run = boom
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
self.assertIn("KeyError", self.hub.results[1]["error"])
|
||||||
|
self.assertEqual(self.journal.unfinished_started(), [])
|
||||||
|
self.assertEqual(self.journal.unacked_finished(), [])
|
||||||
|
|
||||||
|
def test_recover_waits_when_hub_down(self):
|
||||||
|
cid = self.claim_in_flight("wiki_fetch", FETCH)
|
||||||
|
self.journal.started(cid)
|
||||||
|
real = self.runner.hub.command
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def flaky(i):
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) == 1:
|
||||||
|
raise ConnectionError("нет связи")
|
||||||
|
return real(i)
|
||||||
|
|
||||||
|
self.runner.hub.command = flaky
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.sleeps, [10])
|
||||||
|
self.assertEqual(self.hub.results[cid]["status"], "failed")
|
||||||
|
|
||||||
|
def test_settings_reload_on_version_change(self):
|
||||||
|
self.runner.load_settings()
|
||||||
|
self.runner.settings["settings_version"] = 0
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.runner.settings["settings_version"], 1)
|
||||||
|
|
||||||
|
def test_settings_reloaded_before_command(self):
|
||||||
|
self.runner.load_settings()
|
||||||
|
self.hub.settings["settings_version"] = 2
|
||||||
|
self.hub.settings["wiki_pacing"] = 0.5
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
seen = []
|
||||||
|
real = self.runner.execute
|
||||||
|
self.runner.execute = lambda cmd: (seen.append(self.runner.settings["settings_version"]), real(cmd))
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(seen, [2])
|
||||||
|
self.assertEqual(self.runner.eva.pacing, 0.5)
|
||||||
|
|
||||||
|
def test_hub_down_at_start_keeps_polling(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def hub_settings():
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) < 3:
|
||||||
|
raise ConnectionError("нет связи")
|
||||||
|
return dict(SETTINGS)
|
||||||
|
|
||||||
|
self.runner.hub.settings = hub_settings
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
self.assertEqual(self.sleeps, [1, 1]) # пол паузы на ошибках хаба — 1 с
|
||||||
|
|
||||||
|
def test_retry_waits_from_failure_even_if_last_call_is_old(self):
|
||||||
|
# F-1: Eva недоступна с первого вызова, last_call_at старый (обрыв не обновляет его) —
|
||||||
|
# ожидание перед повтором считается от момента отказа, а не от последнего успешного вызова.
|
||||||
|
self.runner.load_settings()
|
||||||
|
self.runner.eva.last_call_at = time.monotonic() - 600
|
||||||
|
runs = []
|
||||||
|
|
||||||
|
def flaky(cmd):
|
||||||
|
runs.append(1)
|
||||||
|
if len(runs) == 1:
|
||||||
|
return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 0,
|
||||||
|
"rate_limited": 0, "retryable": True}
|
||||||
|
return {"status": "done", "result": {"changed": []}, "jira_calls": 1, "rate_limited": 0}
|
||||||
|
|
||||||
|
self.runner.executor.run = flaky
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(len(runs), 2)
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "done")
|
||||||
|
self.assertAlmostEqual(sum(self.sleeps), 30, delta=0.1) # полные 30 с от момента отказа
|
||||||
|
|
||||||
|
def test_retry_exhausted_gives_failed_unknown(self):
|
||||||
|
self.runner.load_settings()
|
||||||
|
runs = []
|
||||||
|
|
||||||
|
def down(cmd):
|
||||||
|
runs.append(1)
|
||||||
|
return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 1, "rate_limited": 0, "retryable": True}
|
||||||
|
|
||||||
|
self.runner.executor.run = down
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(len(runs), 4) # первый запуск + 3 повтора
|
||||||
|
r = self.hub.results[1]
|
||||||
|
self.assertEqual(r["status"], "failed")
|
||||||
|
self.assertIn("результат неизвестен", r["error"])
|
||||||
|
self.assertIn("(обрыв связи с Eva)", r["error"])
|
||||||
|
self.assertEqual(r["jira_calls"], 4)
|
||||||
|
|
||||||
|
def test_stop_during_retry_wait_leaves_command_for_recovery(self):
|
||||||
|
self.runner.load_settings()
|
||||||
|
self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "x", "jira_calls": 1,
|
||||||
|
"rate_limited": 0, "retryable": True}
|
||||||
|
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.journal.unfinished_started(), [1])
|
||||||
|
self.assertEqual(self.hub.results, {})
|
||||||
|
|
||||||
|
def test_agent_version_in_result(self):
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.hub.results[1]["agent_version"], agent.AGENT_VERSION)
|
||||||
|
|
||||||
|
def test_hub_auth_error_waits_60(self):
|
||||||
|
def denied(*a):
|
||||||
|
raise agent.HttpError(401, "unauthorized")
|
||||||
|
|
||||||
|
self.runner.hub.claim = denied
|
||||||
|
self.runner.sleep = lambda s: (self.sleeps.append(s), setattr(self.runner, "stop", True))
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(self.sleeps, [60])
|
||||||
|
|
||||||
|
def test_wrong_hub_token_401_real_hub(self):
|
||||||
|
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token="bad")
|
||||||
|
r = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), self.journal)
|
||||||
|
r.sleep = lambda s: (self.sleeps.append(s), setattr(r, "stop", True))
|
||||||
|
r.loop()
|
||||||
|
self.assertEqual(self.sleeps, [60])
|
||||||
|
|
||||||
|
def test_settings_reload_failure_retried_before_command(self):
|
||||||
|
self.runner.load_settings()
|
||||||
|
self.hub.settings["settings_version"] = 2
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
real = self.runner.hub.settings
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def flaky():
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) < 3:
|
||||||
|
raise ConnectionError("нет связи")
|
||||||
|
return real()
|
||||||
|
|
||||||
|
self.runner.hub.settings = flaky
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual(len(calls), 3)
|
||||||
|
self.assertEqual(self.sleeps, [1, 1])
|
||||||
|
self.assertEqual(self.runner.settings["settings_version"], 2)
|
||||||
|
self.assertEqual(self.hub.results[1]["status"], "failed")
|
||||||
|
|
||||||
|
def test_claim_timeout_at_least_45(self):
|
||||||
|
seen = []
|
||||||
|
real = agent.Http.call
|
||||||
|
agent.Http.call = lambda self_, m, p, body=None, params=None, timeout=None: (seen.append(timeout), {"command": None, "settings_version": 1})[1]
|
||||||
|
try:
|
||||||
|
agent.HubClient(eva_cfg(hub_url="http://x", hub_token="t", timeout=5)).claim(1, 1, None)
|
||||||
|
finally:
|
||||||
|
agent.Http.call = real
|
||||||
|
self.assertEqual(seen, [45])
|
||||||
|
|
||||||
|
def test_signal_handler(self):
|
||||||
|
import signal
|
||||||
|
from unittest import mock
|
||||||
|
handlers = {}
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_loop(runner):
|
||||||
|
handlers[signal.SIGINT](signal.SIGINT, None)
|
||||||
|
seen["stop_after_first"] = runner.stop
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
handlers[signal.SIGINT](signal.SIGINT, None)
|
||||||
|
seen["code"] = cm.exception.code
|
||||||
|
|
||||||
|
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
with mock.patch.object(agent.signal, "signal", lambda n, h: handlers.__setitem__(n, h)), \
|
||||||
|
mock.patch.object(agent.Runner, "loop", fake_loop):
|
||||||
|
agent.cmd_run(cfg, self.journal.path)
|
||||||
|
self.assertTrue(seen["stop_after_first"])
|
||||||
|
self.assertEqual(seen["code"], 130)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_refused_while_agent_runs(self):
|
||||||
|
old = "2020-01-01T00:00:00+00:00"
|
||||||
|
self.journal.append({"kind": "finished", "command_id": 7, "status": "done", "result": None, "error": None,
|
||||||
|
"jira_calls": 1, "rate_limited": 0, "at": old})
|
||||||
|
self.journal.append({"kind": "acked", "command_id": 7, "at": old})
|
||||||
|
held = agent.acquire_lock(self.journal.path)
|
||||||
|
try:
|
||||||
|
self.assertEqual(agent.cmd_prune(self.journal.path), 1)
|
||||||
|
self.assertEqual(len(self.journal.records()), 2) # журнал не тронут (T2.3 agent#4)
|
||||||
|
finally:
|
||||||
|
held.close()
|
||||||
|
self.assertEqual(agent.cmd_prune(self.journal.path), 0)
|
||||||
|
self.assertEqual(self.journal.records(), [])
|
||||||
|
|
||||||
|
def test_second_process_refused(self):
|
||||||
|
"""I-1 финального ревью: второй run с тем же журналом не стартует — иначе довыдача хаба отдаст ему ту же команду."""
|
||||||
|
from unittest import mock
|
||||||
|
held = agent.acquire_lock(self.journal.path)
|
||||||
|
self.assertIsNotNone(held)
|
||||||
|
try:
|
||||||
|
self.assertIsNone(agent.acquire_lock(self.journal.path))
|
||||||
|
cfg = eva_cfg(hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
with mock.patch.object(agent.Runner, "loop", lambda r: self.fail("цикл не должен стартовать")):
|
||||||
|
self.assertEqual(agent.cmd_run(cfg, self.journal.path), agent.EXIT_LOCKED)
|
||||||
|
self.assertEqual(agent.EXIT_LOCKED, 73)
|
||||||
|
finally:
|
||||||
|
held.close()
|
||||||
|
again = agent.acquire_lock(self.journal.path)
|
||||||
|
self.assertIsNotNone(again) # после выхода первого — снова можно
|
||||||
|
again.close()
|
||||||
|
|
||||||
|
def test_future_journal_time_single_log_line(self):
|
||||||
|
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
|
||||||
|
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
|
||||||
|
logs = []
|
||||||
|
orig = agent.log
|
||||||
|
agent.log = logs.append
|
||||||
|
try:
|
||||||
|
self.runner.restore_pace()
|
||||||
|
finally:
|
||||||
|
agent.log = orig
|
||||||
|
self.assertEqual(len(logs), 1, logs) # M-2 #18: без «не позже <будущее>»
|
||||||
|
self.assertIn("в будущем", logs[0])
|
||||||
|
self.assertIsNotNone(self.runner.eva.last_call_at)
|
||||||
|
|
||||||
|
def test_future_journal_time_then_stop_in_pause(self):
|
||||||
|
at = (datetime.now(timezone.utc) + timedelta(hours=2)).replace(microsecond=0).isoformat()
|
||||||
|
self.journal.append({"kind": "acked", "command_id": 99, "at": at})
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH, pause=30)
|
||||||
|
self.runner.sleep = lambda s: setattr(self.runner, "stop", True) # Ctrl-C в первую секунду паузы
|
||||||
|
self.runner.loop()
|
||||||
|
self.assertEqual([r["kind"] for r in self.journal.records() if r["command_id"] == 1], ["claimed"])
|
||||||
|
self.assertEqual(self.runner.eva.calls, 0) # к Eva ни одного вызова (M-3 #18)
|
||||||
|
self.assertEqual(self.hub.commands[1]["status"], "claimed")
|
||||||
|
|
||||||
|
def test_retry_exhausted_on_hub_names_hub(self):
|
||||||
|
self.runner.load_settings()
|
||||||
|
logs = []
|
||||||
|
orig = agent.log
|
||||||
|
agent.log = logs.append
|
||||||
|
self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "хаб: не ответил",
|
||||||
|
"jira_calls": 0, "rate_limited": 0, "retryable": True, "peer": "хабом"}
|
||||||
|
self.hub.enqueue("wiki_fetch", FETCH)
|
||||||
|
try:
|
||||||
|
self.runner.loop()
|
||||||
|
finally:
|
||||||
|
agent.log = orig
|
||||||
|
r = self.hub.results[1]
|
||||||
|
self.assertEqual(r["error"], "результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил")
|
||||||
|
self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs)
|
||||||
|
self.assertNotIn("peer", r)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
81
tests/test_self_update_e2e.py
Normal file
81
tests/test_self_update_e2e.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from _common import SETTINGS, SRC
|
||||||
|
from fake_eva import FakeEva
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(pred, timeout=30):
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < end:
|
||||||
|
if pred():
|
||||||
|
return True
|
||||||
|
time.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SelfUpdateE2E(unittest.TestCase):
|
||||||
|
"""run.py + настоящий agent.py: v<N> видит публикацию v<N+1>, ставит её, v<N+1> подтверждает; Ctrl-C группе — выход 0."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
self.eva = FakeEva() # selftest = check: нужна доступная Eva
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
d = self.tmp.name
|
||||||
|
shutil.copy(os.path.join(SRC, "run.py"), d)
|
||||||
|
shutil.copy(os.path.join(SRC, "agent.py"), d)
|
||||||
|
with open(os.path.join(d, "agent.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5, "poll_seconds": 0.2,
|
||||||
|
"eva": {"base_url": self.eva.base_url, "token": "eva-secret"}}, f)
|
||||||
|
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||||||
|
src = f.read()
|
||||||
|
self.cur = int(re.search(r"^AGENT_VERSION = (\d+)", src, re.M).group(1))
|
||||||
|
cur = "AGENT_VERSION = %d\n" % self.cur
|
||||||
|
content = src.replace(cur, "AGENT_VERSION = %d\n" % (self.cur + 1), 1)
|
||||||
|
self.assertNotEqual(content, src)
|
||||||
|
self.hub.release = {"version": self.cur + 1, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
"content": content}
|
||||||
|
self.content = content
|
||||||
|
self.log = open(os.path.join(d, "out.log"), "wb")
|
||||||
|
self.proc = subprocess.Popen([sys.executable, os.path.join(d, "run.py")], stdout=self.log,
|
||||||
|
stderr=subprocess.STDOUT, start_new_session=True)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self.proc.poll() is None:
|
||||||
|
os.killpg(self.proc.pid, signal.SIGKILL)
|
||||||
|
self.proc.wait()
|
||||||
|
self.log.close()
|
||||||
|
self.hub.stop()
|
||||||
|
self.eva.stop()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def state(self):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(self.tmp.name, "update.json"), encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def test_update_to_next_and_stop_by_ctrl_c(self):
|
||||||
|
ok = wait_for(lambda: (self.state() or {}).get("state") == "ok", 60)
|
||||||
|
with open(os.path.join(self.tmp.name, "out.log"), encoding="utf-8", errors="replace") as f:
|
||||||
|
out = f.read()
|
||||||
|
self.assertTrue(ok, out)
|
||||||
|
with open(os.path.join(self.tmp.name, "agent.py"), encoding="utf-8") as f:
|
||||||
|
self.assertEqual(f.read(), self.content)
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.tmp.name, "agent.py.prev")))
|
||||||
|
self.assertTrue(wait_for(lambda: any(b.get("agent_version") == self.cur + 1 for b in self.hub.claim_bodies)))
|
||||||
|
last = [b for b in self.hub.claim_bodies if b.get("agent_version") == self.cur + 1][-1]
|
||||||
|
self.assertEqual(last["launcher_version"], 2)
|
||||||
|
os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе
|
||||||
|
self.assertEqual(self.proc.wait(30), 0)
|
||||||
303
tests/test_update.py
Normal file
303
tests/test_update.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
|
||||||
|
CUR = agent.AGENT_VERSION
|
||||||
|
NEXT = CUR + 1
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateStateTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.path = os.path.join(self.dir.name, "update.json")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.dir.cleanup()
|
||||||
|
|
||||||
|
def test_write_and_read(self):
|
||||||
|
d = agent.write_update_state(self.path, "staged", 5, 6, "abc")
|
||||||
|
self.assertEqual(agent.read_update_state(self.path), d)
|
||||||
|
self.assertEqual((d["state"], d["from_version"], d["to_version"], d["sha256"], d["error"]), ("staged", 5, 6, "abc", None))
|
||||||
|
self.assertTrue(d["at"])
|
||||||
|
self.assertEqual(os.listdir(self.dir.name), ["update.json"]) # временный файл не остался
|
||||||
|
|
||||||
|
def test_read_missing(self):
|
||||||
|
self.assertIsNone(agent.read_update_state(self.path))
|
||||||
|
|
||||||
|
def test_read_update_state_garbage(self):
|
||||||
|
for text in ('{"state": "tri', "[1, 2]", ""):
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
self.assertIsNone(agent.read_update_state(self.path))
|
||||||
|
|
||||||
|
def test_confirm_trial_of_this_version(self):
|
||||||
|
u = agent.Updater(None, self.dir.name, 1)
|
||||||
|
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s")
|
||||||
|
u.confirm()
|
||||||
|
st = agent.read_update_state(u.state_path)
|
||||||
|
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("ok", 4, agent.AGENT_VERSION, "s"))
|
||||||
|
|
||||||
|
def test_confirm_write_error_only_logged(self):
|
||||||
|
"""M-3 финального ревью: диск полон в момент подтверждения — агент не падает, подтвердит на следующем claim."""
|
||||||
|
u = agent.Updater(None, self.dir.name, 1)
|
||||||
|
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION, "s")
|
||||||
|
logs = []
|
||||||
|
|
||||||
|
def full(*a, **kw):
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
orig_log, orig_write = agent.log, agent.write_update_state
|
||||||
|
agent.log, agent.write_update_state = logs.append, full
|
||||||
|
try:
|
||||||
|
u.confirm()
|
||||||
|
finally:
|
||||||
|
agent.log, agent.write_update_state = orig_log, orig_write
|
||||||
|
self.assertEqual(u.state()["state"], "trial")
|
||||||
|
self.assertTrue(any(l.startswith("не удалось записать update.json") for l in logs), logs)
|
||||||
|
u.confirm()
|
||||||
|
self.assertEqual(u.state()["state"], "ok")
|
||||||
|
|
||||||
|
def test_confirm_ignores_other_states(self):
|
||||||
|
u = agent.Updater(None, self.dir.name, 1)
|
||||||
|
u.confirm()
|
||||||
|
self.assertIsNone(u.state()) # нет файла — не создаётся
|
||||||
|
agent.write_update_state(u.state_path, "trial", 4, agent.AGENT_VERSION + 1, "s")
|
||||||
|
u.confirm()
|
||||||
|
self.assertEqual(u.state()["state"], "trial") # пробная другая версия — не наша
|
||||||
|
agent.write_update_state(u.state_path, "rolled_back", 5, 6, "s", "код 1 через 3 с")
|
||||||
|
u.confirm()
|
||||||
|
self.assertEqual(u.state()["state"], "rolled_back")
|
||||||
|
|
||||||
|
|
||||||
|
def make_release(version, body="print('ok')\n"):
|
||||||
|
content = "AGENT_VERSION = %d\n%s" % (version, body)
|
||||||
|
return {"version": version, "sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "content": content}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdaterCheckTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
cfg = {"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5}
|
||||||
|
self.u = agent.Updater(agent.HubClient(cfg), self.dir.name, 1)
|
||||||
|
self.selftests = []
|
||||||
|
self.code = 0
|
||||||
|
self.u.run_selftest = lambda path: (self.selftests.append(path), (self.code, "вывод selftest"))[1]
|
||||||
|
self.now = [1000.0]
|
||||||
|
self.u.clock = lambda: self.now[0]
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.hub.stop()
|
||||||
|
self.dir.cleanup()
|
||||||
|
|
||||||
|
def ref(self, rel):
|
||||||
|
return {"version": rel["version"], "sha256": rel["sha256"]}
|
||||||
|
|
||||||
|
def capture_logs(self):
|
||||||
|
logs = []
|
||||||
|
orig = agent.log
|
||||||
|
agent.log = logs.append
|
||||||
|
self.addCleanup(setattr, agent, "log", orig)
|
||||||
|
return logs
|
||||||
|
|
||||||
|
def test_incomplete_release_ignored_with_hourly_hint(self):
|
||||||
|
logs = self.capture_logs()
|
||||||
|
for ref in ({"version": NEXT, "sha256": None}, {"version": str(NEXT), "sha256": "a" * 64},
|
||||||
|
{"version": True, "sha256": "a" * 64}, {"version": NEXT, "sha256": "A" * 64},
|
||||||
|
{"version": NEXT, "sha256": "a" * 63}, {"version": NEXT, "sha256": "a" * 64 + "\n"}):
|
||||||
|
self.assertFalse(self.u.check(ref))
|
||||||
|
self.now[0] += 3600
|
||||||
|
self.assertFalse(self.u.check({"version": NEXT, "sha256": None}))
|
||||||
|
self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 2)
|
||||||
|
self.assertEqual(self.hub.release_requests, 0)
|
||||||
|
self.assertIsNone(self.u.state())
|
||||||
|
|
||||||
|
def test_incomplete_release_hint_is_bounded(self):
|
||||||
|
"""M-4: подсказка не печатает произвольно длинный мусор от хаба целиком."""
|
||||||
|
logs = self.capture_logs()
|
||||||
|
self.assertFalse(self.u.check({"version": NEXT, "sha256": "x" * 10000}))
|
||||||
|
hints = [l for l in logs if l.startswith("хаб прислал неполную публикацию")]
|
||||||
|
self.assertEqual(len(hints), 1)
|
||||||
|
self.assertLess(len(hints[0]), 300)
|
||||||
|
|
||||||
|
def test_hints_have_separate_timers(self):
|
||||||
|
logs = self.capture_logs()
|
||||||
|
self.u.launcher_version = None
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
self.assertFalse(self.u.check({"version": NEXT, "sha256": None}))
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.assertEqual(sum(1 for l in logs if l.startswith("хаб прислал неполную публикацию")), 1)
|
||||||
|
self.assertEqual(logs.count("доступна v%d; для самообновления запускайте через run.py" % NEXT), 1)
|
||||||
|
|
||||||
|
def test_write_error_is_transient(self):
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
ref = self.ref(self.hub.release)
|
||||||
|
self.u.new_path = os.path.join(self.dir.name, "нет-каталога", "agent.py.new") # open() → FileNotFoundError
|
||||||
|
self.assertFalse(self.u.check(ref))
|
||||||
|
self.assertIsNone(self.u.state())
|
||||||
|
self.assertEqual(self.selftests, [])
|
||||||
|
self.now[0] += 299
|
||||||
|
self.assertFalse(self.u.check(ref))
|
||||||
|
self.assertEqual(self.hub.release_requests, 1) # раньше 5 минут не повторяет
|
||||||
|
|
||||||
|
def test_selftest_os_error_is_transient(self):
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
def boom(path):
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
self.u.run_selftest = boom
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.assertIsNone(self.u.state())
|
||||||
|
self.assertFalse(os.path.exists(self.u.new_path))
|
||||||
|
|
||||||
|
def test_staged_write_error_is_transient(self):
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json")
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release))) # без staged выходить с 75 нельзя
|
||||||
|
self.assertFalse(os.path.exists(self.u.new_path))
|
||||||
|
|
||||||
|
def test_reject_write_error_only_logged(self):
|
||||||
|
rel = make_release(NEXT)
|
||||||
|
self.hub.release = dict(rel, content=rel["content"] + "# подмена\n")
|
||||||
|
self.u.state_path = os.path.join(self.dir.name, "нет-каталога", "update.json")
|
||||||
|
logs = self.capture_logs()
|
||||||
|
self.assertFalse(self.u.check(self.ref(rel))) # не бросает
|
||||||
|
self.assertTrue(any(l.startswith("не удалось записать update.json") for l in logs), logs)
|
||||||
|
|
||||||
|
def test_good_version_is_staged(self):
|
||||||
|
rel = make_release(NEXT)
|
||||||
|
self.hub.release = rel
|
||||||
|
self.assertTrue(self.u.check(self.ref(rel)))
|
||||||
|
with open(self.u.new_path, encoding="utf-8") as f:
|
||||||
|
self.assertEqual(f.read(), rel["content"])
|
||||||
|
st = self.u.state()
|
||||||
|
self.assertEqual((st["state"], st["from_version"], st["to_version"], st["sha256"]), ("staged", CUR, NEXT, rel["sha256"]))
|
||||||
|
self.assertEqual(self.selftests, [self.u.new_path])
|
||||||
|
|
||||||
|
def test_not_newer_or_disabled_or_no_release(self):
|
||||||
|
self.hub.release = make_release(CUR)
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.assertFalse(self.u.check(None))
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
self.u.enabled = False
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.assertEqual(self.hub.release_requests, 0)
|
||||||
|
|
||||||
|
def test_sha_mismatch_rejected_once(self):
|
||||||
|
rel = make_release(NEXT)
|
||||||
|
self.hub.release = dict(rel, content=rel["content"] + "# подмена\n")
|
||||||
|
ref = self.ref(rel)
|
||||||
|
self.hub.release["sha256"] = rel["sha256"]
|
||||||
|
self.assertFalse(self.u.check(ref))
|
||||||
|
st = self.u.state()
|
||||||
|
self.assertEqual((st["state"], st["to_version"], st["error"]), ("rejected", NEXT, "sha256 не сходится"))
|
||||||
|
self.assertFalse(os.path.exists(self.u.new_path))
|
||||||
|
self.assertFalse(self.u.check(ref))
|
||||||
|
self.assertEqual(self.hub.release_requests, 1) # отвергнутую версию больше не качает
|
||||||
|
|
||||||
|
def test_syntax_error_and_wrong_version_rejected(self):
|
||||||
|
bad = make_release(NEXT, body="def (:\n")
|
||||||
|
self.hub.release = bad
|
||||||
|
self.assertFalse(self.u.check(self.ref(bad)))
|
||||||
|
self.assertIn("не компилируется", self.u.state()["error"])
|
||||||
|
wrong = make_release(NEXT + 1)
|
||||||
|
wrong = dict(wrong, version=NEXT + 2)
|
||||||
|
self.hub.release = wrong
|
||||||
|
self.assertFalse(self.u.check(self.ref(wrong)))
|
||||||
|
self.assertIn("AGENT_VERSION", self.u.state()["error"])
|
||||||
|
self.assertEqual(self.selftests, [])
|
||||||
|
|
||||||
|
def test_selftest_bad_code_rejected(self):
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
self.code = 1
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
st = self.u.state()
|
||||||
|
self.assertEqual(st["state"], "rejected")
|
||||||
|
self.assertTrue(st["error"].startswith("selftest: код 1"))
|
||||||
|
self.assertIn("вывод selftest", st["error"])
|
||||||
|
self.assertFalse(os.path.exists(self.u.new_path))
|
||||||
|
|
||||||
|
def test_selftest_unavailable_is_transient(self):
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
self.code = 3
|
||||||
|
ref = self.ref(self.hub.release)
|
||||||
|
self.assertFalse(self.u.check(ref))
|
||||||
|
self.assertIsNone(self.u.state()) # update.json не тронут
|
||||||
|
self.assertFalse(os.path.exists(self.u.new_path))
|
||||||
|
self.now[0] += 299
|
||||||
|
self.assertFalse(self.u.check(ref))
|
||||||
|
self.assertEqual(len(self.selftests), 1) # раньше 5 минут не повторяет
|
||||||
|
self.now[0] += 2
|
||||||
|
self.code = 0
|
||||||
|
self.assertTrue(self.u.check(ref))
|
||||||
|
|
||||||
|
def test_selftest_timeout_is_transient(self):
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
self.code = None
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.assertIsNone(self.u.state())
|
||||||
|
|
||||||
|
def test_download_failure_is_transient(self):
|
||||||
|
rel = make_release(NEXT) # в claim есть, а /agent/release — 404
|
||||||
|
self.assertFalse(self.u.check(self.ref(rel)))
|
||||||
|
self.assertIsNone(self.u.state())
|
||||||
|
|
||||||
|
def test_release_changed_between_requests_is_transient(self):
|
||||||
|
old, new = make_release(NEXT), make_release(NEXT + 1)
|
||||||
|
self.hub.release = new
|
||||||
|
self.assertFalse(self.u.check(self.ref(old)))
|
||||||
|
self.assertIsNone(self.u.state())
|
||||||
|
self.assertEqual(self.selftests, [])
|
||||||
|
|
||||||
|
def test_rolled_back_same_sha_not_retried(self):
|
||||||
|
rel = make_release(NEXT)
|
||||||
|
self.hub.release = rel
|
||||||
|
agent.write_update_state(self.u.state_path, "rolled_back", CUR, NEXT, rel["sha256"], "код 1 через 3 с")
|
||||||
|
self.assertFalse(self.u.check(self.ref(rel)))
|
||||||
|
fixed = make_release(NEXT, body="print('fixed')\n")
|
||||||
|
self.hub.release = fixed
|
||||||
|
self.assertTrue(self.u.check(self.ref(fixed))) # тот же номер, другой sha — пробуем
|
||||||
|
|
||||||
|
def test_without_launcher_only_hint(self):
|
||||||
|
self.u.launcher_version = None
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
logs = []
|
||||||
|
orig = agent.log
|
||||||
|
agent.log = logs.append
|
||||||
|
try:
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.now[0] += 3600
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
finally:
|
||||||
|
agent.log = orig
|
||||||
|
self.assertEqual(logs.count("доступна v%d; для самообновления запускайте через run.py" % NEXT), 2)
|
||||||
|
self.assertEqual(self.hub.release_requests, 0)
|
||||||
|
|
||||||
|
def test_stop_during_selftest_is_not_reject(self):
|
||||||
|
self.hub.release = make_release(NEXT)
|
||||||
|
stop = [False]
|
||||||
|
self.u.should_stop = lambda: stop[0]
|
||||||
|
|
||||||
|
def selftest(path):
|
||||||
|
stop[0] = True # Ctrl-C пришёл и selftest упал с 1
|
||||||
|
return 1, "KeyboardInterrupt"
|
||||||
|
self.u.run_selftest = selftest
|
||||||
|
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||||
|
self.assertIsNone(self.u.state())
|
||||||
|
self.assertFalse(os.path.exists(self.u.new_path))
|
||||||
|
|
||||||
|
def test_real_selftest_runs_new_file(self):
|
||||||
|
u = agent.Updater(None, self.dir.name, 1, config_path=os.path.join(self.dir.name, "agent.json"))
|
||||||
|
path = os.path.join(self.dir.name, "agent.py.new")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("import sys\nprint('args', sys.argv[1:])\nsys.exit(3)\n")
|
||||||
|
code, out = u._run_selftest(path)
|
||||||
|
self.assertEqual(code, 3)
|
||||||
|
self.assertIn("'--config', '%s', 'selftest'" % os.path.join(self.dir.name, "agent.json"), out)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
139
tests/test_wiki_fetch.py
Normal file
139
tests/test_wiki_fetch.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent, eva_cfg, no_sleep
|
||||||
|
from fake_eva import FakeEva
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
|
||||||
|
MB = 1024 * 1024
|
||||||
|
TOO_BIG = "файл больше 10 МБ — откройте в Eva"
|
||||||
|
|
||||||
|
|
||||||
|
class WikiFetchTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.eva = FakeEva()
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
self.addCleanup(self.eva.stop)
|
||||||
|
self.addCleanup(self.hub.stop)
|
||||||
|
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
client = agent.EvaClient(cfg)
|
||||||
|
client.sleep = no_sleep
|
||||||
|
self.ex = agent.Executor(client, agent.HubClient(cfg))
|
||||||
|
|
||||||
|
def fetch(self, url):
|
||||||
|
return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}})
|
||||||
|
|
||||||
|
def file_url(self, path="/files/a.bin"):
|
||||||
|
return self.eva.base_url + path
|
||||||
|
|
||||||
|
def test_fetch_ok(self):
|
||||||
|
data = os.urandom(300 * 1024)
|
||||||
|
self.eva.add_file("/files/a.bin", data)
|
||||||
|
out = self.fetch(self.file_url())
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["size"], 307200)
|
||||||
|
self.assertEqual(out["result"]["file"], hashlib.sha256(data).hexdigest())
|
||||||
|
self.assertEqual(self.hub.files[out["result"]["file"]], data)
|
||||||
|
self.assertEqual(self.eva.requests[0]["auth"], "Bearer " + self.eva.token)
|
||||||
|
|
||||||
|
def test_fetch_foreign_host(self):
|
||||||
|
out = self.fetch("http://127.0.0.1:1/files/a.bin")
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("чужой хост", out["error"])
|
||||||
|
self.assertEqual(self.eva.requests, [])
|
||||||
|
self.assertEqual(self.hub.uploaded, [])
|
||||||
|
|
||||||
|
def test_fetch_foreign_lookalikes(self):
|
||||||
|
"""T5.2: похожий хост, userinfo, другой порт и схема — «чужой хост», запроса к Eva нет."""
|
||||||
|
port = self.eva.base_url.rsplit(":", 1)[1] # http://127.0.0.1:<port>
|
||||||
|
for url in ("http://127.0.0.1.evil:%s/files/a.bin" % port,
|
||||||
|
"http://127.0.0.1:%s@evil.test/files/a.bin" % port,
|
||||||
|
"http://127.0.0.1:%d/files/a.bin" % (int(port) + 1),
|
||||||
|
"https://127.0.0.1:%s/files/a.bin" % port):
|
||||||
|
with self.subTest(url=url):
|
||||||
|
out = self.fetch(url)
|
||||||
|
self.assertEqual((out["status"], out["error"]), ("failed", "чужой хост"))
|
||||||
|
self.assertEqual(self.eva.requests, [])
|
||||||
|
self.assertEqual(self.hub.uploaded, [])
|
||||||
|
|
||||||
|
def test_fetch_malformed_url(self):
|
||||||
|
out = self.fetch("http://[::1/files/a.bin")
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("некорректный адрес", out["error"])
|
||||||
|
self.assertEqual(self.eva.requests, [])
|
||||||
|
|
||||||
|
def test_fetch_non_ascii_and_spaces(self):
|
||||||
|
"""I-2: адрес из HTML с пробелами и кириллицей кодируется перед запросом; %XX не кодируется повторно."""
|
||||||
|
enc = "/files/obj/x/%D0%9F%D0%9C%D0%9F%20%D1%84%D0%B0%D0%B9%D0%BB.png"
|
||||||
|
self.eva.add_file(enc, b"png")
|
||||||
|
for path in ("/files/obj/x/ПМП файл.png", enc, "/files/obj/x/ПМП%20файл.png"):
|
||||||
|
with self.subTest(path=path), redirect_stdout(io.StringIO()):
|
||||||
|
out = self.fetch(self.file_url(path))
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["size"], 3)
|
||||||
|
self.assertEqual([r["path"] for r in self.eva.requests], [enc] * 3)
|
||||||
|
|
||||||
|
def test_fetch_stray_percent(self):
|
||||||
|
self.eva.add_file("/files/100%25.txt", b"ok")
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
out = self.fetch(self.file_url("/files/100%.txt"))
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
|
||||||
|
def test_value_error_inside_is_not_foreign_host(self):
|
||||||
|
"""I-2: ValueError внутри скачивания (например, UnicodeEncodeError) не выдаётся за «чужой хост»."""
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise UnicodeEncodeError("ascii", "я", 0, 1, "ordinal not in range")
|
||||||
|
self.ex.eva.fetch_bytes = boom
|
||||||
|
with self.assertRaises(UnicodeEncodeError):
|
||||||
|
self.fetch(self.file_url())
|
||||||
|
|
||||||
|
def test_fetch_too_big_header(self):
|
||||||
|
self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт
|
||||||
|
out = self.fetch(self.file_url())
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertEqual(out["error"], TOO_BIG)
|
||||||
|
self.assertEqual(self.hub.uploaded, [])
|
||||||
|
|
||||||
|
def test_fetch_too_big_streaming(self):
|
||||||
|
self.eva.add_file("/files/a.bin", b"x" * (10 * MB + 1), length=False)
|
||||||
|
out = self.fetch(self.file_url())
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertEqual(out["error"], TOO_BIG)
|
||||||
|
self.assertEqual(self.hub.uploaded, [])
|
||||||
|
|
||||||
|
def test_fetch_exactly_limit(self):
|
||||||
|
self.eva.add_file("/files/a.bin", b"x" * (10 * MB))
|
||||||
|
out = self.fetch(self.file_url())
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["size"], 10 * MB)
|
||||||
|
|
||||||
|
def test_fetch_404(self):
|
||||||
|
out = self.fetch(self.file_url("/files/none.bin"))
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertEqual(out["error"], "файл не найден в Eva")
|
||||||
|
|
||||||
|
def test_fetch_empty_file(self):
|
||||||
|
self.eva.add_file("/files/a.bin", b"")
|
||||||
|
out = self.fetch(self.file_url())
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["size"], 0)
|
||||||
|
self.assertEqual(self.hub.files[out["result"]["file"]], b"")
|
||||||
|
|
||||||
|
def test_fetch_hub_error_is_hub_error(self):
|
||||||
|
self.eva.add_file("/files/a.bin", b"abc")
|
||||||
|
self.hub.reject_files = (500, {"error": "boom"})
|
||||||
|
out = self.fetch(self.file_url())
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertTrue(out["error"].startswith("хаб:"), out)
|
||||||
|
self.assertTrue(out["retryable"])
|
||||||
|
|
||||||
|
def test_fetch_no_url(self):
|
||||||
|
out = self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7}})
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
266
tests/test_wiki_sync.py
Normal file
266
tests/test_wiki_sync.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent, eva_cfg, no_sleep
|
||||||
|
from fake_eva import FakeEva
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
|
||||||
|
|
||||||
|
class SyncBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.eva = FakeEva()
|
||||||
|
self.hub = FakeHub(SETTINGS)
|
||||||
|
self.addCleanup(self.eva.stop)
|
||||||
|
self.addCleanup(self.hub.stop)
|
||||||
|
self.cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||||
|
self.client = agent.EvaClient(self.cfg)
|
||||||
|
self.client.sleep = no_sleep
|
||||||
|
self.ex = agent.Executor(self.client, agent.HubClient(self.cfg))
|
||||||
|
|
||||||
|
def sync(self, root="DOC-1", label="tz"):
|
||||||
|
cid = self.hub.enqueue("wiki_sync", {"label": label, "root": root})
|
||||||
|
self.hub.queue.remove(cid)
|
||||||
|
self.hub.commands[cid]["status"] = "claimed"
|
||||||
|
self.cid = cid
|
||||||
|
return self.ex.run({"id": cid, "type": "wiki_sync", "params": {"label": label, "root": root}})
|
||||||
|
|
||||||
|
def sent(self):
|
||||||
|
return [p for body in self.hub.pages for p in body["pages"]]
|
||||||
|
|
||||||
|
|
||||||
|
class WikiSyncTest(SyncBase):
|
||||||
|
def test_sync_tree_order(self):
|
||||||
|
e = self.eva
|
||||||
|
e.add_doc(1, "DOC-1", "Корень", project="P", text="<p>корень</p>")
|
||||||
|
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P", orderno=2, text="<p>б</p>")
|
||||||
|
e.add_doc(3, "DOC-3", "А", parent="DOC-1", project="P", orderno=1, text="<p>а</p>")
|
||||||
|
e.add_doc(4, "DOC-4", "Внук", parent="DOC-3", project="P", text="<p>внук</p>")
|
||||||
|
e.add_doc(5, "DOC-5", "Чужой", parent="DOC-1", project="OTHER")
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"])
|
||||||
|
self.assertEqual(out["result"]["root_title"], "Корень")
|
||||||
|
self.assertEqual(out["result"]["eva_calls"], self.client.calls)
|
||||||
|
pages = {p["code"]: p for p in self.sent()}
|
||||||
|
self.assertEqual([p["code"] for p in self.sent()], out["result"]["codes"])
|
||||||
|
self.assertIsNone(pages["DOC-1"]["parent_code"])
|
||||||
|
self.assertEqual(pages["DOC-3"]["parent_code"], "DOC-1")
|
||||||
|
self.assertEqual(pages["DOC-4"]["parent_code"], "DOC-3")
|
||||||
|
self.assertEqual(pages["DOC-3"]["orderno"], 1)
|
||||||
|
self.assertIsNone(pages["DOC-4"]["orderno"])
|
||||||
|
self.assertEqual(pages["DOC-3"]["url"], self.eva.base_url + "/project/Document/DOC-3")
|
||||||
|
self.assertEqual(pages["DOC-3"]["eva_id"], "3")
|
||||||
|
self.assertEqual(pages["DOC-3"]["title"], "А")
|
||||||
|
self.assertEqual(pages["DOC-3"]["html"], "<p>а</p>")
|
||||||
|
self.assertEqual(self.hub.pages[0]["command_id"], self.cid)
|
||||||
|
|
||||||
|
def test_sync_passes_links_and_attachments(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "К", project="P", text=(
|
||||||
|
'<p><a href="/project/Document/DOC-7">x</a><img src="/files/a/b.png" alt="Схема"></p>'))
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
page = self.sent()[0]
|
||||||
|
self.assertEqual(page["links"], [{"ref": "/project/Document/DOC-7", "code": "DOC-7"}])
|
||||||
|
self.assertEqual(page["attachments"], [{"ref": "/files/a/b.png", "url": self.eva.base_url + "/files/a/b.png",
|
||||||
|
"name": "Схема"}])
|
||||||
|
|
||||||
|
def test_sync_batches_50(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
for i in range(2, 121):
|
||||||
|
self.eva.add_doc(i, "DOC-%d" % i, "С%03d" % i, parent="DOC-1", project="P", orderno=i)
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual([len(b["pages"]) for b in self.hub.pages], [50, 50, 20])
|
||||||
|
self.assertEqual(len(out["result"]["codes"]), 120)
|
||||||
|
self.assertEqual(out["result"]["codes"][0], "DOC-1")
|
||||||
|
|
||||||
|
def test_sync_batch_bytes(self):
|
||||||
|
big = "<p>" + "x" * (1500 * 1024) + "</p>"
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P", text=big)
|
||||||
|
for i in (2, 3):
|
||||||
|
self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i, text=big)
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
sizes = [len(json.dumps(b, ensure_ascii=False).encode("utf-8")) for b in self.hub.pages]
|
||||||
|
self.assertTrue(all(s <= 4 * 1024 * 1024 for s in sizes), sizes)
|
||||||
|
self.assertEqual([len(b["pages"]) for b in self.hub.pages], [2, 1])
|
||||||
|
|
||||||
|
def test_tree_cycle_and_two_parents(self):
|
||||||
|
e = self.eva
|
||||||
|
e.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
e.add_doc(3, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1)
|
||||||
|
e.add_doc(10, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2)
|
||||||
|
e.add_doc(11, "DOC-B", "Б", tree_parent="DOC-A", project="P")
|
||||||
|
e.add_doc(12, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3)
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
codes = out["result"]["codes"]
|
||||||
|
self.assertEqual(len(codes), len(set(codes)))
|
||||||
|
self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-A", "DOC-B", "DOC-C"])
|
||||||
|
self.assertEqual([p["code"] for p in self.sent()], codes)
|
||||||
|
|
||||||
|
def fill_forms_tree(self):
|
||||||
|
e = self.eva
|
||||||
|
e.add_doc(101, "DOC-1", "Корень", project="P")
|
||||||
|
e.add_doc(103, "DOC-3", "Три", parent="DOC-1", project="P", orderno=1) # только parent
|
||||||
|
e.add_doc(104, "DOC-4", "Внук", tree_parent="DOC-3", project="P") # только tree_parent
|
||||||
|
e.add_doc(110, "DOC-A", "А", parent="DOC-B", tree_parent="DOC-1", project="P", orderno=2)
|
||||||
|
e.add_doc(111, "DOC-B", "Б", tree_parent="DOC-A", project="P") # цикл A <-> B
|
||||||
|
e.add_doc(112, "DOC-C", "Ц", parent="DOC-1", tree_parent="DOC-3", project="P", orderno=3) # два родителя
|
||||||
|
|
||||||
|
def test_all_ref_forms(self):
|
||||||
|
"""Ссылки parent/tree_parent бывают {id, code}, {id} и голой строкой-id; id документа не равен коду."""
|
||||||
|
for form in ("full", "id_only", "string", "code_id"):
|
||||||
|
with self.subTest(form=form):
|
||||||
|
self.setUp()
|
||||||
|
self.eva.ref_form = form
|
||||||
|
self.fill_forms_tree()
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
codes = out["result"]["codes"]
|
||||||
|
self.assertEqual(codes[:4], ["DOC-1", "DOC-3", "DOC-C", "DOC-4"])
|
||||||
|
self.assertEqual(len(codes), len(set(codes)))
|
||||||
|
self.assertEqual(sorted(codes), ["DOC-1", "DOC-3", "DOC-4", "DOC-A", "DOC-B", "DOC-C"])
|
||||||
|
parents = {p["code"]: p["parent_code"] for p in self.sent()}
|
||||||
|
self.assertEqual(parents["DOC-4"], "DOC-3")
|
||||||
|
self.assertEqual(parents["DOC-A"], "DOC-1")
|
||||||
|
self.assertEqual(parents["DOC-B"], "DOC-A")
|
||||||
|
self.assertEqual(parents["DOC-C"], "DOC-3") # первый родитель по обходу в глубину
|
||||||
|
self.assertEqual(self.sent()[0]["eva_id"], "101")
|
||||||
|
self.eva.stop()
|
||||||
|
self.hub.stop()
|
||||||
|
|
||||||
|
def test_root_without_project(self):
|
||||||
|
e = self.eva
|
||||||
|
e.add_doc(1, "DOC-1", "Корень", text="<p>к</p>")
|
||||||
|
e.add_doc(2, "DOC-2", "Б", tree_parent="DOC-1", orderno=2)
|
||||||
|
e.add_doc(3, "DOC-3", "А", tree_parent="DOC-1", orderno=1)
|
||||||
|
e.add_doc(4, "DOC-4", "Внук", tree_parent="DOC-3")
|
||||||
|
e.add_doc(5, "DOC-5", "Посторонний", tree_parent="DOC-9")
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["result"]["codes"], ["DOC-1", "DOC-3", "DOC-4", "DOC-2"])
|
||||||
|
self.assertEqual({p["code"]: p["parent_code"] for p in self.sent()},
|
||||||
|
{"DOC-1": None, "DOC-3": "DOC-1", "DOC-4": "DOC-3", "DOC-2": "DOC-1"})
|
||||||
|
|
||||||
|
def test_root_missing(self):
|
||||||
|
out = self.sync("DOC-9")
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertEqual(out["error"], "страница DOC-9 не найдена или нет прав")
|
||||||
|
self.assertEqual(self.hub.pages, [])
|
||||||
|
|
||||||
|
def test_eva_failure_reported(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
self.eva.fail_next(503, 4)
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("503", out["error"])
|
||||||
|
self.assertNotIn(FakeEva.token, out["error"])
|
||||||
|
self.assertEqual(self.hub.pages, [])
|
||||||
|
|
||||||
|
def test_hub_rejects_batch(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
self.hub.reject_pages = (422, {"error": "bad_pages", "detail": "DOC-1: html"})
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("422", out["error"])
|
||||||
|
self.assertIn("DOC-1: html", out["error"])
|
||||||
|
|
||||||
|
def test_eva_rpc_error_reported(self):
|
||||||
|
"""M-1: `error` JSON-RPC Eva — failed «Eva: <текст>», без повтора и без «внутренней ошибки агента»."""
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
self.eva.rpc_error_next("нет прав, токен eva-secret")
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertEqual(out["error"], "Eva: CmfDocument.get: нет прав, токен ***")
|
||||||
|
self.assertFalse(out.get("retryable"))
|
||||||
|
self.assertEqual(self.hub.pages, [])
|
||||||
|
|
||||||
|
def test_eva_rpc_error_in_runner(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
self.eva.rpc_error_next("нет прав")
|
||||||
|
runner = agent.Runner(self.cfg, self.ex.hub, self.client, agent.Journal(os.devnull))
|
||||||
|
runner.executor = self.ex
|
||||||
|
self.assertEqual(runner.run_once({"id": 1, "type": "wiki_sync", "params": {"root": "DOC-1"}})["error"],
|
||||||
|
"Eva: CmfDocument.get: нет прав")
|
||||||
|
|
||||||
|
def test_hub_413_single_page(self):
|
||||||
|
"""M-3: хаб не принял тело (413) — в ошибке названа страница."""
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("страница DOC-1 больше 16 МБ", out["error"])
|
||||||
|
self.assertFalse(out.get("retryable"))
|
||||||
|
|
||||||
|
def test_hub_413_batch(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
for i in (2, 3):
|
||||||
|
self.eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i)
|
||||||
|
self.hub.reject_pages = (413, {"error": "too_large", "detail": "тело больше 16777216 байт"})
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("пачка страниц DOC-1…DOC-3 (3 шт.) больше 16 МБ", out["error"])
|
||||||
|
|
||||||
|
def test_empty_name_falls_back_to_code(self):
|
||||||
|
self.eva.add_doc(1, "DOC-1", "", project="P") # хаб отклоняет пустой title (stage._check)
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(self.sent()[0]["title"], "DOC-1")
|
||||||
|
self.assertEqual(out["result"]["root_title"], "DOC-1")
|
||||||
|
|
||||||
|
def test_page_vanished(self):
|
||||||
|
e = self.eva
|
||||||
|
e.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P")
|
||||||
|
real = self.client.get_doc
|
||||||
|
self.client.get_doc = lambda code, fields: None if code == "DOC-2" else real(code, fields)
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("DOC-2", out["error"])
|
||||||
|
|
||||||
|
def test_counts(self):
|
||||||
|
e = self.eva
|
||||||
|
e.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
e.add_doc(2, "DOC-2", "Б", parent="DOC-1", project="P")
|
||||||
|
e.fail_next(429, 2, retry_after=0)
|
||||||
|
out = self.sync()
|
||||||
|
self.assertEqual(out["status"], "done", out)
|
||||||
|
self.assertEqual(out["jira_calls"], len(e.requests))
|
||||||
|
self.assertEqual(out["jira_calls"], self.client.calls)
|
||||||
|
self.assertEqual(out["rate_limited"], 2)
|
||||||
|
|
||||||
|
|
||||||
|
class WikiSyncRecoverTest(unittest.TestCase):
|
||||||
|
def test_recover_resyncs(self):
|
||||||
|
eva, hub = FakeEva(), FakeHub(SETTINGS)
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
for x in (eva.stop, hub.stop, tmp.cleanup):
|
||||||
|
self.addCleanup(x)
|
||||||
|
eva.add_doc(1, "DOC-1", "Корень", project="P")
|
||||||
|
for i in range(2, 61):
|
||||||
|
eva.add_doc(i, "DOC-%d" % i, "С", parent="DOC-1", project="P", orderno=i)
|
||||||
|
cid = hub.enqueue("wiki_sync", {"label": "tz", "root": "DOC-1"})
|
||||||
|
hub.queue.remove(cid)
|
||||||
|
hub.commands[cid]["status"] = "claimed"
|
||||||
|
hub.in_flight = cid
|
||||||
|
journal = agent.Journal(os.path.join(tmp.name, "journal.jsonl"))
|
||||||
|
journal.claimed(cid)
|
||||||
|
journal.started(cid) # перезапуск посреди выполнения: finished нет
|
||||||
|
cfg = eva_cfg(eva.base_url, hub_url=hub.base_url, hub_token=hub.token)
|
||||||
|
runner = agent.Runner(cfg, agent.HubClient(cfg), agent.EvaClient(cfg), journal)
|
||||||
|
runner.sleep = lambda s: None
|
||||||
|
runner.eva.sleep = no_sleep
|
||||||
|
runner.idle_limit = 1
|
||||||
|
runner.loop()
|
||||||
|
self.assertEqual(hub.results[cid]["status"], "done", hub.results[cid])
|
||||||
|
self.assertEqual(len(hub.results[cid]["result"]["codes"]), 60)
|
||||||
|
self.assertEqual([len(b["pages"]) for b in hub.pages], [50, 10])
|
||||||
|
self.assertEqual(len([r for r in journal.records() if r["kind"] == "finished"]), 1)
|
||||||
|
self.assertEqual(journal.unfinished_started(), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user