From 67f1a5a0961e9c072e21850c08dae1ee508d0630 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 04:40:33 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20eva-agen?= =?UTF-8?q?t:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20jira-agent=20v14=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitignore | 8 + ARCHITECTURE.md | 39 +- CLAUDE.md | 50 +- README.md | 10 + openspec/changes/eva-agent-mvp/design.md | 24 + src/agent.example.json | 13 + src/agent.py | 1084 ++++++++++++++++++++++ src/run.py | 319 +++++++ tests/__init__.py | 7 + tests/_common.py | 29 + tests/fake_hub.py | 158 ++++ tests/fake_relay.py | 74 ++ tests/test_clients.py | 254 +++++ tests/test_folder.py | 240 +++++ tests/test_journal.py | 154 +++ tests/test_launcher.py | 503 ++++++++++ tests/test_py39.py | 33 + tests/test_runner.py | 563 +++++++++++ tests/test_self_update_e2e.py | 78 ++ tests/test_update.py | 303 ++++++ 20 files changed, 3924 insertions(+), 19 deletions(-) create mode 100644 README.md create mode 100644 src/agent.example.json create mode 100644 src/agent.py create mode 100644 src/run.py create mode 100644 tests/__init__.py create mode 100644 tests/_common.py create mode 100644 tests/fake_hub.py create mode 100644 tests/fake_relay.py create mode 100644 tests/test_clients.py create mode 100644 tests/test_folder.py create mode 100644 tests/test_journal.py create mode 100644 tests/test_launcher.py create mode 100644 tests/test_py39.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_self_update_e2e.py create mode 100644 tests/test_update.py diff --git a/.gitignore b/.gitignore index 4b7e182..134e3e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ .superpowers/ .bounded .DS_Store +agent.json +*.jsonl +update.json +*.lock +__pycache__/ +*.pyc +agent.py.new +agent.py.prev diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a85988..da5b7c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/.json` → реле хаба → `resp/.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 плана. diff --git a/CLAUDE.md b/CLAUDE.md index 5c75d81..8fdc5b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.*` + эталон `.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 сами по себе не запускать. diff --git a/README.md b/README.md new file mode 100644 index 0000000..39a9105 --- /dev/null +++ b/README.md @@ -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 .`. diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 56a7081..6400602 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -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`** (рабочие файлы самообновления + и кэш). Цена ошибки: нет. diff --git a/src/agent.example.json b/src/agent.example.json new file mode 100644 index 0000000..1bd733b --- /dev/null +++ b/src/agent.example.json @@ -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 +} diff --git a/src/agent.py b/src/agent.py new file mode 100644 index 0000000..909ffc9 --- /dev/null +++ b/src/agent.py @@ -0,0 +1,1084 @@ +#!/usr/bin/env python3 +"""eva-agent — клиент модуля wiki хаба (sdlc-hub) для виртуалки с доступом к Eva банка. + +Опрашивает хаб, выполняет команды wiki_sync и wiki_fetch (только чтение Eva), ведёт журнал на диске. Только +стандартная библиотека, Python 3.9. Общие части (HTTP, папка, журнал, цикл, самообновление, запускатель) — копия +jira-agent v14. Спека: jira-hub/openspec/changes/wiki-module/. +Связь с хабом — HTTP (`hub_url = http://…`) или общая папка (`hub_url = folder:<путь>`), если сеть закрыта VPN. + + python3 agent.py check # доступ к хабу (проверка Eva добавится позже), без записи + python3 agent.py run # основной цикл (по умолчанию) + python3 agent.py selftest # самопроверка новой версии перед обновлением: версия + check; код 0 или 3 + python3 agent.py prune # почистить journal.jsonl от подтверждённых записей старше 30 дней + +Самообновление — через запускатель run.py (спека agent, «Самообновление агента»). +""" +import argparse +import base64 +import fcntl +import hashlib +import http.client +import json +import os +import re +import signal +import ssl +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Tuple + +AGENT_VERSION = 1 +EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) +EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def log(msg: str) -> None: + print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True) + + +def load_config(path: str) -> dict: + try: + with open(path, encoding="utf-8") as f: + cfg = json.load(f) + except FileNotFoundError: + sys.exit("нет файла настроек %s — скопируйте agent.example.json в agent.json и заполните" % path) + for key in ("hub_url", "hub_token", "eva"): + if not cfg.get(key): + sys.exit("в %s нет обязательного поля %s" % (path, key)) + if not isinstance(cfg["eva"], dict): + sys.exit("в %s поле eva должно быть объектом {base_url, token}" % path) + for key in ("base_url", "token"): + if not cfg["eva"].get(key): + sys.exit("в %s нет eva.%s" % (path, key)) + if cfg["hub_url"].startswith("folder:") and not os.path.isabs(cfg["hub_url"][len("folder:"):]): + sys.exit("hub_url: после folder: нужен абсолютный путь общей папки, например " + "folder:/Volumes/My Shared Files/RSHB/jira-bridge") + cfg.setdefault("timeout", 60) + cfg.setdefault("poll_seconds", 5) + cfg.setdefault("verify_ssl", True) + return cfg + + +# --------------------------------------------------------------------------- +# Самообновление: update.json рядом с agent.py (спека agent «Самообновление агента», design 7) +# --------------------------------------------------------------------------- +def read_update_state(path: str) -> Optional[dict]: + """Состояние обновления; нет файла или мусор — None.""" + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return None + except (OSError, ValueError): + log("update.json не читается — считаю, что состояния нет") + return None + return data if isinstance(data, dict) else None + + +def write_update_state(path: str, state: str, from_version, to_version, sha256, error=None) -> dict: + data = {"state": state, "from_version": from_version, "to_version": to_version, "sha256": sha256, + "error": error, "at": now_iso()} + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, path) + return data + + +# --------------------------------------------------------------------------- +# HTTP +# --------------------------------------------------------------------------- +class HttpError(Exception): + def __init__(self, code: int, text: str, headers=None): + super().__init__("HTTP %d: %s" % (code, text[:300])) + self.code, self.text, self.headers = code, text, headers or {} + + +class RedirectAway(Exception): + """Сервер перенаправил запрос на другой адрес (схема, хост или порт): учётные данные туда не отправляются.""" + def __init__(self, host: str): + super().__init__("перенаправление на %s" % host) + self.host = host + + +class TooBig(Exception): + """Ответ длиннее предела: чтение оборвано.""" + + +def _origin(url: str) -> Tuple[str, str, Optional[int]]: + u = urllib.parse.urlsplit(url) + scheme = (u.scheme or "").lower() + return scheme, (u.hostname or "").lower(), u.port or {"http": 80, "https": 443}.get(scheme) + + +class _SameOriginRedirect(urllib.request.HTTPRedirectHandler): + """Перенаправления — только в пределах адреса клиента: urllib переносит Authorization и на чужой хост.""" + def __init__(self, base: str): + self.origin = _origin(base) + + def redirect_request(self, req, fp, code, msg, headers, newurl): + if _origin(newurl) != self.origin: + fp.close() + raise RedirectAway(urllib.parse.urlsplit(newurl).hostname or newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class Http: + def __init__(self, base: str, headers: Dict[str, str], timeout: float = 60, ctx=None): + self.base, self.headers, self.timeout, self.ctx = base.rstrip("/"), headers, timeout, ctx + + def __repr__(self): + return "<%s %s>" % (self.__class__.__name__, self.base) + + def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + return self.send(method, path, data, "application/json; charset=utf-8" if data is not None else None, + params, timeout) + + def send(self, method: str, path: str, data: Optional[bytes], content_type: Optional[str], params=None, + timeout=None) -> Any: + """Запрос с готовым телом (JSON или multipart); ответ — JSON. Ошибки — HttpError и ConnectionError.""" + url = self.base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + req = urllib.request.Request(url, data=data, method=method) + for k, v in self.headers.items(): + req.add_header(k, v) + req.add_header("Accept", "application/json") + if content_type is not None: + req.add_header("Content-Type", content_type) + try: + with self._opener().open(req, timeout=timeout or self.timeout) as resp: + raw = resp.read() + status = resp.status + except RedirectAway as e: + self._touch() + raise HttpError(302, "перенаправление на %s отклонено: учётные данные туда не отправляются" % e.host) from None + except urllib.error.HTTPError as e: + self._touch() + try: + headers = {k.lower(): v for k, v in e.headers.items()} + text = e.read().decode("utf-8", "replace") + except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err: + raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None + finally: + e.close() + raise HttpError(e.code, text, headers) from None + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e: + raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + self._touch() + try: + return json.loads(raw.decode("utf-8")) if raw else {} + except ValueError as e: # включая UnicodeDecodeError и JSONDecodeError + raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, raw[:200].decode("utf-8", "replace"))) from None + + def _opener(self): + """Открыватель запросов: перенаправления — только на адрес клиента (urllib переносит Authorization и на + чужой хост); контекст TLS — из настроек.""" + handlers = [_SameOriginRedirect(self.base)] # type: List[Any] + if self.ctx is not None: + handlers.append(urllib.request.HTTPSHandler(context=self.ctx)) + return urllib.request.build_opener(*handlers) + + def fetch_bytes(self, path: str, limit: int, timeout=None) -> bytes: + """GET с ответом-байтами (содержимое вложения): без разбора JSON, читает не больше limit + 1 байт — длиннее + limit — TooBig. Перенаправление на другой адрес — RedirectAway без перехода. Ошибки — как у send.""" + req = urllib.request.Request(self.base + path, method="GET") + for k, v in self.headers.items(): + req.add_header(k, v) + req.add_header("Accept", "*/*") + try: + with self._opener().open(req, timeout=timeout or self.timeout) as resp: + data = resp.read(limit + 1) + rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше + if rest and len(data) <= limit: + raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest)) + except RedirectAway: + self._touch() + raise + except urllib.error.HTTPError as e: + self._touch() + try: + headers = {k.lower(): v for k, v in e.headers.items()} + text = e.read().decode("utf-8", "replace") + except (OSError, ConnectionError, TimeoutError, http.client.HTTPException) as err: + raise ConnectionError("%s: обрыв при чтении ответа: %s" % (self.base, err)) from None + finally: + e.close() + raise HttpError(e.code, text, headers) from None + except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, http.client.HTTPException) as e: + raise ConnectionError("%s недоступен: %s" % (self.base, e)) from None + self._touch() + if len(data) > limit: + raise TooBig("ответ длиннее %d байт" % limit) + return data + + def _touch(self) -> None: + """Вызывается на каждый полученный HTTP-ответ (любой код).""" + + +class EvaClient(Http): + """Клиент Eva: счётчики вызовов и 429, время последнего вызова (для паузы между вызовами). Методы JSON-RPC + добавляются в задачах 2–3; здесь — только каркас, общий для цикла и Executor.""" + + def __init__(self, cfg: dict): + e = cfg["eva"] + ctx = None + if not cfg.get("verify_ssl", True): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif cfg.get("ca_bundle"): + ctx = ssl.create_default_context(cafile=cfg["ca_bundle"]) + super().__init__(e["base_url"], {"Authorization": "Bearer " + e["token"]}, cfg.get("timeout", 60), ctx) + self.calls = 0 + self.rate_limited = 0 + self.last_call_at = None # type: Optional[float] (time.monotonic) + self.last_call_iso = None # type: Optional[str] + self.sleep = time.sleep + + def _touch(self) -> None: + self.calls += 1 + self.last_call_at, self.last_call_iso = time.monotonic(), now_iso() + + def reset_counters(self) -> None: + self.calls, self.rate_limited = 0, 0 + + +class FolderTransport: + """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). + Запрос — файл req/.json, ответ кладёт реле хаба в resp/.json. Ошибки те же, что у Http.call: + нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport.""" + POLL = 0.1 + BAD_JSON_WAIT = 2.0 + + def __init__(self, root: str, authorization: str): + self.root, self.authorization = root, authorization + + def __repr__(self): + return "" % self.root + + def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any: + if not os.path.isdir(self.root): + raise ConnectionError("нет папки %s" % self.root) + req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp") + rid = uuid.uuid4().hex + try: + os.makedirs(req_dir, exist_ok=True) + os.makedirs(resp_dir, exist_ok=True) + deadline = time.time() + timeout + self._write(req_dir, rid + ".json", { + "id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization, + "deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()}) + except OSError as e: + raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None + resp_path = os.path.join(resp_dir, rid + ".json") + end = time.monotonic() + timeout + while time.monotonic() < end: + if os.path.exists(resp_path): + return self._take(resp_path) + time.sleep(self.POLL) + try: + os.remove(os.path.join(req_dir, rid + ".json")) + except OSError: + pass + raise ConnectionError("не ответил через папку %s за %.0f с" % (self.root, timeout)) + + @staticmethod + def _write(directory: str, name: str, payload: dict) -> None: + tmp = os.path.join(directory, "." + name + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, os.path.join(directory, name)) + + def _take(self, path: str) -> Any: + end = time.monotonic() + self.BAD_JSON_WAIT + while True: + try: + with open(path, encoding="utf-8") as f: + resp = json.load(f) + if not isinstance(resp, dict): + raise ValueError("ответ не объект") + status, body = resp.get("status"), resp.get("body") + if type(status) is not int or not (body is None or isinstance(body, str)): + raise ValueError("неверные типы status/body") # bool — подкласс int, поэтому type(), не isinstance (M-2 agent#8) + break + except (ValueError, OSError): + if time.monotonic() >= end: + raise ConnectionError("нечитаемый ответ хаба в %s" % path) from None + time.sleep(self.POLL) + try: + os.remove(path) + except OSError: + pass + text = body or "" # status и body проверены выше (M-5 #18) + if not 200 <= status < 300: + raise HttpError(status, text) + try: + return json.loads(text) if text else {} + except ValueError as e: + raise HttpError(status, "ответ не JSON: %s; начало: %s" % (e, text[:200])) from None + + +class HubClient(Http): + def __init__(self, cfg: dict): + auth = "Bearer " + cfg["hub_token"] + super().__init__(cfg["hub_url"], {"Authorization": auth}, cfg.get("timeout", 60)) + url = cfg["hub_url"] + self.folder = FolderTransport(url[len("folder:"):], auth) if url.startswith("folder:") else None + + def call(self, method: str, path: str, body=None, params=None, timeout=None) -> Any: + if self.folder is None: + return super().call(method, path, body, params, timeout) + if params: + path += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + return self.folder.call(method, path, body, timeout or self.timeout) + + def claim(self, agent_version, settings_version, last_call_at, update_state=None, launcher_version=None) -> dict: + return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, + "last_call_at": last_call_at, "update_state": update_state, + "launcher_version": launcher_version}, timeout=max(self.timeout, 45)) + + def release(self) -> dict: + return self.call("GET", "/agent/release") + + def settings(self) -> dict: + return self.call("GET", "/agent/settings") + + def command(self, command_id) -> dict: + return self.call("GET", "/agent/commands/%d" % int(command_id)) + + def result(self, command_id, payload: dict) -> dict: + return self.call("POST", "/agent/commands/%d/result" % int(command_id), payload) + + def upload_file(self, data: bytes) -> dict: + """Файл, скачанный из Eva, — в хранилище хаба: {file (sha256), size}.""" + return self.call("POST", "/agent/files", {"content": base64.b64encode(data).decode("ascii")}) + + def wiki_pages(self, command_id, pages: list) -> dict: + """Пачка страниц wiki_sync в стадию хаба (до 50 страниц, тело до 16 МБ).""" + return self.call("POST", "/agent/wiki/pages", {"command_id": command_id, "pages": pages}) + + +# --------------------------------------------------------------------------- +# Журнал: только дозапись; started → finished → acked по id команды +# --------------------------------------------------------------------------- +class Journal: + def __init__(self, path: str): + self.path = path + + def _torn_tail(self) -> bool: + """Файл не пуст и не заканчивается переводом строки (оборванная запись).""" + try: + if os.path.getsize(self.path) == 0: + return False + except FileNotFoundError: + return False + with open(self.path, "rb") as f: + f.seek(-1, os.SEEK_END) + return f.read(1) != b"\n" + + def append(self, record: dict) -> None: + record = dict(record) + record.setdefault("at", now_iso()) + data = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8") + if self._torn_tail(): + data = b"\n" + data + fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + with os.fdopen(fd, "ab") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + + def _scan(self): + """Возвращает (годные записи, номера битых строк). Годная — dict с command_id.""" + good, bad = [], [] + if not os.path.exists(self.path): + return good, bad + with open(self.path, "rb") as f: + for n, raw in enumerate(f, 1): + line = raw.strip() + if not line: + continue + try: + r = json.loads(line.decode("utf-8")) + except ValueError: + r = None + if isinstance(r, dict) and "command_id" in r: + good.append(r) + else: + bad.append(n) + return good, bad + + def records(self) -> List[dict]: + good, bad = self._scan() + for n in bad: + log("журнал: строка %d не читается, пропускаю" % n) + return good + + def last_at(self) -> Optional[datetime]: + """Самое позднее время записи журнала (UTC). Записи без времени или с непонятным временем пропускаются.""" + latest = None + for r in self.records(): + try: + t = datetime.fromisoformat(r["at"]) + except (KeyError, TypeError, ValueError): + continue + if t.tzinfo is None: + continue + if latest is None or t > latest: + latest = t + return latest + + def claimed(self, command_id) -> None: + """Команда получена из claim (до паузы): с этого момента хаб считает её выданной агенту.""" + self.append({"kind": "claimed", "command_id": int(command_id)}) + + def started(self, command_id) -> None: + self.append({"kind": "started", "command_id": int(command_id)}) + + def finished(self, command_id, status, result, error, jira_calls, rate_limited) -> None: + self.append({"kind": "finished", "command_id": int(command_id), "status": status, "result": result, + "error": error, "jira_calls": int(jira_calls or 0), "rate_limited": int(rate_limited or 0)}) + + def acked(self, command_id) -> None: + self.append({"kind": "acked", "command_id": int(command_id)}) + + def unacked_finished(self) -> List[dict]: + last, acked = {}, set() + for r in self.records(): + if r.get("kind") == "finished": + last[r["command_id"]] = r + acked.discard(r["command_id"]) + elif r.get("kind") == "acked": + acked.add(r["command_id"]) + return [r for cid, r in last.items() if cid not in acked] + + def unfinished_started(self) -> List[int]: + """Порядковый учёт: claimed/started открывают id, finished/acked закрывает; порядок — первое открытие.""" + opened = {} # command_id -> открыта ли сейчас; dict сохраняет порядок первого открытия + for r in self.records(): + kind, cid = r.get("kind"), r["command_id"] + if kind in ("claimed", "started"): + opened[cid] = True + elif kind in ("finished", "acked") and cid in opened: + opened[cid] = False + return [cid for cid, is_open in opened.items() if is_open] + + def prune(self, days: int = 30) -> int: + """Удалить записи id до его последнего acked, если тот старше days дней (возраст — по acked) + и после него у id нет started/finished. Возвращает число удалённых годных записей; битые строки + отбрасываются при перезаписи и учитываются в логе.""" + good, bad = self._scan() + if bad: + log("журнал: удаляю битых строк %d" % len(bad)) + cutoff = datetime.now(timezone.utc).timestamp() - days * 86400 + last_ack, last_open = {}, {} + for i, r in enumerate(good): + cid = r["command_id"] + if r.get("kind") == "acked": + last_ack[cid] = i + elif r.get("kind") in ("claimed", "started", "finished"): + last_open[cid] = i + drop_upto = {} # command_id -> индекс последнего acked: всё до него включительно удаляется + for cid, i in last_ack.items(): + if last_open.get(cid, -1) > i: + continue + try: + ts = _parse_at(good[i].get("at")) + except (ValueError, TypeError): + continue + if ts < cutoff: + drop_upto[cid] = i + keep = [r for i, r in enumerate(good) + if not (r["command_id"] in drop_upto and i <= drop_upto[r["command_id"]])] + tmp = self.path + ".tmp" + try: + os.remove(tmp) + except FileNotFoundError: + pass + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as f: + for r in keep: + f.write((json.dumps(r, ensure_ascii=False) + "\n").encode("utf-8")) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, self.path) + return len(good) - len(keep) + + +def _parse_at(value) -> float: + """Метка времени журнала в эпоху; наивное время трактуется как UTC.""" + dt = datetime.fromisoformat(value) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + + +# --------------------------------------------------------------------------- +# Исполнитель: команда wiki_sync / wiki_fetch (читает Eva, отдаёт хабу) +# --------------------------------------------------------------------------- +class HubError(Exception): + """Ошибка хаба внутри команды (стадия страниц, загрузка файла): источник — хаб, не Eva (из jira-agent, M-6 #18). + Повтор — только при недоступности хаба и 5xx; команды wiki Eva только читают, повтор безопасен.""" + + def __init__(self, cause: Exception): + super().__init__("хаб: %s" % cause) + self.retryable = isinstance(cause, ConnectionError) or (isinstance(cause, HttpError) and cause.code >= 500) + + +class Executor: + def __init__(self, eva: EvaClient, hub: Optional[HubClient], settings: dict): + self.eva, self.hub, self.s = eva, hub, settings + self.sleep = time.sleep + + def run(self, cmd: dict) -> dict: + self.eva.reset_counters() + handler = getattr(self, "do_" + str(cmd.get("type")), None) + if handler is None: + return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) + try: + return handler(cmd.get("params") or {}, cmd) + except HubError as e: + out = self._out("failed", error=str(e)) + out["retryable"] = e.retryable + out["peer"] = "хабом" + return out + except HttpError as e: + out = self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) + out["retryable"] = e.code >= 500 # исход неизвестен: Runner повторит + return out + except ConnectionError as e: + out = self._out("failed", error=str(e)) + out["retryable"] = True + return out + + def _out(self, status, result=None, error=None) -> dict: + return {"status": status, "result": result, "error": error, + "jira_calls": self.eva.calls, "rate_limited": self.eva.rate_limited} # имена полей — протокол хаба + + def _hub(self, fn, *args): + """Вызов хаба из команды: его сбой — HubError, чтобы не выдать его за обрыв связи с Eva.""" + try: + return fn(*args) + except (ConnectionError, HttpError) as e: + raise HubError(e) from None + + def do_wiki_sync(self, p: dict, cmd: dict) -> dict: + return self._out("failed", error="wiki_sync: не реализовано") + + def do_wiki_fetch(self, p: dict, cmd: dict) -> dict: + return self._out("failed", error="wiki_fetch: не реализовано") + + +# --------------------------------------------------------------------------- +# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте +# --------------------------------------------------------------------------- +class Updater: + """Самообновление агента (спека agent «Самообновление агента», design 7–12). Работает только под run.py.""" + RETRY_SECONDS = 300 # временный сбой подготовки — повтор не раньше (R-8) + HINT_SECONDS = 3600 # подсказка «запускайте через run.py» — не чаще (R-10) + SELFTEST_TIMEOUT = 120 + VERSION_RE = re.compile(r"^AGENT_VERSION = (\d+)[ \t]*\r?$", re.M) + SHA_RE = re.compile(r"[0-9a-f]{64}") + + def __init__(self, hub, base_dir: str, launcher_version: Optional[int], enabled: bool = True, + config_path: Optional[str] = None): + self.hub, self.dir, self.launcher_version, self.enabled = hub, base_dir, launcher_version, enabled + self.config_path = config_path or os.path.join(base_dir, "agent.json") + self.state_path = os.path.join(base_dir, "update.json") + self.new_path = os.path.join(base_dir, "agent.py.new") + self.agent_path = os.path.join(base_dir, "agent.py") + self.retry_after = {} # (version, sha256) -> clock(), раньше которого не повторять + self.hints = {} # вид подсказки -> clock() последней записи в лог (не чаще HINT_SECONDS) + self.clock = time.monotonic + self.should_stop = lambda: False + self.run_selftest = self._run_selftest + + def state(self) -> Optional[dict]: + return read_update_state(self.state_path) + + def confirm(self) -> None: + """Первый успешный claim новой версии: trial этой версии → ok (запускатель перестаёт ждать отката).""" + st = self.state() + if st and st.get("state") == "trial" and st.get("to_version") == AGENT_VERSION: + try: + write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256")) + except OSError as e: # диск полон — не падать; trial остался, повтор на следующем claim (M-3) + log("не удалось записать update.json: %s" % e) + return + log("обновление до v%d подтверждено" % AGENT_VERSION) + + def _hint(self, kind: str, msg: str) -> None: + now = self.clock() + at = self.hints.get(kind) + if at is None or now - at >= self.HINT_SECONDS: + self.hints[kind] = now + log(msg) + + def check(self, release) -> bool: + """Вызывается после claim без команды. True — agent.py.new проверен, update.json = staged: run выходит с 75.""" + if not self.enabled or not isinstance(release, dict): + return False + version, sha = release.get("version"), release.get("sha256") + if type(version) is not int or not isinstance(sha, str) or not self.SHA_RE.fullmatch(sha): + self._hint("format", "хаб прислал неполную публикацию: version=%.80r, sha256=%.80r" % (version, sha)) # design 10 + return False + if version <= AGENT_VERSION: + return False + st = self.state() + if st and st.get("state") in ("rejected", "rolled_back") and st.get("to_version") == version \ + and st.get("sha256") == sha: + return False + if self.launcher_version is None: + self._hint("launcher", "доступна v%d; для самообновления запускайте через run.py" % version) + return False + until = self.retry_after.get((version, sha)) + if until is not None and self.clock() < until: + return False + return self.prepare(version, sha) + + def prepare(self, version: int, sha: str) -> bool: + log("доступна v%d — готовлю обновление" % version) + try: + rel = self.hub.release() + except (ConnectionError, HttpError) as e: + return self._transient(version, sha, "скачивание: %s" % e) + content = rel.get("content") if isinstance(rel, dict) else None + if not isinstance(content, str) or rel.get("version") != version or rel.get("sha256") != sha: + return self._transient(version, sha, "публикация сменилась между запросами") + data = content.encode("utf-8") + if hashlib.sha256(data).hexdigest() != sha: + return self._reject(version, sha, "sha256 не сходится") + try: + compile(content, "agent.py.new", "exec") + except (SyntaxError, ValueError) as e: + return self._reject(version, sha, "не компилируется: %s" % e) + if self.VERSION_RE.findall(content) != [str(version)]: + return self._reject(version, sha, "AGENT_VERSION в файле не %d" % version) + tmp = self.new_path + ".tmp" + try: # диск полон, нет прав — временный сбой, не трейсбек (design 9) + with open(tmp, "wb") as f: + f.write(data) + try: + os.chmod(tmp, os.stat(self.agent_path).st_mode & 0o777) + except OSError: + pass + os.replace(tmp, self.new_path) + except OSError as e: + return self._transient(version, sha, "файлы: %s" % e) + if self.should_stop(): + self._drop_new() + return False + try: + code, out = self.run_selftest(self.new_path) + except OSError as e: + return self._transient(version, sha, "selftest: %s" % e) + if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии + self._drop_new() + return False + if code == 0: + try: + write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha) + except OSError as e: + return self._transient(version, sha, "update.json: %s" % e) + log("v%d готова, перезапуск через run.py" % version) + return True + if code is None: + return self._transient(version, sha, "selftest: таймаут %d с" % self.SELFTEST_TIMEOUT) + if code == 3: + return self._transient(version, sha, "selftest: хаб или Eva недоступны") + return self._reject(version, sha, "selftest: код %s: %s" % (code, out[-500:])) + + def _run_selftest(self, path: str): + try: + p = subprocess.run([sys.executable, path, "--config", self.config_path, "selftest"], cwd=self.dir, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=self.SELFTEST_TIMEOUT) + except subprocess.TimeoutExpired: + return None, "" + return p.returncode, p.stdout.decode("utf-8", "replace") + + def _drop_new(self) -> None: + for path in (self.new_path, self.new_path + ".tmp"): + try: + os.remove(path) + except OSError: + pass + + def _transient(self, version, sha, reason) -> bool: + self._drop_new() + self.retry_after[(version, sha)] = self.clock() + self.RETRY_SECONDS + log("обновление до v%d отложено на %d мин: %s" % (version, self.RETRY_SECONDS // 60, reason)) + return False + + def _reject(self, version, sha, reason) -> bool: + self._drop_new() + try: + write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason) + except OSError as e: + log("не удалось записать update.json: %s" % e) + log("v%d отвергнута: %s" % (version, reason)) + return False + + +class Runner: + RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Eva / 5xx (R-10) + RETRY_MIN_WAIT = 30 # секунд до повтора + + def __init__(self, cfg: dict, hub: HubClient, eva: EvaClient, journal: Journal): + self.cfg, self.hub, self.eva, self.journal = cfg, hub, eva, journal + self.settings = None # type: Optional[dict] + self.executor = None # type: Optional[Executor] + self.stop = False + self.sleep = time.sleep + self.idle_limit = None # type: Optional[int] + self.updater = None # type: Optional[Updater] + self.exit_code = 0 # EXIT_UPDATE — выход для подмены версии + + @property + def poll(self) -> float: + return max(float(self.cfg.get("poll_seconds", 5) or 0), 0) + + def hub_wait(self, e: Exception, base: float) -> float: + """Залогировать сбой хаба и вернуть, сколько ждать. 401/403 — токен неверен: повторять часто бессмысленно.""" + if isinstance(e, HttpError) and e.code in (401, 403): + log("хаб: HTTP %d — неверный hub_token в agent.json, повтор через 60 с" % e.code) + return 60 + log("хаб: %s, повтор через %d с" % (e, base)) + return base + + def load_settings(self) -> None: + self.settings = self.hub.settings() + self.executor = Executor(self.eva, self.hub, self.settings) + self.executor.sleep = self.sleep + log("настройки хаба v%s" % self.settings.get("settings_version")) + + def restore_pace(self) -> None: + """После перезапуска время последнего вызова Eva неизвестно (живёт в памяти). Берём самое позднее время записи + журнала: finished пишется сразу после последнего вызова команды, оценка не раньше настоящего вызова (M-9). + Время в будущем (часы ушли назад) — считаем «сейчас»: пауза полная (M-6 #12). Оценка служит только паузе: + last_call_iso (уходит хабу в claim) ставит лишь настоящий вызов Eva (M-7 #12).""" + if self.eva.last_call_at is not None: + return + at = self.journal.last_at() + if at is None: + return + ago = (datetime.now(timezone.utc) - at).total_seconds() + if ago < 0: + log("журнал: время последней записи в будущем (%s) — пауза перед первой командой полная" % at.isoformat()) + self.eva.last_call_at = time.monotonic() + return # вторая строка «не позже <будущее>» противоречила бы первой (M-2 #18) + self.eva.last_call_at = time.monotonic() - ago + log("темп: последний вызов Eva не позже %s (по журналу)" + % at.astimezone(timezone.utc).replace(microsecond=0).isoformat()) + + def send_result(self, rec: dict) -> bool: + """Отчёт хабу; повторяется каждые 10 с, пока хаб не подтвердит. 404/409/422 — хаб отверг (команда отменена + или удалена): считаем закрытой, чтобы не зациклиться. При остановке по сигналу делается одна попытка.""" + cid = rec["command_id"] + payload = {"status": rec["status"], "result": rec.get("result"), "error": rec.get("error"), + "jira_calls": rec.get("jira_calls", 0), "rate_limited": rec.get("rate_limited", 0), + "agent_version": AGENT_VERSION} + while True: + try: + self.hub.result(cid, payload) + self.journal.acked(cid) + return True + except HttpError as e: + if e.code in (404, 409, 422): + log("хаб отверг отчёт по #%s: %s — считаю закрытым" % (cid, e)) + self.journal.acked(cid) + return True + wait = self.hub_wait(e, 10) + except ConnectionError as e: + wait = self.hub_wait(e, 10) + if self.stop: + log("остановка: отчёт по #%s не подтверждён, будет дослан при следующем старте" % cid) + return False + self.sleep(wait) + + def wait_seconds(self, seconds) -> bool: + """Спать заданное время шагами по секунде; False — остановлены во время ожидания.""" + remaining = float(seconds or 0) + if remaining > 0: + log(" пауза %.0f с" % remaining) + while remaining > 0 and not self.stop: + step = min(remaining, 1.0) + self.sleep(step) + remaining -= step + return not self.stop + + def wait_pause(self, pause_seconds) -> bool: + """Досыпать до зазора от последнего вызова Eva; False — остановлены во время паузы. + Если вызовов Eva ещё не было — пауза не нужна.""" + pause = float(pause_seconds or 0) + if pause > 0 and self.eva.last_call_at is not None: + return self.wait_seconds(pause - (time.monotonic() - self.eva.last_call_at)) + return not self.stop + + def run_once(self, cmd: dict) -> dict: + try: + return self.executor.run(cmd) + except Exception as e: # баг в обработчике не должен ронять цикл и зацикливать восстановление + log(" ! необработанная ошибка: %r" % e) + return {"status": "failed", "result": None, + "error": "внутренняя ошибка агента: %s: %s" % (type(e).__name__, str(e)[:300]), + "jira_calls": self.eva.calls, "rate_limited": self.eva.rate_limited} + + def execute(self, cmd: dict, journal_claimed: bool = True) -> None: + cid = cmd["id"] + log("#%s %s %s" % (cid, cmd["type"], json.dumps(cmd.get("params"), ensure_ascii=False)[:200])) + if journal_claimed: + self.journal.claimed(cid) # сразу после claim, до паузы (R-9): остановка не теряет команду + if not self.wait_pause(cmd.get("pause_seconds")): + return + self.journal.started(cid) # started — только после паузы + calls = limited = attempt = 0 + while True: + out = self.run_once(cmd) + calls += out["jira_calls"] + limited += out["rate_limited"] + peer = out.pop("peer", "Eva") # с кем оборвалась связь: Eva или хаб + if not out.pop("retryable", False): + break + if attempt >= self.RETRY_ATTEMPTS: + out = {"status": "failed", "result": None, + "error": "результат неизвестен (обрыв связи с %s), повтор безопасен: %s" % (peer, out.get("error"))} + break + attempt += 1 + log(" ! обрыв связи с %s, повтор %d/%d через проверку: %s" % (peer, attempt, self.RETRY_ATTEMPTS, out.get("error"))) + # F-1: обрыв не обновляет last_call_at, поэтому ждём фиксированно от момента отказа, а не от последнего вызова + if not self.wait_seconds(max(float(cmd.get("pause_seconds") or 0), self.RETRY_MIN_WAIT)): + return # остановка: finished не пишем, команду подхватит recover + out["jira_calls"], out["rate_limited"] = calls, limited + self.journal.finished(cid, out["status"], out.get("result"), out.get("error"), calls, limited) + log(" → %s %s" % (out["status"], out.get("error") or json.dumps(out.get("result"), ensure_ascii=False))) + rec = dict(out) + rec["command_id"] = cid + self.send_result(rec) + + def fetch_command(self, cid): + """Команда из хаба по id; None — хаб такой не знает; при недоступности хаба ждём и повторяем.""" + while True: + try: + return self.hub.command(cid) + except HttpError as e: + if e.code in (404, 409, 422): + log("#%s: хаб ответил %s" % (cid, e)) + return None + wait = self.hub_wait(e, 10) + except ConnectionError as e: + wait = self.hub_wait(e, 10) + if self.stop: + raise InterruptedError(cid) + self.sleep(wait) + + def recover(self) -> None: + for rec in self.journal.unacked_finished(): + log("досылаю отчёт по #%s" % rec["command_id"]) + self.send_result(rec) + for cid in self.journal.unfinished_started(): + if self.stop: + return + try: + cmd = self.fetch_command(cid) + except InterruptedError: + return + if cmd is None: + self.journal.acked(cid) + continue + if cmd.get("status") != "claimed": + log("#%s уже %s в хабе — пропускаю" % (cid, cmd.get("status"))) + self.journal.acked(cid) + continue + log("#%s выдана до перезапуска — выполняю через проверку" % cid) + self.execute(cmd, journal_claimed=False) # claimed/started для неё уже в журнале (FM-4); пауза — из хаба (M-9) + + def reload_settings_before(self, cmd) -> bool: + """Настройки сменились: перечитать ДО выполнения. False — остановка, команда не должна выполняться + (если она уже выдана хабом, фиксируем claimed — подхватит recover).""" + while True: + try: + self.load_settings() + return True + except (ConnectionError, HttpError) as e: + wait = self.hub_wait(e, max(self.poll, 1)) + if self.stop: + if cmd: + self.journal.claimed(cmd["id"]) + return False + if not cmd: + self.sleep(wait) + return False + self.sleep(wait) + + def loop(self) -> None: + self.restore_pace() + idle, recovered = 0, False + while not self.stop: + if self.settings is None: + try: + self.load_settings() + except (ConnectionError, HttpError) as e: + self.sleep(self.hub_wait(e, max(self.poll, 1))) + continue + if not recovered: + self.recover() + recovered = not self.stop + continue + try: + upd = self.updater + resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.eva.last_call_iso, + upd.state() if upd else None, upd.launcher_version if upd else None) + except (ConnectionError, HttpError) as e: + self.sleep(self.hub_wait(e, max(self.poll, 1))) + continue + if self.updater: + self.updater.confirm() + cmd = resp.get("command") + if resp.get("settings_version") != self.settings.get("settings_version"): + if not self.reload_settings_before(cmd): + continue + if cmd: + idle = 0 + self.execute(cmd) + continue + if self.updater and not self.stop and self.updater.check(resp.get("agent_release")): + if not self.stop: # Ctrl-C во время подготовки — обычная остановка; staged доделает run.py + self.exit_code = EXIT_UPDATE + return + idle += 1 + if self.idle_limit is not None and idle >= self.idle_limit: + return + self.sleep(self.poll) + + +def acquire_lock(journal_path: str): + """Исключительная блокировка <журнал>.lock: один процесс агента на журнал. Хаб довыдаёт команду без отчёта при + следующем claim, поэтому второй процесс выполнял бы ту же команду одновременно с первым. None — уже занято.""" + f = open(journal_path + ".lock", "a") + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + f.close() + return None + return f + + +def cmd_run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: + lock = acquire_lock(journal_path) + if lock is None: + log("агент уже запущен с журналом %s — второй процесс не стартует" % journal_path) + return EXIT_LOCKED + try: + return _run(cfg, journal_path, config_path) + finally: + lock.close() + + +def cmd_prune(journal_path: str) -> int: + """Удалить подтверждённые записи старше 30 дней. Под той же блокировкой, что run: prune перезаписывает файл, и запись, + добавленная работающим агентом (finished — неотправленный отчёт), пропала бы (T2.3).""" + lock = acquire_lock(journal_path) + if lock is None: + log("агент запущен с журналом %s — prune невозможен, сначала остановите агента" % journal_path) + return 1 + try: + removed = Journal(journal_path).prune(30) + log("журнал: удалено записей %d" % removed) + return 0 + finally: + lock.close() + + +def launcher_version() -> Optional[int]: + """Версия запускателя из окружения (run.py ставит JIRA_AGENT_LAUNCHER — имя общее с jira-agent); без запускателя — None.""" + try: + return int(os.environ["JIRA_AGENT_LAUNCHER"]) + except (KeyError, ValueError): + return None + + +def _run(cfg: dict, journal_path: str, config_path: Optional[str] = None) -> int: + runner = Runner(cfg, HubClient(cfg), EvaClient(cfg), Journal(journal_path)) + runner.updater = Updater(runner.hub, HERE, launcher_version(), cfg.get("auto_update", True) is not False, config_path) + runner.updater.should_stop = lambda: runner.stop + + def on_signal(signum, frame): + try: + if runner.stop: + log("повторный сигнал — выхожу немедленно") + else: + log("сигнал %s: доделываю текущую команду и выхожу" % signum) + except RuntimeError: # print внутри обработчика, прервавшего другой print (reentrant call) + pass + if runner.stop: + sys.exit(130) + runner.stop = True + + signal.signal(signal.SIGINT, on_signal) + signal.signal(signal.SIGTERM, on_signal) + log("eva-agent v%d: хаб %s, Eva %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["eva"]["base_url"], journal_path)) + runner.loop() + log("выход для обновления (код %d)" % EXIT_UPDATE if runner.exit_code == EXIT_UPDATE else "остановлен") + return runner.exit_code + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def cmd_check(cfg: dict) -> int: + ok = True + hub = HubClient(cfg) + via = "" + if hub.folder is not None: + hub.timeout = 10 # реле молчит дольше 10 с — хаб недоступен + via = " через папку %s" % hub.folder.root + try: + s = hub.settings() + log("хаб: доступен%s, настройки v%s" % (via, s.get("settings_version"))) + except (ConnectionError, HttpError) as e: + log("хаб: НЕДОСТУПЕН — %s" % e) + ok = False + # проверка Eva — в задаче 6 плана + return 0 if ok else 1 + + +def cmd_selftest(cfg: dict) -> int: + """Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться: + 0 — хаб (и Eva, когда проверка добавится) доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10).""" + log("eva-agent v%d" % AGENT_VERSION) + return 0 if cmd_check(cfg) == 0 else 3 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.path.join(HERE, "agent.json")) + ap.add_argument("--journal", default=os.path.join(HERE, "journal.jsonl")) + ap.add_argument("cmd", nargs="?", default="run", choices=["run", "check", "selftest", "prune"]) + args = ap.parse_args(argv) + cfg = load_config(args.config) + if args.cmd == "check": + return cmd_check(cfg) + if args.cmd == "selftest": + return cmd_selftest(cfg) + if args.cmd == "prune": + return cmd_prune(args.journal) + return cmd_run(cfg, args.journal, args.config) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/run.py b/src/run.py new file mode 100644 index 0000000..b647271 --- /dev/null +++ b/src/run.py @@ -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()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b10eec1 --- /dev/null +++ b/tests/__init__.py @@ -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) diff --git a/tests/_common.py b/tests/_common.py new file mode 100644 index 0000000..89ed982 --- /dev/null +++ b/tests/_common.py @@ -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 diff --git a/tests/fake_hub.py b/tests/fake_hub.py new file mode 100644 index 0000000..eabe8f8 --- /dev/null +++ b/tests/fake_hub.py @@ -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} diff --git a/tests/fake_relay.py b/tests/fake_relay.py new file mode 100644 index 0000000..2b1255a --- /dev/null +++ b/tests/fake_relay.py @@ -0,0 +1,74 @@ +"""Фейковое реле хаба для тестов агента: забирает req/.json из папки и отвечает resp/.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() diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..79011ec --- /dev/null +++ b/tests/test_clients.py @@ -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"\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() diff --git a/tests/test_folder.py b/tests/test_folder.py new file mode 100644 index 0000000..0e3ff9e --- /dev/null +++ b/tests/test_folder.py @@ -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": "

" + "я" * 200000 + "

"}] + 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() diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 0000000..5e63fef --- /dev/null +++ b/tests/test_journal.py @@ -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() diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 0000000..9325b42 --- /dev/null +++ b/tests/test_launcher.py @@ -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() diff --git a/tests/test_py39.py b/tests/test_py39.py new file mode 100644 index 0000000..03c76c9 --- /dev/null +++ b/tests/test_py39.py @@ -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() diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..cffccf1 --- /dev/null +++ b/tests/test_runner.py @@ -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() diff --git a/tests/test_self_update_e2e.py b/tests/test_self_update_e2e.py new file mode 100644 index 0000000..e0cad78 --- /dev/null +++ b/tests/test_self_update_e2e.py @@ -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 видит публикацию v, ставит её, v подтверждает; 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) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..773199c --- /dev/null +++ b/tests/test_update.py @@ -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()