feat(hands): выдача komodo несёт свою область - полные списки, отрезок журнала и фильтры

This commit is contained in:
hh committed 2026-09-21 00:09:04 +00:00
1 parent 27c4e68b6e
commit 04fc4dc177
2 files changed
+256 -15

No files matched your search

+121 -15
View File
@@ -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')}"