Add inter-service call graph: generate calls.example.json, implement scan_calls.py, and update documentation

This commit is contained in:
emelinda 2026-08-20 17:47:27 +03:00
parent 047e2e6a91
commit d077847c00
10 changed files with 2962 additions and 1230 deletions

View File

@ -2,7 +2,7 @@
Модель технологического слоя типового контура платформы. **Все данные, зависящие от конкретной инсталляции — имя кластера, узлы, домены, реестр — заменены на примеры.** Реальные значения в репозиторий не попадают: они задаются профилем контура, который держится отдельно. Модель технологического слоя типового контура платформы. **Все данные, зависящие от конкретной инсталляции — имя кластера, узлы, домены, реестр — заменены на примеры.** Реальные значения в репозиторий не попадают: они задаются профилем контура, который держится отдельно.
Прикладной слой присутствует только как потребители технологических сервисов. Межсервисные REST-связи приложений и бизнес-слой будут добавлены отдельно. Прикладной слой представлен потребителями технологических сервисов и межсервисными вызовами. Бизнес-слой будет добавлен отдельно.
## Файлы ## Файлы
@ -11,24 +11,32 @@
| `example-technology.archimate` | нативный формат [Archi](https://www.archimatetool.com/) | десктопный Archi: File → Open | | `example-technology.archimate` | нативный формат [Archi](https://www.archimatetool.com/) | десктопный Archi: File → Open |
| `example-technology.xml` | The Open Group ArchiMate Model Exchange File Format 3.1 | любой инструмент с импортом Open Exchange, в том числе archi-online.com: **Import, save, and create models** → Open Exchange | | `example-technology.xml` | The Open Group ArchiMate Model Exchange File Format 3.1 | любой инструмент с импортом Open Exchange, в том числе archi-online.com: **Import, save, and create models** → Open Exchange |
| `preview.html` | SVG-рендер всех представлений | любой браузер, без установки чего-либо | | `preview.html` | SVG-рендер всех представлений | любой браузер, без установки чего-либо |
| `matrix.example.json` | снимок матрицы «приложение → технологический сервис» | вход для сборки |
| `calls.example.json` | снимок графа межсервисных вызовов | вход для сборки |
70 элементов, 153 отношения, 9 представлений. 80 элементов, 318 отношений, 23 представления.
| Представление | Что показывает | | Представление | Что показывает |
|---|---| |---|---|
| T1 | Кластер, ПО внутри него, внешние узлы, технологические сервисы. Без приложений — обзорная схема | | T1 | Кластер, ПО внутри него, внешние узлы, технологические сервисы. Без приложений — обзорная схема |
| T2T9 | По одному представлению на технологический сервис: кто им пользуется | | T2 | Развёртывание по узлам: какая группа узлов какие компоненты несёт |
| A1 | Ядро прикладного слоя: восемь самых связанных сервисов и вызовы между ними |
| A2A12 | По представлению на сервис с тремя и более потребителями: кто от него зависит |
| T15T23 | По одному представлению на технологический сервис: кто им пользуется |
Разбиение по сервисам сделано осознанно: все 118 связей «сервис → приложение» на одной схеме нечитаемы. В модели они лежат одним набором, представления — это срезы. Направление связи — как принято в ArchiMate: стрелка идёт от вызываемого к вызывающему (serving). Читается как «кого лишишься — тот и сломается».
Разбиение по сервисам сделано осознанно: 148 связей «технологический сервис → приложение» и 86 межсервисных вызовов на одной схеме нечитаемы. В модели они лежат одним набором, представления — это срезы.
Метамодель намеренно урезана. Технологический слой: `Node`, `SystemSoftware`, `TechnologyService`, `Artifact`. Прикладной: `ApplicationComponent`. Отношения: `Composition`, `Realization`, `Serving`, `Assignment`. Не используются `Device`, `Path`, `CommunicationNetwork`, `TechnologyFunction` — они не несут информации на этом уровне. Метамодель намеренно урезана. Технологический слой: `Node`, `SystemSoftware`, `TechnologyService`, `Artifact`. Прикладной: `ApplicationComponent`. Отношения: `Composition`, `Realization`, `Serving`, `Assignment`. Не используются `Device`, `Path`, `CommunicationNetwork`, `TechnologyFunction` — они не несут информации на этом уровне.
## Как собрать модель для реального контура ## Как собрать модель для реального контура
1. Снять матрицу связей с нужного кластера: 1. Снять с нужного кластера матрицу использования технологических сервисов и граф межсервисных вызовов:
``` ```
py scan_contour.py <имя-кластера> matrix.json py scan_contour.py <имя-кластера> matrix.json
py scan_calls.py <имя-кластера> calls.json
``` ```
2. Скопировать `contour-profile.example.json`, подставить настоящие имена кластера, узлов, доменов и реестра. **Копию хранить вне репозитория.** 2. Скопировать `contour-profile.example.json`, подставить настоящие имена кластера, узлов, доменов и реестра. **Копию хранить вне репозитория.**
@ -36,11 +44,13 @@
3. Собрать модель и производные форматы: 3. Собрать модель и производные форматы:
``` ```
py build_archimate.py <профиль.json> matrix.json out.archimate py build_archimate.py <профиль.json> matrix.json out.archimate calls.json
py to_open_exchange.py out.archimate out.xml py to_open_exchange.py out.archimate out.xml
py render_preview.py out.archimate out.html py render_preview.py out.archimate out.html
``` ```
Последний аргумент `build_archimate.py` необязателен: без него собирается только технологический слой.
Результат шага 3 в репозиторий не коммитить — он содержит данные контура. Результат шага 3 в репозиторий не коммитить — он содержит данные контура.
## Откуда взяты связи ## Откуда взяты связи
@ -58,10 +68,16 @@
| Приложение → Vault | аннотации `vault.hashicorp.com/agent-inject*` | | Приложение → Vault | аннотации `vault.hashicorp.com/agent-inject*` |
| Приложение → Zitadel | env `ZITADEL*`, `JWKS`, `OIDC`, секрет `jwt-public` | | Приложение → Zitadel | env `ZITADEL*`, `JWKS`, `OIDC`, секрет `jwt-public` |
| Приложение → Istio | `service:` в маршрутах `infrastructure/istio-config/<контур>/istio-config.yaml` (закомментированные не считаются) | | Приложение → Istio | `service:` в маршрутах `infrastructure/istio-config/<контур>/istio-config.yaml` (закомментированные не считаются) |
| Приложение → приложение | адрес `<сервис>.<namespace>` в манифестах вызывающего; либо переменная `<APP>_URL` / `_HOST` / `_BASE_URL` / `_ENDPOINT` |
Сканируются `*.yaml` в `apps/<app>/base/` и `apps/<app>/<контур>/`; документация `*.md` игнорируется, иначе прозаические упоминания переменных дают ложные срабатывания. Сканируются `*.yaml` в `apps/<app>/base/` и `apps/<app>/<контур>/`. Для матрицы технологических сервисов документация `*.md` служит вторым источником, для графа вызовов — `*ENDPOINTS.md` и `*CONFIGURATION.md`.
`matrix.example.json` — снимок такой матрицы. Он содержит только имена приложений и флаги использования сервисов; идентификаторов контура в нём нет. Две поправки, без которых граф вызовов врёт:
- **суффикс контура в namespace.** Адреса встречаются в формах `documentations`, `documentations-prod`, `bim-api`; без нормализации связь теряется, а короткая форма `<сервис>.<namespace>` без `.svc.cluster.local` не распознаётся вовсе.
- **инфраструктурный сегмент в имени переменной.** `DJANGO_POSTGRES_HOST` и `ISSUES_DB_HOST` адресуют базу данных чужого сервиса, а не сам сервис. Это связь другого рода, и как вызов её показывать нельзя.
`matrix.example.json` и `calls.example.json` — снимки. В них только имена приложений и флаги связей; идентификаторов контура нет.
## Про формат файлов ## Про формат файлов
@ -78,6 +94,7 @@
| Скрипт | Назначение | | Скрипт | Назначение |
|---|---| |---|---|
| `scan_contour.py` | матрица «приложение → технологический сервис» по кластеру | | `scan_contour.py` | матрица «приложение → технологический сервис» по кластеру |
| `scan_calls.py` | граф межсервисных вызовов прикладного слоя |
| `build_archimate.py` | сборка модели из профиля и матрицы | | `build_archimate.py` | сборка модели из профиля и матрицы |
| `to_open_exchange.py` | конвертация в обменный формат | | `to_open_exchange.py` | конвертация в обменный формат |
| `render_preview.py` | рендер представлений в HTML со встроенным SVG | | `render_preview.py` | рендер представлений в HTML со встроенным SVG |

View File

@ -9,15 +9,22 @@ import json, pathlib, sys, io
from xml.sax.saxutils import escape, quoteattr from xml.sax.saxutils import escape, quoteattr
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
if len(sys.argv) != 4: if len(sys.argv) not in (4, 5):
sys.exit(__doc__) sys.exit(__doc__)
PROFILE = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) PROFILE = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
DATA = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) DATA = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))
OUT = pathlib.Path(sys.argv[3]) OUT = pathlib.Path(sys.argv[3])
CALLS = json.loads(pathlib.Path(sys.argv[4]).read_text(encoding="utf-8")) if len(sys.argv) == 5 else None
elements, relations, _rc = [], [], [0] elements, relations, _rc = [], [], [0]
REL_INDEX = {} REL_INDEX = {}
def plural(n, one, few, many):
"""Русское согласование числительного: 1 сервис, 3 сервиса, 5 сервисов."""
if n % 100 // 10 == 1:
return many
return {1: one, 2: few, 3: few, 4: few}.get(n % 10, many)
def el(folder, eid, etype, name, doc=""): def el(folder, eid, etype, name, doc=""):
elements.append((folder, eid, etype, name, doc)); return eid elements.append((folder, eid, etype, name, doc)); return eid
@ -204,19 +211,61 @@ if GROUPS:
for i, a in enumerate(big["_apps"]): for i, a in enumerate(big["_apps"]):
obj(vd, app_id[a], 14 + (i % per) * bw, yy + (i // per) * 62, bw - 15, 50, og) obj(vd, app_id[a], 14 + (i % per) * bw, yy + (i // per) * 62, bw - 15, 50, og)
# --- прикладной слой: межсервисные вызовы
if CALLS:
import math, collections
known = set(DATA["apps"])
calls = [(e["from"], e["to"], e["src"]) for e in CALLS["edges"]
if e["from"] in known and e["to"] in known]
# если A зовёт B, то в ArchiMate B обслуживает A
for a, b, _ in calls:
R("Serving", app_id[b], app_id[a])
inbound = collections.Counter(b for _, b, _ in calls)
outbound = collections.Counter(a for a, _, _ in calls)
# A1: ядро — самые связанные сервисы и связи между ними, по кругу
core = [a for a, _ in collections.Counter(
{a: inbound[a] + outbound[a] for a in known}).most_common(8)]
vc = view("A1. Ядро прикладного слоя",
f"Восемь сервисов с наибольшим числом связей и вызовы между ними. "
f"Стрелка идёт от вызываемого к вызывающему (serving): кого лишишься — "
f"тот и сломается. Всего в контуре {len(calls)} межсервисных связей.")
# координаты считаем от нуля, затем сдвигаем: обменный формат не допускает
# отрицательных x/y, а круговая раскладка их легко даёт
rad, pos = 300, {}
for i, a in enumerate(core):
ang = 2 * math.pi * i / len(core) - math.pi / 2
pos[a] = (int(rad * math.cos(ang) * 1.35) - 85, int(rad * math.sin(ang)) - 27)
dx, dy = 20 - min(x for x, _ in pos.values()), 20 - min(y for _, y in pos.values())
core_obj = {a: obj(vc, app_id[a], x + dx, y + dy, 170, 55) for a, (x, y) in pos.items()}
for a, b, _ in calls:
if a in core_obj and b in core_obj:
conn("Serving", core_obj[b], core_obj[a])
# A2…: по представлению на каждый сервис с тремя и более потребителями
a_no = 2
for hub, n in inbound.most_common():
if n < 3:
break
callers = sorted(a for a, b, _ in calls if b == hub)
v = view(f"A{a_no}. Зависят от: {hub}",
f"К {hub} обращаются {n} {plural(n, 'сервис', 'сервиса', 'сервисов')}.")
a_no += 1
oh = obj(v, app_id[hub], 20, 20, 260, 60)
for i, a in enumerate(callers):
conn("Serving", oh,
obj(v, app_id[a], 20 + (i % 6) * 190, 150 + (i // 6) * 80, 170, 55))
PER_ROW = 6 PER_ROW = 6
for ts in SERVICES: for ts in SERVICES:
lst = consumers[ts["id"]] lst = consumers[ts["id"]]
extra = EXTRA_USES.get(ts["id"], []) extra = EXTRA_USES.get(ts["id"], [])
if not lst and not extra: if not lst and not extra:
continue continue
n_doc = sum(1 for a in lst if DATA["matrix"][a].get(ts.get("key")) == "doc") n = len(lst)
src = ("Связи выведены из манифестов приложений."
if not n_doc else
f"Связи выведены из манифестов; {n_doc} из них подтверждаются только "
f"документацией сервиса — значения приходят из k8s-секретов и в конфигах не видны.")
v = view(f"T{len(views)+1}. Потребители: {ts['name']}", v = view(f"T{len(views)+1}. Потребители: {ts['name']}",
f"{len(lst)} из {len(DATA['apps'])} приложений контура. {src}") f"Сервисом {plural(n, 'пользуется', 'пользуются', 'пользуются')} "
f"{n} из {len(DATA['apps'])} приложений контура.")
ots = obj(v, ts["id"], 20, 20, 260, 60) ots = obj(v, ts["id"], 20, 20, 260, 60)
for i, r in enumerate(ts["realizers"]): for i, r in enumerate(ts["realizers"]):
conn("Realization", obj(v, r, 320 + i * 280, 20, 260, 60), ots) conn("Realization", obj(v, r, 320 + i * 280, 20, 260, 60), ots)

View File

@ -0,0 +1,473 @@
{
"apps": [
"ams-sync",
"attachments",
"auth-flow",
"bim",
"cde",
"checklists",
"comparisons",
"contracts",
"control-interface",
"cross-section",
"django",
"document-link",
"documentations",
"drawings",
"eav",
"faas",
"flows",
"iam",
"inspections",
"issues",
"mapper",
"measurements",
"message-hub",
"notes",
"pm",
"prescriptions",
"processing",
"projects",
"remarks",
"resources",
"reviews",
"rfi",
"stamp-verification",
"subscriptions",
"system-log",
"transmittal",
"workspaces"
],
"edges": [
{
"from": "bim",
"to": "django",
"src": "svc"
},
{
"from": "cde",
"to": "flows",
"src": "doc"
},
{
"from": "cde",
"to": "system-log",
"src": "doc"
},
{
"from": "cde",
"to": "workspaces",
"src": "doc"
},
{
"from": "comparisons",
"to": "bim",
"src": "svc"
},
{
"from": "comparisons",
"to": "django",
"src": "doc"
},
{
"from": "comparisons",
"to": "documentations",
"src": "svc"
},
{
"from": "comparisons",
"to": "eav",
"src": "env"
},
{
"from": "comparisons",
"to": "processing",
"src": "svc"
},
{
"from": "comparisons",
"to": "workspaces",
"src": "svc"
},
{
"from": "django",
"to": "bim",
"src": "svc"
},
{
"from": "django",
"to": "documentations",
"src": "svc"
},
{
"from": "django",
"to": "measurements",
"src": "svc"
},
{
"from": "django",
"to": "pm",
"src": "svc"
},
{
"from": "django",
"to": "processing",
"src": "svc"
},
{
"from": "django",
"to": "workspaces",
"src": "svc"
},
{
"from": "documentations",
"to": "attachments",
"src": "svc"
},
{
"from": "documentations",
"to": "bim",
"src": "svc"
},
{
"from": "documentations",
"to": "django",
"src": "svc"
},
{
"from": "documentations",
"to": "drawings",
"src": "svc"
},
{
"from": "documentations",
"to": "eav",
"src": "svc"
},
{
"from": "documentations",
"to": "flows",
"src": "svc"
},
{
"from": "documentations",
"to": "iam",
"src": "svc"
},
{
"from": "documentations",
"to": "inspections",
"src": "svc"
},
{
"from": "documentations",
"to": "notes",
"src": "env"
},
{
"from": "documentations",
"to": "processing",
"src": "svc"
},
{
"from": "documentations",
"to": "remarks",
"src": "svc"
},
{
"from": "documentations",
"to": "resources",
"src": "env"
},
{
"from": "documentations",
"to": "subscriptions",
"src": "svc"
},
{
"from": "documentations",
"to": "system-log",
"src": "svc"
},
{
"from": "documentations",
"to": "workspaces",
"src": "svc"
},
{
"from": "faas",
"to": "django",
"src": "env"
},
{
"from": "faas",
"to": "documentations",
"src": "env"
},
{
"from": "faas",
"to": "eav",
"src": "svc"
},
{
"from": "faas",
"to": "reviews",
"src": "env"
},
{
"from": "faas",
"to": "transmittal",
"src": "svc"
},
{
"from": "flows",
"to": "checklists",
"src": "svc"
},
{
"from": "flows",
"to": "django",
"src": "svc"
},
{
"from": "flows",
"to": "documentations",
"src": "svc"
},
{
"from": "flows",
"to": "eav",
"src": "svc"
},
{
"from": "flows",
"to": "iam",
"src": "svc"
},
{
"from": "flows",
"to": "pm",
"src": "svc"
},
{
"from": "flows",
"to": "processing",
"src": "svc"
},
{
"from": "flows",
"to": "resources",
"src": "svc"
},
{
"from": "inspections",
"to": "eav",
"src": "svc"
},
{
"from": "inspections",
"to": "processing",
"src": "svc"
},
{
"from": "issues",
"to": "django",
"src": "svc"
},
{
"from": "issues",
"to": "documentations",
"src": "svc"
},
{
"from": "issues",
"to": "eav",
"src": "svc"
},
{
"from": "issues",
"to": "flows",
"src": "svc"
},
{
"from": "issues",
"to": "iam",
"src": "svc"
},
{
"from": "issues",
"to": "processing",
"src": "svc"
},
{
"from": "issues",
"to": "resources",
"src": "env"
},
{
"from": "mapper",
"to": "django",
"src": "env"
},
{
"from": "measurements",
"to": "django",
"src": "doc"
},
{
"from": "message-hub",
"to": "django",
"src": "svc"
},
{
"from": "message-hub",
"to": "eav",
"src": "svc"
},
{
"from": "message-hub",
"to": "issues",
"src": "svc"
},
{
"from": "message-hub",
"to": "pm",
"src": "svc"
},
{
"from": "notes",
"to": "attachments",
"src": "svc"
},
{
"from": "notes",
"to": "django",
"src": "env"
},
{
"from": "notes",
"to": "documentations",
"src": "doc"
},
{
"from": "pm",
"to": "django",
"src": "svc"
},
{
"from": "pm",
"to": "eav",
"src": "svc"
},
{
"from": "pm",
"to": "iam",
"src": "svc"
},
{
"from": "pm",
"to": "resources",
"src": "svc"
},
{
"from": "processing",
"to": "django",
"src": "svc"
},
{
"from": "processing",
"to": "documentations",
"src": "svc"
},
{
"from": "processing",
"to": "iam",
"src": "svc"
},
{
"from": "processing",
"to": "resources",
"src": "svc"
},
{
"from": "resources",
"to": "django",
"src": "svc"
},
{
"from": "rfi",
"to": "django",
"src": "svc"
},
{
"from": "rfi",
"to": "documentations",
"src": "svc"
},
{
"from": "rfi",
"to": "eav",
"src": "svc"
},
{
"from": "rfi",
"to": "resources",
"src": "doc"
},
{
"from": "subscriptions",
"to": "django",
"src": "svc"
},
{
"from": "subscriptions",
"to": "system-log",
"src": "svc"
},
{
"from": "system-log",
"to": "django",
"src": "svc"
},
{
"from": "system-log",
"to": "documentations",
"src": "svc"
},
{
"from": "transmittal",
"to": "django",
"src": "svc"
},
{
"from": "transmittal",
"to": "documentations",
"src": "svc"
},
{
"from": "transmittal",
"to": "flows",
"src": "svc"
},
{
"from": "transmittal",
"to": "iam",
"src": "svc"
},
{
"from": "transmittal",
"to": "resources",
"src": "svc"
},
{
"from": "workspaces",
"to": "django",
"src": "svc"
},
{
"from": "workspaces",
"to": "documentations",
"src": "svc"
}
]
}

View File

@ -32,7 +32,7 @@
{ {
"id": "ss-vault", "id": "ss-vault",
"name": "Vault + Agent Injector", "name": "Vault + Agent Injector",
"doc": "HashiCorp Vault — централизованная система управления секретами: учётными данными, ключами шифрования и сертификатами, с аудитом всех обращений. Работает по схеме «клиент аутентифицируется — получает токен, связанный с набором политик — обращается к секретам по путям — Vault проверяет права и записывает обращение в журнал независимо от исхода». Модульная архитектура позволяет подключать разные способы аутентификации и разные хранилища секретов, включая выдачу временных учётных данных к базам данных.\n\nЕдинственный механизм доставки секретов приложениям. Секреты не лежат в манифестах и не хранятся в Git: под получает их через sidecar Agent Injector, который аутентифицируется в Vault по service account пода и монтирует значения из пути вида secrets/data/apps/<приложение>/postgres. Ролью и service account служит имя приложения — соглашение единообразно для всех сервисов. Практическое следствие для чтения архитектуры: связи приложения с БД, брокером или S3 часто не видны в YAML, потому что реквизиты приходят из Vault. Трафик к Vault выведен из-под sidecar-прокси Istio отдельной аннотацией, иначе инициализация пода не проходит." "doc": "HashiCorp Vault — централизованная система управления секретами: учётными данными, ключами шифрования и сертификатами, с аудитом всех обращений. Работает по схеме «клиент аутентифицируется — получает токен, связанный с набором политик — обращается к секретам по путям — Vault проверяет права и записывает обращение в журнал независимо от исхода». Модульная архитектура позволяет подключать разные способы аутентификации и разные хранилища секретов, включая выдачу временных учётных данных к базам данных.\n\nЕдинственный механизм доставки секретов приложениям. Секреты не лежат в манифестах и не хранятся в Git: под получает их через sidecar Agent Injector, который аутентифицируется в Vault по service account пода и монтирует значения из пути вида secrets/data/apps/<приложение>/postgres. Ролью и service account служит имя приложения — соглашение единообразно для всех сервисов. Трафик к Vault выведен из-под sidecar-прокси Istio отдельной аннотацией, иначе инициализация пода не проходит."
}, },
{ {
"id": "ss-postgres", "id": "ss-postgres",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
{ {
"apps": [ "apps": [
"ams-sync",
"attachments", "attachments",
"auth-flow", "auth-flow",
"bim", "bim",
@ -28,6 +29,7 @@
"processing", "processing",
"projects", "projects",
"remarks", "remarks",
"resources",
"reviews", "reviews",
"rfi", "rfi",
"stamp-verification", "stamp-verification",
@ -37,6 +39,16 @@
"workspaces" "workspaces"
], ],
"matrix": { "matrix": {
"ams-sync": {
"postgres": false,
"rabbitmq": false,
"kafka": "manifest",
"camunda": false,
"s3": false,
"redis": false,
"vault": false,
"oidc": "manifest"
},
"attachments": { "attachments": {
"postgres": "manifest", "postgres": "manifest",
"rabbitmq": false, "rabbitmq": false,
@ -317,6 +329,16 @@
"vault": false, "vault": false,
"oidc": "doc" "oidc": "doc"
}, },
"resources": {
"postgres": "manifest",
"rabbitmq": false,
"kafka": false,
"camunda": false,
"s3": "manifest",
"redis": false,
"vault": "manifest",
"oidc": false
},
"reviews": { "reviews": {
"postgres": false, "postgres": false,
"rabbitmq": false, "rabbitmq": false,
@ -410,6 +432,7 @@
"reviews", "reviews",
"rfi", "rfi",
"stamp-verification", "stamp-verification",
"superset",
"transmittal", "transmittal",
"workspaces", "workspaces",
"zitadel" "zitadel"

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,127 @@
"""Строит граф межсервисных вызовов прикладного слоя.
py scan_calls.py <имя-кластера> <выход.json>
Три источника, по убыванию надёжности:
svc внутрикластерный адрес сервиса в манифестах вызывающего;
env переменная окружения вида <APP>_URL / _HOST / _BASE_URL в манифестах;
doc та же переменная, но найденная только в *ENDPOINTS.md / *CONFIGURATION.md.
Третий источник нужен потому, что значительная часть адресов приезжает из
k8s-секретов и в манифестах отсутствует на технологическом слое такой же
приём вскрыл четверть связей.
"""
import re, json, pathlib, sys, io, collections
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
if len(sys.argv) != 3:
sys.exit(__doc__)
CLUSTER, OUT = sys.argv[1], pathlib.Path(sys.argv[2])
ROOT = pathlib.Path(__file__).resolve().parents[2]
# Считаем развёрнутыми все приложения репозитория — см. комментарий в scan_contour.py
ALL = sorted(d.name for d in (ROOT / "apps").iterdir() if d.is_dir())
apps = ALL
# namespace -> приложение. Совпадают везде, кроме алиасов других контуров.
ALIASES = {
"proc": "processing", "workflow": "processing", "sarex-processing": "processing",
"orchestrator": "cde", "rfi-prod": "rfi",
"transmittals": "transmittal", "transmittal-api-stage": "transmittal",
"sarex": "django", "sarex-django": "django",
"sarex-documentations": "documentations",
"sarex-faas": "faas", "sarex-workspaces": "workspaces",
"sarex-attachments": "attachments", "planning": "pm",
}
# namespace технологических компонентов — они уже отражены на технологическом слое
INFRA_NS = {"postgresql", "rabbitmq", "minio", "kafka", "signoz", "redis",
"vault", "zitadel", "camunda", "istio-system", "insert-db-documentation"}
# В адресах встречаются суффиксы контура: documentations-prod, bim-api, eav-stage
NS_SUFFIX = ("-prod", "-stage", "-preprod", "-test", "-api")
def resolve(ns):
if ns in INFRA_NS:
return None
if ns in ALL:
return ns
if ns in ALIASES:
return ALIASES[ns]
for suf in NS_SUFFIX:
if ns.endswith(suf) and ns[:-len(suf)] in ALL:
return ns[:-len(suf)]
return None
# Адрес внутри кластера: <сервис>.<namespace> с необязательным .svc.cluster.local
# и портом. Короткая форма (backend-svc.django:8000) встречается наравне с полной.
HOST = re.compile(r"https?://([a-z0-9][a-z0-9-]*)\.([a-z0-9][a-z0-9-]*)"
r"(?:\.svc(?:\.cluster\.local)?)?(?::\d+)?(?![a-z0-9.-])")
TLD = {"ru", "com", "tech", "io", "lan", "net", "org", "dev", "local", "svc"}
def envpat(app):
u = app.upper().replace("-", "_")
return re.compile(rf"\b{u}(?:_[A-Z0-9]+)*_(?:URL|HOST|BASE_URL|INTERNAL_URL|ENDPOINT)\b")
PATS = {a: envpat(a) for a in ALL}
# DJANGO_POSTGRES_HOST, ISSUES_DB_HOST — адрес базы данных сервиса, а не самого
# сервиса. Такая переменная означает доступ в чужую БД, а не вызов по HTTP.
INFRA_SEG = re.compile(r"_(?:DB|POSTGRES|PG|REDIS|KAFKA|RABBITMQ|AMQP|S3|SMTP"
r"|MONGO|CLICKHOUSE|VAULT|BROKER|CACHE)_")
def env_hit(pat, blob):
return any(not INFRA_SEG.search(m.group(0)) for m in pat.finditer(blob))
def read(paths):
return "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in paths)
edges, unresolved = {}, collections.Counter()
for caller in apps:
d = ROOT / "apps" / caller
yaml_blob = read([f for sub in ("base", CLUSTER) if (d / sub).is_dir()
for f in (d / sub).rglob("*.yaml")])
doc_blob = read(sorted(d.glob("*ENDPOINTS*.md")) + sorted(d.glob("*CONFIGURATION*.md")))
for svc, ns in set(HOST.findall(yaml_blob)):
if ns in TLD:
continue
callee = resolve(ns)
if callee is None:
if ns not in INFRA_NS:
unresolved[f"{svc}.{ns}"] += 1
continue
if callee != caller:
edges[(caller, callee)] = "svc"
for callee, pat in PATS.items():
if callee == caller or (caller, callee) in edges:
continue
if env_hit(pat, yaml_blob):
edges[(caller, callee)] = "env"
elif env_hit(pat, doc_blob):
edges[(caller, callee)] = "doc"
inbound = collections.Counter(c for _, c in edges)
outbound = collections.Counter(c for c, _ in edges)
by_src = collections.Counter(edges.values())
print(f"приложений в контуре: {len(apps)} связей: {len(edges)}")
print("по источнику:", dict(by_src))
print()
print("самые вызываемые (входящие связи):")
for a, n in inbound.most_common(12):
print(f" {a:18s} {n:2d} исходящих: {outbound[a]}")
print()
print("самые зависимые (исходящие связи):")
for a, n in outbound.most_common(8):
print(f" {a:18s} {n:2d}")
isolated = [a for a in apps if not inbound[a] and not outbound[a]]
print(f"\nбез связей: {', '.join(isolated) if isolated else 'нет'}")
if unresolved:
print(f"\nне сопоставлены с приложением: {dict(unresolved.most_common(8))}")
OUT.write_text(json.dumps({
"apps": apps,
"edges": [{"from": c, "to": t, "src": s} for (c, t), s in sorted(edges.items())],
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\nзаписано: {OUT}")

View File

@ -15,10 +15,13 @@ if len(sys.argv) != 3:
CLUSTER, OUT = sys.argv[1], pathlib.Path(sys.argv[2]) CLUSTER, OUT = sys.argv[1], pathlib.Path(sys.argv[2])
ROOT = pathlib.Path(__file__).resolve().parents[2] ROOT = pathlib.Path(__file__).resolve().parents[2]
kust = (ROOT / "clusters" / CLUSTER / "kustomization.yaml").read_text(encoding="utf-8") # Считаем развёрнутыми все приложения репозитория. Имя кластера определяет,
apps = sorted(set(re.findall(r"\.\./\.\./apps/([a-z0-9-]+)/" + re.escape(CLUSTER), kust))) # какой оверлей читать поверх base, но состав приложений им не ограничивается:
if not apps: # часть сервисов работает в контуре, не будучи заявленной в kustomization.
sys.exit(f"в clusters/{CLUSTER}/kustomization.yaml не найдено ни одного приложения") apps = sorted(d.name for d in (ROOT / "apps").iterdir() if d.is_dir())
with_overlay = [a for a in apps if (ROOT / "apps" / a / CLUSTER).is_dir()]
print(f"приложений: {len(apps)}, из них с оверлеем {CLUSTER}: {len(with_overlay)} "
f"(остальные читаются только из base)\n")
MARKERS = { MARKERS = {
"postgres": [r"POSTGRES_[A-Z]", r"DB__HOST", r"postgresql\.postgresql\.svc", "postgres": [r"POSTGRES_[A-Z]", r"DB__HOST", r"postgresql\.postgresql\.svc",