Compare commits
8 Commits
96c92d9b9e
...
20b858c984
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b858c984 | ||
|
|
866f9d7b4d | ||
|
|
7512acb083 | ||
|
|
3aa8823929 | ||
|
|
140f921ce5 | ||
|
|
82428ea869 | ||
|
|
d2cefa72c1 | ||
|
|
0c2a539963 |
@ -1,6 +1,6 @@
|
||||
# Архитектура
|
||||
|
||||
Агент — один файл `src/agent.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках
|
||||
Агент — один файл `src/agent.py` и запускатель `src/run.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках
|
||||
Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, план агента: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md` (план хаба: `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`).
|
||||
|
||||
## Разделы файла `src/agent.py`
|
||||
@ -12,10 +12,20 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
||||
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
||||
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
|
||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||
| CLI | `cmd_run`, `cmd_check`, `cmd_prune`, `main` | команды `run`, `check`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||
|
||||
## Запускатель `src/run.py`
|
||||
|
||||
Не импортирует `agent.py` (тот меняется). `Launcher` запускает `agent.py run` дочерним процессом с
|
||||
`JIRA_AGENT_LAUNCHER`; коды 0/130 — выход; 75 — `apply_update` (копия в `agent.py.prev`, `os.replace`, `trial`);
|
||||
падение в `trial` или 10 минут без `ok` — `rollback`; иначе перезапуск с паузой 5…300 с. Блокировка `run.py.lock`;
|
||||
SIGINT при живом агенте игнорирует (Ctrl-C получает агент), SIGTERM пересылает. Спека — «Запускатель run.py».
|
||||
|
||||
## Границы
|
||||
- Код новой версии приходит только через API хаба (`GET /agent/release`) и пишется в каталог агента; общая папка для
|
||||
кода не используется.
|
||||
- Пароль и токен Jira — только в `agent.json`. В хаб, журнал и логи не попадают.
|
||||
- Политика темпа не хранится в агенте: зазоры (`pacing`) и `pause_seconds` приходят от хаба. Агент досыпает паузу
|
||||
до зазора от последнего вызова Jira (`JiraClient.last_call_at`).
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@ -17,7 +17,9 @@
|
||||
## Архитектура
|
||||
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/specs/agent/`),
|
||||
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
|
||||
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `cmd_prune`, `main`).
|
||||
выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`,
|
||||
`cmd_selftest`, `cmd_run`, `cmd_prune`, `main`).
|
||||
- `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует.
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
|
||||
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
|
||||
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
|
||||
@ -29,6 +31,8 @@
|
||||
- `/usr/bin/python3 src/agent.py check` — доступность хаба и Jira, без записи в Jira.
|
||||
- `/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` — запуск через запускатель (самообновление).
|
||||
|
||||
## Тесты
|
||||
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`.
|
||||
@ -36,14 +40,24 @@
|
||||
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
|
||||
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
|
||||
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_refresh.py` (`refresh`),
|
||||
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429).
|
||||
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
|
||||
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
|
||||
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).
|
||||
- Golden-фикстур пока нет: каждый тест строит сценарий в коде.
|
||||
|
||||
## Первая подсистема
|
||||
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
||||
журнал, восстановление после перезапуска, CLI `run|check|prune`.
|
||||
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
||||
Текущий change — `../jira-hub/openspec/changes/agent-self-update/`, план — `docs/superpowers/plans/2026-10-14-agent-self-update-agent.md`.
|
||||
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
|
||||
|
||||
## Выкатка новой версии
|
||||
Самообновление (с v5): после merge PR агента в main — поднять `AGENT_VERSION` (если не поднят в PR) и опубликовать:
|
||||
`uv run --project ../jira-hub jira-hub agent publish src/agent.py` (из основного checkout `jira-agent` на main).
|
||||
Через несколько минут MCP `agent_status` должен показать «обновление: ok v<старая> → v<новая>»; откат — «откат с v<N>…»
|
||||
(причина — в логе агента на виртуалке). Это пункт чеклиста «закрывай» для PR агента. `run.py` публикацией не
|
||||
обновляется — только вручную через общую папку.
|
||||
|
||||
## OpenSpec и superpowers
|
||||
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.
|
||||
- Когда brainstorming классифицировал задачу как architectural, структуру change создать через `openspec new change <id>` (или `/opsx:propose <id>`), а содержимое proposal.md, specs/ и design.md написать по итогам brainstorming. tasks.md из OpenSpec не заполнять — задачи живут в плане superpowers.
|
||||
|
||||
53
README.md
53
README.md
@ -2,14 +2,16 @@
|
||||
|
||||
Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`jira-hub` на Mac mini),
|
||||
выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске
|
||||
и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9.
|
||||
и возвращает хабу состояние задач. Один файл `src/agent.py` и запускатель `src/run.py`, только стандартная
|
||||
библиотека, Python 3.9.
|
||||
|
||||
Спека: `../jira-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
|
||||
Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP.
|
||||
|
||||
## Доставка на виртуалку
|
||||
|
||||
1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`.
|
||||
1. Скопировать `src/agent.py`, `src/run.py` и `src/agent.example.json` через общую папку в локальный каталог на
|
||||
виртуалке, например `~/jira-agent/`. Запускать из этого каталога, не из общей папки.
|
||||
2. Переименовать `agent.example.json` в `agent.json` и заполнить:
|
||||
- `hub_url` — `http://<адрес Mac mini>:8790`, или, если сеть до Mac mini закрыта VPN (Континент), `folder:/Volumes/My Shared Files/RSHB/jira-bridge` — обмен через общую папку UTM (на хабе должна быть задана настройка `relay_dir`, см. README хаба);
|
||||
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
|
||||
@ -17,7 +19,8 @@
|
||||
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
|
||||
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 2 ниже);
|
||||
- `timeout` — таймаут запроса в секундах (по умолчанию 60);
|
||||
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5).
|
||||
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5);
|
||||
- `auto_update` — самообновление (по умолчанию `true`; `false` — агент не обновляется сам).
|
||||
3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки.
|
||||
Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira.
|
||||
|
||||
@ -41,6 +44,9 @@ python3 agent.py check
|
||||
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
|
||||
Команда ничего не пишет в Jira.
|
||||
|
||||
`python3 agent.py selftest` — то же, что `check`, плюс первая строка `jira-agent v<N>`; код 0 — всё доступно, 3 — что-то
|
||||
недоступно. Её вызывает предыдущая версия агента перед самообновлением.
|
||||
|
||||
## Связь с хабом через общую папку
|
||||
|
||||
Когда VPN «Континент ZTN» включён, его фильтр закрывает локальную сеть: хаб по `http://` недоступен. Тогда в `agent.json`:
|
||||
@ -59,19 +65,41 @@ python3 agent.py check
|
||||
В терминале:
|
||||
|
||||
```
|
||||
python3 agent.py
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
В фоне с логом:
|
||||
|
||||
```
|
||||
nohup python3 -u agent.py > agent.log 2>&1 &
|
||||
nohup python3 -u run.py > agent.log 2>&1 &
|
||||
```
|
||||
|
||||
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
|
||||
`run.py` запускает `agent.py` дочерним процессом и обновляет его (раздел «Самообновление»). Запуск
|
||||
`python3 agent.py` без запускателя работает, но тогда агент себя не обновляет и раз в час пишет «доступна v<N>; для
|
||||
самообновления запускайте через run.py».
|
||||
|
||||
Остановка: Ctrl-C в терминале или `kill <pid run.py>` (не pid агента: агент, вышедший без сигнала запускателю,
|
||||
считается упавшим и перезапускается). Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
|
||||
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
|
||||
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку, выдержав паузу темпа от последней записи журнала.
|
||||
|
||||
## Самообновление
|
||||
|
||||
С версии 5 агент обновляется сам. После `jira-hub agent publish` на Mac mini хаб сообщает новую версию в ответе на
|
||||
`claim`. Агент в паузе между командами (очередь пуста, отчёты подтверждены) скачивает файл по `GET /agent/release`,
|
||||
сверяет sha256, компилирует, проверяет `AGENT_VERSION`, кладёт рядом `agent.py.new`, запускает его `selftest` и выходит
|
||||
с кодом 75. `run.py` копирует текущий `agent.py` в `agent.py.prev`, ставит новый и запускает его в пробном режиме:
|
||||
версия подтверждена, когда она получила первый ответ хаба (`update.json` → `ok`). Если новая версия упала или не вышла
|
||||
на связь за 10 минут — `run.py` возвращает `agent.py.prev` и пишет `rolled_back`; эту версию агент больше не ставит,
|
||||
хаб показывает откат в `agent_status`. Код в общую папку не попадает: файлы пишутся только в каталог `run.py`.
|
||||
|
||||
Файлы рядом с `agent.py`: `update.json` (состояние: `staged`, `trial`, `ok`, `rolled_back`, `rejected`),
|
||||
`agent.py.prev` (прежняя версия), `run.py.lock`. Не удалять во время работы.
|
||||
|
||||
Если агент упал уже после подтверждения, `run.py` перезапускает ту же версию с паузой 5 с, удваивая её до 300 с при
|
||||
повторных падениях. Выключить самообновление: `"auto_update": false` в `agent.json`. `run.py` обновляется вручную
|
||||
(меняется редко).
|
||||
|
||||
## Автозапуск через launchd (опционально)
|
||||
|
||||
Пример `~/Library/LaunchAgents/ru.aepif.jira-agent.plist`:
|
||||
@ -86,7 +114,7 @@ nohup python3 -u agent.py > agent.log 2>&1 &
|
||||
<array>
|
||||
<string>/usr/bin/python3</string>
|
||||
<string>-u</string>
|
||||
<string>/Users/USER/jira-agent/agent.py</string>
|
||||
<string>/Users/USER/jira-agent/run.py</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key><string>/Users/USER/jira-agent</string>
|
||||
<key>RunAtLoad</key><true/>
|
||||
@ -153,9 +181,9 @@ python3 agent.py prune
|
||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||
по сигналу команду подхватит восстановление.
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 4) отправляется в каждом `claim`, в каждом отчёте
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 5) отправляется в каждом `claim`, в каждом отчёте
|
||||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой.
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
@ -171,11 +199,12 @@ python3 agent.py prune
|
||||
|
||||
## Обновление агента
|
||||
|
||||
Остановить старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать новый `agent.py` поверх
|
||||
старого и запустить снова (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
|
||||
С версии 5 — само (раздел «Самообновление»). Вручную обновляются только `run.py` и переход с версии 4: остановить
|
||||
старый процесс (Ctrl-C или `kill <pid>`, дождаться «остановлен»), скопировать `agent.py` и `run.py` в каталог агента,
|
||||
`python3 agent.py check`, запустить `python3 run.py` (или `launchctl unload` + `load`). Два процесса с одним журналом работать не могут: хаб
|
||||
довыдаёт команду без отчёта, и второй процесс выполнял бы её одновременно с первым. Начиная с версии 3 второй процесс
|
||||
не стартует («агент уже запущен…», код 1) — блокировка `journal.jsonl.lock` рядом с журналом.
|
||||
`agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
|
||||
`agent.json`, `journal.jsonl` и `update.json` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
|
||||
|
||||
## Известные ограничения / эксплуатация
|
||||
|
||||
|
||||
1363
docs/superpowers/plans/2026-10-14-agent-self-update-agent.md
Normal file
1363
docs/superpowers/plans/2026-10-14-agent-self-update-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
19
openspec/changes/agent-self-update/design.md
Normal file
19
openspec/changes/agent-self-update/design.md
Normal file
@ -0,0 +1,19 @@
|
||||
# agent-self-update — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/agent-self-update/` (issue aep/jira-hub#22, aep/jira-agent#19).
|
||||
Агентская часть — решения 7–20 дизайна хаба (агент v5, `selftest`, `update.json`, запускатель `run.py`) и 21–24
|
||||
(совместимость, последнее ручное обновление, публикация в чеклисте закрытия, живая проверка).
|
||||
|
||||
Решения владельца 14.10.2026: доставка через хаб (`GET /agent/release`), запускатель с откатом, код пишется и
|
||||
запускается только в каталоге `run.py` на виртуалке, не в общей папке.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Рулинги R-4…R-13, R-16, R-17 записаны в `jira-hub/openspec/changes/agent-self-update/design.md`
|
||||
(раздел «Decisions made without discussion»); здесь — только решения, принятые при исполнении агентской части.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-19 | 2026-10-14 | `test_version_is_4` → `test_version_is_5` | план не учёл старый тест версии; спека требует v5 | нет |
|
||||
| R-20 (design #16, #17 хаба) | 2026-10-14 | Выход 0/130 — остановка человеком, только если запускатель сам получил SIGINT/SIGTERM; иначе падение (в `trial` — откат); Ctrl-C при выходе с 75 — подмена и выход; агент, получивший Ctrl-C при подготовке, выходит с 0 | финальное ревью I-1: версия, сама вышедшая с 0, останавливала мост без отката | `kill <pid агента>` перезапускает агента — останавливать `run.py` |
|
||||
| R-21 | 2026-10-14 | M-1 финального ревью (отрицательный `sleep` в `pause`) повышена до Important и исправлена | падение запускателя останавливает мост до человека, `run.py` обновляется вручную | нет |
|
||||
@ -8,5 +8,6 @@
|
||||
"verify_ssl": true,
|
||||
"ca_bundle": null,
|
||||
"timeout": 60,
|
||||
"poll_seconds": 5
|
||||
"poll_seconds": 5,
|
||||
"auto_update": true
|
||||
}
|
||||
|
||||
214
src/agent.py
214
src/agent.py
@ -8,16 +8,22 @@ Python 3.9. Спека: jira-hub/openspec/specs/agent/spec.md.
|
||||
|
||||
python3 agent.py check # доступ к хабу и Jira, без записи
|
||||
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
|
||||
@ -27,7 +33,8 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
AGENT_VERSION = 4
|
||||
AGENT_VERSION = 5
|
||||
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
API = "/rest/api/2"
|
||||
AGILE = "/rest/agile/1.0"
|
||||
@ -61,6 +68,32 @@ def load_config(path: str) -> dict:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -273,9 +306,13 @@ class HubClient(Http):
|
||||
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_jira_call_at) -> dict:
|
||||
def claim(self, agent_version, settings_version, last_jira_call_at, update_state=None, launcher_version=None) -> dict:
|
||||
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
|
||||
"last_jira_call_at": last_jira_call_at}, timeout=max(self.timeout, 45))
|
||||
"last_jira_call_at": last_jira_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")
|
||||
@ -911,6 +948,132 @@ class Executor:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Цикл: 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)
|
||||
|
||||
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.hint_at = None # type: Optional[float]
|
||||
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:
|
||||
write_update_state(self.state_path, "ok", st.get("from_version"), AGENT_VERSION, st.get("sha256"))
|
||||
log("обновление до v%d подтверждено" % AGENT_VERSION)
|
||||
|
||||
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
|
||||
try:
|
||||
version, sha = int(release.get("version")), str(release.get("sha256"))
|
||||
except (TypeError, ValueError):
|
||||
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:
|
||||
now = self.clock()
|
||||
if self.hint_at is None or now - self.hint_at >= self.HINT_SECONDS:
|
||||
self.hint_at = now
|
||||
log("доступна 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"
|
||||
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)
|
||||
if self.should_stop():
|
||||
self._drop_new()
|
||||
return False
|
||||
code, out = self.run_selftest(self.new_path)
|
||||
if self.should_stop(): # Ctrl-C уронил и selftest — это не отказ версии
|
||||
self._drop_new()
|
||||
return False
|
||||
if code == 0:
|
||||
write_update_state(self.state_path, "staged", AGENT_VERSION, version, sha)
|
||||
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: хаб или Jira недоступны")
|
||||
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 FileNotFoundError:
|
||||
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()
|
||||
write_update_state(self.state_path, "rejected", AGENT_VERSION, version, sha, reason)
|
||||
log("v%d отвергнута: %s" % (version, reason))
|
||||
return False
|
||||
|
||||
|
||||
class Runner:
|
||||
RETRY_ATTEMPTS = 3 # повторов команды после обрыва связи с Jira / 5xx (R-10)
|
||||
RETRY_MIN_WAIT = 30 # секунд до повтора
|
||||
@ -922,6 +1085,8 @@ class Runner:
|
||||
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:
|
||||
@ -1113,10 +1278,14 @@ class Runner:
|
||||
recovered = not self.stop
|
||||
continue
|
||||
try:
|
||||
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.last_call_iso)
|
||||
upd = self.updater
|
||||
resp = self.hub.claim(AGENT_VERSION, self.settings.get("settings_version"), self.jira.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):
|
||||
@ -1125,6 +1294,10 @@ class Runner:
|
||||
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
|
||||
@ -1143,13 +1316,13 @@ def acquire_lock(journal_path: str):
|
||||
return f
|
||||
|
||||
|
||||
def cmd_run(cfg: dict, journal_path: str) -> int:
|
||||
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 1
|
||||
try:
|
||||
return _run(cfg, journal_path)
|
||||
return _run(cfg, journal_path, config_path)
|
||||
finally:
|
||||
lock.close()
|
||||
|
||||
@ -1169,8 +1342,18 @@ def cmd_prune(journal_path: str) -> int:
|
||||
lock.close()
|
||||
|
||||
|
||||
def _run(cfg: dict, journal_path: str) -> int:
|
||||
def launcher_version() -> Optional[int]:
|
||||
"""Версия запускателя из окружения (run.py ставит JIRA_AGENT_LAUNCHER); без запускателя — 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), JiraClient(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:
|
||||
@ -1188,8 +1371,8 @@ def _run(cfg: dict, journal_path: str) -> int:
|
||||
signal.signal(signal.SIGTERM, on_signal)
|
||||
log("jira-agent v%d: хаб %s, Jira %s, журнал %s" % (AGENT_VERSION, cfg["hub_url"], cfg["jira"]["base_url"], journal_path))
|
||||
runner.loop()
|
||||
log("остановлен")
|
||||
return 0
|
||||
log("выход для обновления (код %d)" % EXIT_UPDATE if runner.exit_code == EXIT_UPDATE else "остановлен")
|
||||
return runner.exit_code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -1217,18 +1400,27 @@ def cmd_check(cfg: dict) -> int:
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def cmd_selftest(cfg: dict) -> int:
|
||||
"""Самопроверка версии перед обновлением: её запускает предыдущая версия. Контракт кодов MUST сохраняться:
|
||||
0 — хаб и Jira доступны, 3 — что-то недоступно (временно); любой другой код — файл негоден (design 10)."""
|
||||
log("jira-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", "prune"])
|
||||
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)
|
||||
return cmd_run(cfg, args.journal, args.config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
238
src/run.py
Normal file
238
src/run.py
Normal file
@ -0,0 +1,238 @@
|
||||
#!/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 = 1
|
||||
EXIT_UPDATE = 75 # агент: «agent.py.new проверен, update.json = staged»
|
||||
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) -> None:
|
||||
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"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
return hashlib.sha256(f.read()).hexdigest()
|
||||
|
||||
|
||||
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 recover_files(self) -> None:
|
||||
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")
|
||||
if os.path.exists(self.new):
|
||||
st = read_state(self.state_path)
|
||||
if st and st.get("state") == "staged":
|
||||
log("найдена подготовленная версия — ставлю")
|
||||
self.apply_update()
|
||||
else:
|
||||
os.remove(self.new)
|
||||
|
||||
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")):
|
||||
if os.path.exists(self.new):
|
||||
os.remove(self.new)
|
||||
write_state(self.state_path, "rejected", st, "файл изменился после проверки")
|
||||
log("обновление отвергнуто: файл изменился после проверки")
|
||||
return
|
||||
tmp = self.prev + ".tmp"
|
||||
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 существует на каждом шаге
|
||||
write_state(self.state_path, "trial", st)
|
||||
log("v%s → v%s: пробный запуск" % (st.get("from_version"), st.get("to_version")))
|
||||
|
||||
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."""
|
||||
start = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
return child.wait(1), False
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
if trial:
|
||||
st = read_state(self.state_path)
|
||||
if st and st.get("state") == "ok":
|
||||
trial = False
|
||||
elif time.monotonic() - start >= self.trial_seconds:
|
||||
self.terminate(child)
|
||||
return child.returncode, True
|
||||
|
||||
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.recover_files()
|
||||
code = 0
|
||||
while True:
|
||||
if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2)
|
||||
return code
|
||||
st = read_state(self.state_path)
|
||||
trial = bool(st and st.get("state") == "trial")
|
||||
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 = self.wait_child(self.child, trial)
|
||||
self.child = None
|
||||
uptime = time.monotonic() - started
|
||||
st = read_state(self.state_path)
|
||||
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 trial and not (st and st.get("state") == "ok"):
|
||||
if self.rollback(st, "код %s через %d с" % (code, uptime)):
|
||||
continue
|
||||
if self.stopping:
|
||||
return code
|
||||
wait = self.next_backoff(uptime)
|
||||
log("агент упал (код %s), перезапуск через %d с" % (code, 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())
|
||||
@ -20,6 +20,8 @@ class FakeHub:
|
||||
self.fail_results = 0
|
||||
self.reject_results = {}
|
||||
self.in_flight = None
|
||||
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
||||
self.release_requests = 0
|
||||
self.lock = threading.Lock()
|
||||
hub = self
|
||||
|
||||
@ -50,6 +52,10 @@ class FakeHub:
|
||||
self.queue.append(cid)
|
||||
return cid
|
||||
|
||||
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 {}
|
||||
@ -72,15 +78,20 @@ class FakeHub:
|
||||
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"]}
|
||||
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"]}
|
||||
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": c["params"], "pause_seconds": c["pause_seconds"]},
|
||||
"settings_version": self.settings["settings_version"]}
|
||||
"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/known-keys":
|
||||
|
||||
@ -200,5 +200,22 @@ class ClientsTest(unittest.TestCase):
|
||||
self.assertEqual(agent.main(["--config", path, "check"]), 1)
|
||||
|
||||
|
||||
def test_selftest_command(self):
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "agent.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(jira_cfg(self.fake), f)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
# хаб недоступен (порт 1), Jira доступен — selftest печатает версию и возвращает 3, а не 1
|
||||
self.assertEqual(agent.main(["--config", path, "selftest"]), 3)
|
||||
self.assertIn("jira-agent v5", out.getvalue())
|
||||
self.assertIn("Jira: доступен", out.getvalue())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -157,8 +157,8 @@ class FolderIntegrationTest(unittest.TestCase):
|
||||
def cfg(self):
|
||||
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||
|
||||
def test_version_is_4(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 4)
|
||||
def test_version_is_5(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 5)
|
||||
|
||||
def test_runner_through_folder(self):
|
||||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||||
|
||||
292
tests/test_launcher.py
Normal file
292
tests/test_launcher.py
Normal file
@ -0,0 +1,292 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
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')
|
||||
|
||||
|
||||
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 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"), "1 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")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -10,21 +10,24 @@ from _common import SRC
|
||||
|
||||
|
||||
class Py39Test(unittest.TestCase):
|
||||
def test_agent_parses_as_python_39(self):
|
||||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||||
ast.parse(f.read(), feature_version=(3, 9))
|
||||
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):
|
||||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid", "fcntl"}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
self.assertIn(a.name.split(".")[0], allowed, a.name)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
self.assertIn((node.module or "").split(".")[0], allowed, node.module)
|
||||
|
||||
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()
|
||||
|
||||
@ -51,6 +51,53 @@ class RunnerTest(unittest.TestCase):
|
||||
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"], 5)
|
||||
|
||||
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": 6, "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": 6, "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": 6, "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": 6, "sha256": "s", "content": "x"}
|
||||
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
|
||||
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"], "done") # команда выполнена и подтверждена
|
||||
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,
|
||||
|
||||
78
tests/test_self_update_e2e.py
Normal file
78
tests/test_self_update_e2e.py
Normal file
@ -0,0 +1,78 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from _common import SETTINGS, SRC
|
||||
from fake_hub import FakeHub
|
||||
from fake_jira import FakeJira
|
||||
|
||||
|
||||
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: v5 видит публикацию v6, ставит её, v6 подтверждает; Ctrl-C группе — выход 0."""
|
||||
|
||||
def setUp(self):
|
||||
self.jira = FakeJira()
|
||||
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,
|
||||
"jira": {"base_url": self.jira.base_url,
|
||||
"auth": {"type": "basic", "user": "me", "password": "secret"}}}, f)
|
||||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
content = src.replace("AGENT_VERSION = 5\n", "AGENT_VERSION = 6\n", 1)
|
||||
self.assertNotEqual(content, src)
|
||||
self.hub.release = {"version": 6, "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.jira.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_v6_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") == 6 for b in self.hub.claim_bodies)))
|
||||
last = [b for b in self.hub.claim_bodies if b.get("agent_version") == 6][-1]
|
||||
self.assertEqual(last["launcher_version"], 1)
|
||||
os.killpg(self.proc.pid, signal.SIGINT) # Ctrl-C в терминале — всей группе
|
||||
self.assertEqual(self.proc.wait(30), 0)
|
||||
211
tests/test_update.py
Normal file
211
tests/test_update.py
Normal file
@ -0,0 +1,211 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from _common import SETTINGS, agent
|
||||
from fake_hub import FakeHub
|
||||
|
||||
|
||||
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_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 test_good_version_is_staged(self):
|
||||
rel = make_release(6)
|
||||
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", 5, 6, 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(5)
|
||||
self.assertFalse(self.u.check(self.ref(self.hub.release)))
|
||||
self.assertFalse(self.u.check(None))
|
||||
self.hub.release = make_release(6)
|
||||
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(6)
|
||||
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", 6, "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(6, body="def (:\n")
|
||||
self.hub.release = bad
|
||||
self.assertFalse(self.u.check(self.ref(bad)))
|
||||
self.assertIn("не компилируется", self.u.state()["error"])
|
||||
wrong = make_release(7)
|
||||
wrong = dict(wrong, version=8)
|
||||
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(6)
|
||||
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(6)
|
||||
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(6)
|
||||
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(6) # в 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(6), make_release(7)
|
||||
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(6)
|
||||
self.hub.release = rel
|
||||
agent.write_update_state(self.u.state_path, "rolled_back", 5, 6, rel["sha256"], "код 1 через 3 с")
|
||||
self.assertFalse(self.u.check(self.ref(rel)))
|
||||
fixed = make_release(6, 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(6)
|
||||
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("доступна v6; для самообновления запускайте через run.py"), 2)
|
||||
self.assertEqual(self.hub.release_requests, 0)
|
||||
|
||||
def test_stop_during_selftest_is_not_reject(self):
|
||||
self.hub.release = make_release(6)
|
||||
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()
|
||||
Loading…
x
Reference in New Issue
Block a user