feat(hands): выдача komodo несёт свою область - полные списки, отрезок журнала и фильтры
This commit is contained in:
1 parent
6cd937249b
commit
0ab77a6b69
2 files changed
+256
-15
No files matched your search
+121
-15
@@ -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')}"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in new issue
Block a user