diff --git a/src/agent.py b/src/agent.py index d1ec045..5931386 100644 --- a/src/agent.py +++ b/src/agent.py @@ -21,6 +21,7 @@ import time import urllib.error import urllib.parse import urllib.request +import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional @@ -176,9 +177,93 @@ class JiraClient(Http): return self.call("DELETE", path) +class FolderTransport: + """Обмен с хабом через общую папку (hub_url = folder:<путь>), когда сеть до хаба закрыта (VPN на виртуалке). + Запрос — файл req/.json, ответ кладёт реле хаба в resp/.json. Ошибки те же, что у Http.call: + нет ответа к сроку или нет папки — ConnectionError, код не 2xx — HttpError. Спека: specs/agent, folder-transport.""" + POLL = 0.1 + BAD_JSON_WAIT = 2.0 + + def __init__(self, root: str, authorization: str): + self.root, self.authorization = root, authorization + + def __repr__(self): + return "" % self.root + + def call(self, method: str, path: str, body=None, timeout: float = 60) -> Any: + if not os.path.isdir(self.root): + raise ConnectionError("нет папки %s" % self.root) + req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp") + rid = uuid.uuid4().hex + try: + os.makedirs(req_dir, exist_ok=True) + os.makedirs(resp_dir, exist_ok=True) + deadline = time.time() + timeout + self._write(req_dir, rid + ".json", { + "id": rid, "method": method, "path": path, "body": body, "authorization": self.authorization, + "deadline": datetime.fromtimestamp(deadline, timezone.utc).isoformat()}) + except OSError as e: + raise ConnectionError("папка %s недоступна: %s" % (self.root, e)) from None + resp_path = os.path.join(resp_dir, rid + ".json") + end = time.monotonic() + timeout + while time.monotonic() < end: + if os.path.exists(resp_path): + return self._take(resp_path) + time.sleep(self.POLL) + try: + os.remove(os.path.join(req_dir, rid + ".json")) + except OSError: + pass + raise ConnectionError("хаб не ответил через папку %s за %.0f с" % (self.root, timeout)) + + @staticmethod + def _write(directory: str, name: str, payload: dict) -> None: + tmp = os.path.join(directory, "." + name + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, os.path.join(directory, name)) + + def _take(self, path: str) -> Any: + end = time.monotonic() + self.BAD_JSON_WAIT + while True: + try: + with open(path, encoding="utf-8") as f: + resp = json.load(f) + if not isinstance(resp, dict): + raise ValueError("ответ не объект") + 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): 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: return self.call("POST", "/agent/claim", {"agent_version": agent_version, "settings_version": settings_version, diff --git a/tests/fake_relay.py b/tests/fake_relay.py new file mode 100644 index 0000000..2b1255a --- /dev/null +++ b/tests/fake_relay.py @@ -0,0 +1,74 @@ +"""Фейковое реле хаба для тестов агента: забирает req/.json из папки и отвечает resp/.json. +Режимы: forward — переслать в HTTP (FakeHub) и вернуть ответ; silent — забрать и не отвечать; +garbage — положить нечитаемый ответ; late — ответить через delay секунд.""" +import json +import os +import threading +import time +import urllib.error +import urllib.request + + +def write_atomic(directory, name, payload): + tmp = os.path.join(directory, "." + name + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + f.write(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)) + os.replace(tmp, os.path.join(directory, name)) + + +class FakeRelay: + def __init__(self, root, target_base_url="", mode="forward", delay=0.0): + self.root, self.base, self.mode, self.delay = root, target_base_url, mode, delay + self.requests = [] + self._stop = threading.Event() + self._thread = threading.Thread(target=self._loop, daemon=True) + for sub in ("req", "resp"): + os.makedirs(os.path.join(root, sub), exist_ok=True) + + def start(self): + self._thread.start() + return self + + def stop(self): + self._stop.set() + self._thread.join(5) + + def _loop(self): + req_dir, resp_dir = os.path.join(self.root, "req"), os.path.join(self.root, "resp") + while not self._stop.is_set(): + for name in sorted(os.listdir(req_dir)): + if name.startswith(".") or not name.endswith(".json"): + continue + path = os.path.join(req_dir, name) + try: + with open(path, encoding="utf-8") as f: + req = json.load(f) + os.remove(path) + except (OSError, ValueError): + continue + self.requests.append(req) + if self.mode == "silent": + continue + if self.mode == "garbage": + write_atomic(resp_dir, name, "{не json") + continue + if self.mode == "late": + time.sleep(self.delay) + status, body = self._forward(req) + write_atomic(resp_dir, name, {"id": req["id"], "status": status, "body": body}) + time.sleep(0.02) + + def _forward(self, req): + data = json.dumps(req["body"]).encode("utf-8") if req.get("body") is not None else None + r = urllib.request.Request(self.base + req["path"], data=data, method=req["method"]) + r.add_header("Authorization", req["authorization"]) + if data is not None: + r.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(r, timeout=10) as resp: + return resp.status, resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + try: + return e.code, e.read().decode("utf-8") + finally: + e.close() diff --git a/tests/test_folder.py b/tests/test_folder.py new file mode 100644 index 0000000..a2198d8 --- /dev/null +++ b/tests/test_folder.py @@ -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() diff --git a/tests/test_py39.py b/tests/test_py39.py index c846b4c..3b1a0cb 100644 --- a/tests/test_py39.py +++ b/tests/test_py39.py @@ -17,7 +17,7 @@ class Py39Test(unittest.TestCase): 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"} + allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing", "uuid"} for node in ast.walk(tree): if isinstance(node, ast.Import): for a in node.names: