diff --git a/beaver_agent/hands/komodo.py b/beaver_agent/hands/komodo.py index 9a044b8..36ff3ee 100644 --- a/beaver_agent/hands/komodo.py +++ b/beaver_agent/hands/komodo.py @@ -37,6 +37,29 @@ Action = Literal[ ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") EXIT_MARK = "__KOMODO_EXIT_CODE:" NOT_RUNNING_LIMIT = 15 +UPDATES_LIMIT = 15 +UPDATES_MAX = 200 + +LIST_LIMIT = 500 +LIST_PAGES = 20 +"""Komodo режет любой ``List*`` до своего ``default_pagination_limit`` (у нас +50) и молчит об этом: ответ - просто короткий массив, неотличимый от полного. +Поэтому листаем сами, а не верим первой странице.""" + +RESOURCE_LISTS = ( + "ListStacks", + "ListServers", + "ListRepos", + "ListResourceSyncs", + "ListProcedures", + "ListActions", + "ListAlerters", + "ListDeployments", + "ListBuilds", +) +"""Журнал операций называет ресурс id, а не именем: отсюда берём словарь.""" + +SCOPE_NOTE = "список ограничен правами ключа: вне их Komodo ресурсы не показывает" @dataclass(frozen=True, slots=True) @@ -65,6 +88,9 @@ class Komodo: tail: int = 100, update: str | None = None, server: str | None = None, + limit: int = UPDATES_LIMIT, + operation: str | None = None, + resource: str | None = None, ) -> str: """Прод через Komodo: посмотреть, а по просьбе Бобра - подействовать. @@ -74,7 +100,11 @@ class Komodo: - containers: контейнеры сервера (server; без него - все серверы). - logs: логи стека (stack, service опционально, tail строк). - search_logs: поиск по логам стека (stack, terms - слова через пробел). - - updates: последние операции - кто, что, статус. + - updates: последние операции - кто, что, статус. limit записей + (по умолчанию 15, максимум 200), operation - подстрока типа операции + (deploy, sync, restart), resource - имя стека, сервера, репозитория. + Шапка говорит, какой отрезок журнала показан: журнал длинный, и + «под фильтр ничего не попало» - это не «операций больше не было». - update: подробности одной операции по id (update). - deploy / restart: стек целиком или один service; ждёт исхода до 3 мин. - exec: команда внутри контейнера (container, command; server, если имя @@ -98,7 +128,7 @@ class Komodo: _need(stack, "stack"), _need(terms, "terms"), service ) case "updates": - return await self._updates() + return await self._updates(limit, operation, resource) case "update": return await self._update(_need(update, "update")) case "deploy": @@ -118,7 +148,7 @@ class Komodo: async def _status(self) -> str: servers, stacks = await asyncio.gather( - self.read("ListServers", {}), self.read("ListStacks", {}) + self._list("ListServers"), self._list("ListStacks") ) lines = [f"{s['name']:24} {s['info']['state']}" for s in servers] bad = [s for s in stacks if s["info"]["state"] != "running"] @@ -127,10 +157,15 @@ class Komodo: f" {s['info']['state']:12} {s['name']} @{s['info'].get('server_name')}" for s in bad[:NOT_RUNNING_LIMIT] ) + if len(bad) > NOT_RUNNING_LIMIT: + lines.append( + f"показаны {NOT_RUNNING_LIMIT} из {len(bad)}, остальные скрыты лимитом" + ) + lines.append(SCOPE_NOTE) return "\n".join(lines) async def _stacks(self) -> str: - stacks = await self.read("ListStacks", {}) + stacks = await self._list("ListStacks") lines = [] for s in sorted(stacks, key=lambda s: s["name"]): info = s["info"] @@ -141,17 +176,16 @@ class Komodo: f"{info['state']:12} {s['name']:28} @{info.get('server_name')}" + (" (есть незадеплоенные коммиты)" if behind else "") ) + lines.append(f"стеков: {len(stacks)}; {SCOPE_NOTE}") return "\n".join(lines) async def _containers(self, server: str | None) -> str: names = ( - [server] - if server - else [s["name"] for s in await self.read("ListServers", {})] + [server] if server else [s["name"] for s in await self._list("ListServers")] ) lines = [] for name in names: - containers = await self.read("ListDockerContainers", {"server": name}) + containers = await self._list("ListDockerContainers", {"server": name}) lines.append(f"{name}:") lines.extend( f" {c['state']:10} {c['name']:36} {c.get('status', '')}" @@ -177,9 +211,67 @@ class Komodo: ) return self._clip(_log_text(log)) - async def _updates(self) -> str: - updates = (await self.read("ListUpdates", {}))["updates"][:15] - return "\n".join(_update_line(u) for u in updates) or "операций нет" + async def _updates( + self, limit: int, operation: str | None, resource: str | None + ) -> str: + want = min(max(limit, 1), UPDATES_MAX) + names = await self._resource_names() + query: dict[str, Any] = {} + applied = [] + if operation: + query["operation"] = {"$regex": re.escape(operation), "$options": "i"} + applied.append(f"операция ~{operation}") + if resource: + ids = [i for i, name in names.items() if resource.lower() in name.lower()] + if not ids: + return ( + f"ресурса ~{resource} ключу руки не видно: " + "либо имя другое, либо он вне прав ключа" + ) + query["target.id"] = {"$in": ids} + applied.append(f"ресурс ~{resource} ({len(ids)} шт)") + updates, more = await self._update_pages(query, want) + + head = [f"операций: {len(updates)}"] + if updates: + head.append( + f"{_ts(updates[-1]['start_ts'])} … {_ts(updates[0]['start_ts'])}" + ) + head.append("раньше в журнале есть ещё" if more else "это весь журнал") + lines = [", ".join(head)] + lines.append( + "фильтрует Komodo: " + "; ".join(applied) + if applied + else "фильтра нет: это просто хвост журнала" + ) + if want != limit: + lines.append(f"запрошено {limit} записей, предел руки {want}") + unseen = sum(1 for u in updates if u["target"]["id"] not in names) + if unseen: + lines.append( + f"записей о невидимых ключу ресурсах: {unseen} - id вместо имени" + ) + lines.extend(_update_line(u, names) for u in updates) + return "\n".join(lines) + + async def _update_pages( + self, query: dict[str, Any], want: int + ) -> tuple[list[dict[str, Any]], bool]: + """Страница ListUpdates - ровно 100 записей, limit там не работает.""" + out: list[dict[str, Any]] = [] + page: int | None = 0 + while page is not None and len(out) < want: + body: dict[str, Any] = {"page": page} + if query: + body["query"] = query + answer = await self.read("ListUpdates", body) + out.extend(answer["updates"]) + page = answer.get("next_page") + return out[:want], len(out) > want or page is not None + + async def _resource_names(self) -> dict[str, str]: + lists = await asyncio.gather(*(self._list(r) for r in RESOURCE_LISTS)) + return {r["id"]: r["name"] for rows in lists for r in rows} async def _update(self, update_id: str) -> str: return self._clip( @@ -224,12 +316,25 @@ class Komodo: return f"{header}\n{self._clip(output.strip())}" async def _server_of(self, container: str) -> str | None: - for s in await self.read("ListServers", {}): - containers = await self.read("ListDockerContainers", {"server": s["name"]}) + for s in await self._list("ListServers"): + containers = await self._list("ListDockerContainers", {"server": s["name"]}) if any(c["name"] == container for c in containers): return s["name"] return None + async def _list( + self, request: str, params: dict[str, Any] | None = None + ) -> list[Any]: + out: list[Any] = [] + for page in range(LIST_PAGES): + chunk = await self.read( + request, (params or {}) | {"page": page, "limit": LIST_LIMIT} + ) + out.extend(chunk) + if len(chunk) < LIST_LIMIT: + break + return out + async def _json(self, route: str, body: dict[str, Any]) -> Any: status, text = await self._post(route, body) if status != 200: @@ -275,9 +380,10 @@ def _ts(ms: int) -> str: return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%m-%d %H:%M") -def _update_line(u: dict[str, Any]) -> str: +def _update_line(u: dict[str, Any], names: dict[str, str]) -> str: ok = "ok" if u.get("success") else "FAIL" - head = f"{_ts(u['start_ts'])} {u['operation']:20} {u['status']:10} {ok}" + target = names.get(u["target"]["id"], u["target"]["id"]) + head = f"{_ts(u['start_ts'])} {u['operation']:20} {target:26} {u['status']:10} {ok}" return f"{head} user={u.get('username')} id={u.get('id')}" diff --git a/tests/test_komodo.py b/tests/test_komodo.py index b41dceb..e7cb4e6 100644 --- a/tests/test_komodo.py +++ b/tests/test_komodo.py @@ -49,3 +49,138 @@ def test_clip_keeps_head_and_tail(): k = Komodo(url="u", key="k", secret="s", max_chars=30) out = k._clip("a" * 100) assert out.startswith("a" * 20) and out.endswith("a" * 10) and "обрезано" in out + + +def fleet(answers, calls=None): + """Komodo, отвечающий заготовками; calls копит запросы к нему.""" + + class Fake(Komodo): + async def read(self, request, params): + if calls is not None: + calls.append((request, params)) + answer = answers.get(request, []) + return answer(params) if callable(answer) else answer + + return Fake(url="u", key="k", secret="s") + + +def a_stack(name, state="running", stack_id=None): + return { + "id": stack_id or name, + "name": name, + "info": {"state": state, "server_name": "dell-home"}, + } + + +def an_update(operation="DeployStack", target_id="beaver-agent", ts=1_700_000_000_000): + return { + "id": f"upd-{ts}", + "operation": operation, + "start_ts": ts, + "success": True, + "status": "Complete", + "username": "hh", + "target": {"type": "Stack", "id": target_id}, + } + + +async def test_a_short_page_is_not_the_whole_list(): + # Komodo режет List* до default_pagination_limit и молчит об этом. + pages = [ + [a_stack(f"s{i}") for i in range(500)], + [a_stack("zv-import"), a_stack("veris-runner")], + ] + calls = [] + komodo = fleet({"ListStacks": lambda p: pages[p["page"]]}, calls) + out = await komodo._stacks() + assert [p["page"] for r, p in calls if r == "ListStacks"] == [0, 1] + assert "zv-import" in out and "стеков: 502" in out + + +async def test_status_says_when_the_not_running_list_is_cut(): + bad = [a_stack(f"b{i}", state="unknown") for i in range(23)] + komodo = fleet({"ListStacks": bad, "ListServers": []}) + out = await komodo._status() + assert "стеков: 23, не running: 23" in out + assert "показаны 15 из 23, остальные скрыты лимитом" in out + + komodo = fleet({"ListStacks": bad[:3], "ListServers": []}) + assert "скрыты лимитом" not in await komodo._status() + + +async def test_both_listings_say_that_they_are_limited_by_rights(): + komodo = fleet({"ListStacks": [a_stack("beaver-agent")], "ListServers": []}) + assert "ограничен правами ключа" in await komodo._status() + assert "ограничен правами ключа" in await komodo._stacks() + + +async def test_updates_carry_the_shown_span_and_say_there_is_more(): + page = { + "updates": [an_update(ts=1_700_000_000_000 - i * 60_000) for i in range(100)] + } + seen = {"ListStacks": [a_stack("beaver-agent")]} + komodo = fleet(seen | {"ListUpdates": page | {"next_page": 1}}) + head, second, *lines = (await komodo._updates(15, None, None)).splitlines() + assert head == "операций: 15, 11-14 21:59 … 11-14 22:13, раньше в журнале есть ещё" + assert second == "фильтра нет: это просто хвост журнала" + assert len(lines) == 15 + + komodo = fleet( + seen | {"ListUpdates": {"updates": page["updates"][:3], "next_page": None}} + ) + assert (await komodo._updates(15, None, None)).startswith( + "операций: 3, 11-14 22:11 … 11-14 22:13, это весь журнал" + ) + + +async def test_the_filter_goes_to_komodo_as_a_query(): + calls = [] + komodo = fleet( + { + "ListStacks": [a_stack("beaver-agent", stack_id="id-1")], + "ListUpdates": { + "updates": [an_update(target_id="id-1")], + "next_page": None, + }, + }, + calls, + ) + out = await komodo._updates(15, "deploy", "beaver") + (params,) = [p for r, p in calls if r == "ListUpdates"] + assert params["query"] == { + "operation": {"$regex": "deploy", "$options": "i"}, + "target.id": {"$in": ["id-1"]}, + } + assert "фильтрует Komodo: операция ~deploy; ресурс ~beaver (1 шт)" in out + # имя ресурса в строке, а не id + assert "beaver-agent" in out and "id-1 " not in out + + +async def test_an_unknown_name_is_said_out_loud_instead_of_filtering_by_nothing(): + komodo = fleet({"ListStacks": [a_stack("beaver-agent")]}) + out = await komodo._updates(15, None, "obsidian") + assert out == ( + "ресурса ~obsidian ключу руки не видно: либо имя другое, либо он вне прав ключа" + ) + + +async def test_a_target_the_key_cannot_see_is_counted_and_shown_by_id(): + komodo = fleet( + { + "ListStacks": [a_stack("beaver-agent", stack_id="id-1")], + "ListUpdates": { + "updates": [an_update(target_id="id-1"), an_update(target_id="gone")], + "next_page": None, + }, + } + ) + out = await komodo._updates(15, None, None) + assert "записей о невидимых ключу ресурсах: 1 - id вместо имени" in out + assert "gone" in out + + +async def test_the_depth_is_clamped_out_loud(): + komodo = fleet({"ListUpdates": {"updates": [], "next_page": None}}) + assert "запрошено 5000 записей, предел руки 200" in await komodo._updates( + 5000, None, None + )