Каркас eva-agent: общие части jira-agent v14 (#1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 04:40:33 +04:00
parent 4749c862f7
commit 67f1a5a096
20 changed files with 3924 additions and 19 deletions

8
.gitignore vendored
View File

@ -1,3 +1,11 @@
.superpowers/
.bounded
.DS_Store
agent.json
*.jsonl
update.json
*.lock
__pycache__/
*.pyc
agent.py.new
agent.py.prev

View File

@ -1,15 +1,40 @@
# Архитектура
{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед 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` | клиент Eva (Bearer-токен): счётчики вызовов и 429, время последнего вызова. Каркас; JSON-RPC и повторы — в задачах 2–3 | `Http` |
| Папка | `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`, `do_wiki_fetch` (в каркасе — `failed` «не реализовано»); сбой хаба внутри команды — `HubError` | `EvaClient`, `HubClient` |
| Самообновление | `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`, `check`, `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.
- Тесты не ходят во внешние сервисы.
## Открытые вопросы
-
- Проверка Eva в `check` и клиент JSON-RPC — задачи 2 и 6 плана.

View File

@ -1,28 +1,54 @@
# 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/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно.
- {{Ключевые интерфейсы/порты между ними, если есть.}}
- `src/agent.py` — весь агент, один файл. Общие части скопированы из jira-agent v14 без изменения смысла (решение 1):
`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}}` — весь набор тестов. Зелёным считается только весь набор.
- `{{npm run build / ...}}` — сборка.
- `{{npm run dev / ...}}` — локальный запуск.
- `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор.
- `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые
ресурсы (так проверяем перед коммитом).
- `/usr/bin/python3 src/agent.py check` — доступность хаба (и Eva — после задачи 6 плана), без записи.
- `/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.*`. Внешние сервисы в тестах мокаются.
- {{Где лежит тестовое окружение (vault, БД, песочница).}}
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: хаб и Eva заменяют фейки `tests/fake_hub.py`,
`tests/fake_relay.py` (реле общей папки) и `tests/fake_eva.py` (появится вместе с клиентом Eva).
- `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`).
- Golden-фикстур пока нет: каждый тест строит сценарий в коде. Тестовый HTML — синтетический.
## Первая подсистема
{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}}
MVP агента: каркас с общими частями (задача 1 плана `docs/superpowers/plans/`), затем клиент Eva, обход дерева,
`wiki_sync`, `wiki_fetch`, установка и живая проверка. Версия агента — 1. Выкатка: `uv run --project ../jira-hub
jira-hub agent publish src/agent.py` (из основного checkout), как у jira-agent; токен — «агент wiki».
## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.

10
README.md Normal file
View File

@ -0,0 +1,10 @@
# eva-agent
Агент модуля wiki хаба (sdlc-hub) для виртуалки банка с доступом к Eva. Опрашивает хаб, выгружает поддерево страниц
Eva (`wiki_sync`) и скачивает вложения (`wiki_fetch`); в Eva только читает. Python 3.9, только стандартная библиотека,
один файл `src/agent.py` и запускатель `src/run.py`.
Установка и эксплуатация — будут дописаны в задаче 6 плана (`docs/superpowers/plans/`).
Настройки — `src/agent.example.json` (скопировать в `agent.json` рядом с `agent.py`, токены в git не коммитить).
Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`.

View File

@ -115,3 +115,27 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге
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`** (рабочие файлы самообновления
и кэш). Цена ошибки: нет.

13
src/agent.example.json Normal file
View File

@ -0,0 +1,13 @@
{
"hub_url": "http://192.168.0.106:8790",
"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
}

1084
src/agent.py Normal file

File diff suppressed because it is too large Load Diff

319
src/run.py Normal file
View 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
View 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
View 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_hub.py Normal file
View File

@ -0,0 +1,158 @@
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import base64
import hashlib
import json
import re
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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
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)
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
View 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()

254
tests/test_clients.py Normal file
View 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(self.eva.call("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"{}")
self.eva.call("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):
self.eva.call("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:
self.eva.call("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:
self.eva.call("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(self.eva.call("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):
self.eva.call("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):
self.eva.call("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):
client.call("GET", "/x")
finally:
t.join(2)
srv.close()
def test_unreachable_raises_connection_error(self):
with self.assertRaises(ConnectionError):
agent.EvaClient(eva_cfg("http://127.0.0.1:1")).call("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:
self.eva.call("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"]), 1) # хаб недоступен (порт 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()

240
tests/test_folder.py Normal file
View File

@ -0,0 +1,240 @@
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_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 test_wiki_pages_through_folder(self):
self.run_relay()
pages = [{"code": "DOC-1", "name": "Корень", "html": "<p>" + "я" * 200000 + "</p>"}]
got = self.client().wiki_pages(5, pages)
self.assertEqual(got, {"accepted": 1})
self.assertEqual(self.hub.pages, [{"command_id": 5, "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})
self.assertEqual(hub.wiki_pages(5, [{"code": "DOC-1"}]), {"accepted": 1})
self.assertEqual(self.hub.pages[0]["command_id"], 5)
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.relay = FakeRelay(self.root, self.hub.base_url).start()
def tearDown(self):
self.relay.stop()
self.hub.stop()
self.tmp.cleanup()
def cfg(self):
return eva_cfg(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"], "wiki_fetch: не реализовано")
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, 1)
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
View 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
View 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()

33
tests/test_py39.py Normal file
View 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", "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()

563
tests/test_runner.py Normal file
View File

@ -0,0 +1,563 @@
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 = "wiki_fetch: не реализовано" # Executor каркаса: обе команды — failed
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])
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()

View File

@ -0,0 +1,78 @@
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_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.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": "http://127.0.0.1:1", "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.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
View 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()