feat(agent): транспорт folder: — обмен с хабом через общую папку (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
de0a3d9003
commit
f9a2b7df38
87
src/agent.py
87
src/agent.py
@ -21,6 +21,7 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@ -176,9 +177,93 @@ class JiraClient(Http):
|
|||||||
return self.call("DELETE", path)
|
return self.call("DELETE", path)
|
||||||
|
|
||||||
|
|
||||||
|
class FolderTransport:
|
||||||
|
"""Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке).
|
||||||
|
Запрос — файл req/<id>.json, ответ кладёт реле хаба в resp/<id>.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 "<FolderTransport %s>" % 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("ответ не объект")
|
||||||
|
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
|
||||||
|
status, text = int(resp.get("status") or 0), resp.get("body") or ""
|
||||||
|
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):
|
class HubClient(Http):
|
||||||
def __init__(self, cfg: dict):
|
def __init__(self, cfg: dict):
|
||||||
super().__init__(cfg["hub_url"], {"Authorization": "Bearer " + cfg["hub_token"]}, cfg.get("timeout", 60))
|
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_jira_call_at) -> dict:
|
def claim(self, agent_version, settings_version, last_jira_call_at) -> dict:
|
||||||
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
|
return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version,
|
||||||
|
|||||||
74
tests/fake_relay.py
Normal file
74
tests/fake_relay.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""Фейковое реле хаба для тестов агента: забирает req/<id>.json из папки и отвечает resp/<id>.json.
|
||||||
|
Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать;
|
||||||
|
garbage — положить нечитаемый ответ; late — ответить через delay секунд."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(directory, name, payload):
|
||||||
|
tmp = os.path.join(directory, "." + name + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
|
||||||
|
os.replace(tmp, os.path.join(directory, name))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRelay:
|
||||||
|
def __init__(self, root, target_base_url="", mode="forward", delay=0.0):
|
||||||
|
self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay
|
||||||
|
self.requests = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||||
|
for sub in ("req", "resp"):
|
||||||
|
os.makedirs(os.path.join(root, sub), exist_ok=True)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
self._thread.join(5)
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp")
|
||||||
|
while not self._stop.is_set():
|
||||||
|
for name in sorted(os.listdir(req_dir)):
|
||||||
|
if name.startswith(".") or not name.endswith(".json"):
|
||||||
|
continue
|
||||||
|
path = os.path.join(req_dir, name)
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
req = json.load(f)
|
||||||
|
os.remove(path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
self.requests.append(req)
|
||||||
|
if self.mode == "silent":
|
||||||
|
continue
|
||||||
|
if self.mode == "garbage":
|
||||||
|
write_atomic(resp_dir, name, "{не json")
|
||||||
|
continue
|
||||||
|
if self.mode == "late":
|
||||||
|
time.sleep(self.delay)
|
||||||
|
status, body = self._forward(req)
|
||||||
|
write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body})
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
def _forward(self, req):
|
||||||
|
data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None
|
||||||
|
r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"])
|
||||||
|
r.add_header("Authorization", req["authorization"])
|
||||||
|
if data is not None:
|
||||||
|
r.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=10) as resp:
|
||||||
|
return resp.status, resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, e.read().decode("utf-8")
|
||||||
|
finally:
|
||||||
|
e.close()
|
||||||
117
tests/test_folder.py
Normal file
117
tests/test_folder.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent
|
||||||
|
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()["project_key"], "AURORA")
|
||||||
|
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 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()["project_key"], "AURORA")
|
||||||
|
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()["project_key"], "AURORA")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase):
|
|||||||
def test_no_third_party_imports(self):
|
def test_no_third_party_imports(self):
|
||||||
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
|
||||||
tree = ast.parse(f.read())
|
tree = ast.parse(f.read())
|
||||||
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing"}
|
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid"}
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if isinstance(node, ast.Import):
|
if isinstance(node, ast.Import):
|
||||||
for a in node.names:
|
for a in node.names:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user