Агент: оборванное тело — обрыв связи, перенаправления только на свой адрес, README v14 (#43)
По финальному ревью: M-1, M-4, M-6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7238691a5d
commit
3a5bd9a109
@ -39,7 +39,7 @@
|
||||
- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`.
|
||||
Паузы в тестах отключаются через `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_executor_attachments.py` (`ensure_attachment`), `test_refresh.py` (`refresh`),
|
||||
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `test_refresh.py` (`refresh`),
|
||||
`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` + фейки).
|
||||
|
||||
@ -174,7 +174,8 @@ python3 agent.py prune
|
||||
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
|
||||
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
|
||||
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
|
||||
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
|
||||
| `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `<base_url>/secure/attachment/<id>/<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется |
|
||||
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
|
||||
|
||||
Правила работы:
|
||||
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
|
||||
@ -192,7 +193,7 @@ python3 agent.py prune
|
||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||
по сигналу команду подхватит восстановление.
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 12) отправляется в каждом `claim`, в каждом отчёте
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 14) отправляется в каждом `claim`, в каждом отчёте
|
||||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||||
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
|
||||
@ -205,6 +206,9 @@ python3 agent.py prune
|
||||
выкатывается только после v12 (design хаба `projects`, решение 14).
|
||||
Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая
|
||||
загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`).
|
||||
Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`);
|
||||
перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на
|
||||
`fetch_attachment` не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»).
|
||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
|
||||
22
src/agent.py
22
src/agent.py
@ -172,9 +172,12 @@ class Http:
|
||||
if content_type is not None:
|
||||
req.add_header("Content-Type", content_type)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self.ctx) as resp:
|
||||
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:
|
||||
@ -193,6 +196,14 @@ class Http:
|
||||
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."""
|
||||
@ -200,13 +211,12 @@ class Http:
|
||||
for k, v in self.headers.items():
|
||||
req.add_header(k, v)
|
||||
req.add_header("Accept", "*/*")
|
||||
handlers = [_SameOriginRedirect(self.base)] # type: List[Any]
|
||||
if self.ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=self.ctx))
|
||||
opener = urllib.request.build_opener(*handlers)
|
||||
try:
|
||||
with opener.open(req, timeout=timeout or self.timeout) as resp:
|
||||
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
|
||||
|
||||
@ -90,6 +90,8 @@ class FakeJira:
|
||||
self.content_gets = 0 # GET /secure/attachment/<id>/<имя> — запросы содержимого
|
||||
self.redirect_content = None # адрес для 302 на запрос содержимого (абсолютный или путь этого сервера)
|
||||
self.short_content = set() # id вложений, содержимое которых отдаётся без последнего байта
|
||||
self.lying_length = set() # id вложений: Content-Length больше тела, соединение рвётся после тела
|
||||
self.redirects = {} # путь → адрес Location: 302 вместо ответа (любой метод)
|
||||
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
|
||||
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
|
||||
self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}]
|
||||
@ -263,6 +265,12 @@ class FakeJira:
|
||||
code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]}
|
||||
except Exception as e: # некорректный запрос к фейку
|
||||
code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]}
|
||||
if url.path in self.redirects:
|
||||
h.send_response(302)
|
||||
h.send_header("Location", self.redirects[url.path])
|
||||
h.send_header("Content-Length", "0")
|
||||
h.end_headers()
|
||||
return
|
||||
if method == "GET" and (url.path.startswith("/secure/attachment/") or url.path.startswith("/secure/moved/")):
|
||||
return self._content(h, url.path)
|
||||
if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue":
|
||||
@ -301,9 +309,13 @@ class FakeJira:
|
||||
data = found["data"][:-1] if found["id"] in self.short_content else found["data"]
|
||||
h.send_response(200)
|
||||
h.send_header("Content-Type", found["mimeType"])
|
||||
h.send_header("Content-Length", str(len(data)))
|
||||
h.send_header("Content-Length", str(len(data) + (60 if found["id"] in self.lying_length else 0)))
|
||||
h.end_headers()
|
||||
h.wfile.write(data)
|
||||
if found["id"] in self.lying_length:
|
||||
h.wfile.flush()
|
||||
h.close_connection = True
|
||||
h.connection.shutdown(socket.SHUT_RDWR) # обрыв посреди тела
|
||||
|
||||
def _send(self, h, code, payload, headers=None):
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b""
|
||||
|
||||
@ -133,6 +133,27 @@ class ClientsTest(unittest.TestCase):
|
||||
self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc")
|
||||
self.assertEqual(self.jira.rate_limited, 1)
|
||||
|
||||
def test_download_cut_body_is_connection_error(self): # M-1 финального ревью
|
||||
self.fake.add("AURORA-1")
|
||||
a = self.fake.add_attachment("AURORA-1", "a.bin", b"x" * 40)
|
||||
self.fake.lying_length.add(a["id"])
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.jira.download(self._content_path(a), 1000)
|
||||
|
||||
def test_json_call_redirect_other_host_refused(self): # M-4 финального ревью
|
||||
self.fake.add("AURORA-1")
|
||||
port = self.fake.server.server_port
|
||||
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = "http://localhost:%d/rest/api/2/issue/AURORA-2" % port
|
||||
with self.assertRaises(agent.HttpError) as e:
|
||||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||
self.assertIn("localhost", e.exception.text)
|
||||
self.assertFalse(any(r[1] == "/rest/api/2/issue/AURORA-2" for r in self.fake.requests))
|
||||
|
||||
def test_json_call_redirect_same_host_followed(self):
|
||||
self.fake.add("AURORA-2")
|
||||
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = self.fake.base_url + "/rest/api/2/issue/AURORA-2"
|
||||
self.assertEqual(self.jira.get("/rest/api/2/issue/AURORA-1")["key"], "AURORA-2")
|
||||
|
||||
def test_retry_on_429_with_retry_after(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||||
@ -196,7 +217,7 @@ class ClientsTest(unittest.TestCase):
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
|
||||
with mock.patch("urllib.request.urlopen", side_effect=err):
|
||||
with mock.patch("urllib.request.OpenerDirector.open", side_effect=err):
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.jira.get("/x")
|
||||
self.assertEqual(self.jira.calls, 1)
|
||||
@ -204,7 +225,7 @@ class ClientsTest(unittest.TestCase):
|
||||
def test_incomplete_read_is_connection_error(self):
|
||||
import http.client
|
||||
from unittest import mock
|
||||
with mock.patch("urllib.request.urlopen", side_effect=http.client.IncompleteRead(b"ab", 10)):
|
||||
with mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)):
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.jira.get("/x")
|
||||
|
||||
|
||||
@ -242,3 +242,12 @@ class FetchAttachmentTest(unittest.TestCase):
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertFalse(out["retryable"])
|
||||
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
|
||||
|
||||
def test_fetch_unknown_size_cut_body_retryable(self): # M-1 финального ревью
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.json", b"x" * 40)
|
||||
self.fake.hide_size.add(a["id"])
|
||||
self.fake.lying_length.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user