docs: move diagram artifacts to docs/diagrams, add vApp-IP precondition link and creation guide

This commit is contained in:
Repinoid
2026-09-26 19:30:41 +03:00
parent 6791e8fb5f
commit a366f47eff
8 changed files with 137 additions and 4 deletions
+129
View File
@@ -0,0 +1,129 @@
# Схема зависимостей облачных сервисов (docs/diagrams)
Папка содержит все артефакты схемы: исходники, отрендеренные изображения и
генератор. Здесь же описан процесс создания и результаты сверки с контрактами.
## 1. Состав папки
| Файл | Что это | Чем создаётся |
|---|---|---|
| `infra_services_flow.mmd` | исходник Mermaid-версии схемы | правится вручную |
| `infra_services_flow.svg` | вектор Mermaid-версии | рендер через `mermaid.ink` |
| `infra_services_flow.png` | растр Mermaid-версии | рендер через `mermaid.ink` |
| `render_infra_diagram.py` | генератор стилизованной схемы (PNG + SVG) | чистый Python + Pillow |
| `infra_services_diagram.png` | стилизованная схема, растр 1600×720 (рендер 2x + LANCZOS) | `render_infra_diagram.py` |
| `infra_services_diagram.svg` | стилизованная схема, вектор | `render_infra_diagram.py` |
Две версии существуют одновременно: Mermaid — для правок текстом и встраивания
в Markdown; стилизованная — как готовая картинка для показа.
## 2. Источник данных
Единственный источник фактов — YAML-контракты сервисов:
```
generated/dev/resources_yaml/*.yaml
```
Из них берутся:
- `service_id`, `service_display_name` — идентичность сервиса;
- `operations[].params[].ref_svc_id` — **прямая зависимость** от другого сервиса;
- `operations[].params[].depends_on` — зависимость порядка внутри операции;
- `value_list`, `default`, `man`, `descr` — значения на блоках (CPU, сети и т.п.);
- `service_man` — чек-листы и порядок действий, заданные самой платформой.
Terraform-файлы `DEV_STAND/*` **не используются как источник** для этой схемы —
они лишь один из примеров потребления.
## 3. Как перегенерировать
```bash
cd /home/naeel/TF/tf_provider
python3 docs/diagrams/render_infra_diagram.py
```
Скрипт пишет `infra_services_diagram.png` и `.svg` **в свою же папку**
(`docs/diagrams/`), путь вычисляется от расположения файла.
Требования: `python3` с `Pillow` и шрифты DejaVu
(`/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf`, `...-Bold.ttf`).
Сторонние библиотеки для SVG не нужны — SVG формируется текстом.
Для Mermaid-версии нужен рендерер (`mermaid.ink`, `mmdc` или предпросмотр
Markdown в VS Code); в этом репозитории использовался `mermaid.ink` через
`python3` + `urllib`.
## 4. Структура картинки
Пять колонок = этапы создания, слева направо:
1. **Тенант** — Организация в Cloud Director (19)
2. **Пул ресурсов** — Виртуальный датацентр (21)
3. **Сетевой периметр** — Сетевой шлюз периметра / Edge (22)
4. **Среда запуска** — Каталог ВМ / vApp (26) + Внешние IP (vcOrg modify / 25)
5. **Потребители** — Виртуальная машина (28) и Kubernetes кластер Штурвал (150)
### Обозначения
- **Сплошная стрелка** — обязательная зависимость, ссылка на UUID существующего
инстанса (`ref_svc_id`).
- **Пунктирная стрелка** — выделение ресурса, операция `modify` или предусловие.
- Направление стрелки всегда **от зависимости к потребителю**: `Org → vDC`,
`vApp → VM`. То есть стрелка отвечает на вопрос «что должно существовать до».
- Цвет колонки кодирует этап, цвет рамки блока — группу (тенант / ресурсы / сеть /
потребители / адреса). Легенда — в правом нижнем углу картинки.
### Бейджи точек выделения ресурсов
| Бейдж | Где на схеме | Что означает |
|---|---|---|
| «здесь выделяются CPU / RAM / Диск» | блок vDC (21) | `cpuAllocated`, `memAllocated`, `storageConfig` |
| «SNAT настраивается операцией modify» | блок Edge (22) | SNAT — не сервис, а `modify` у Edge |
| «здесь выделяются IP» | блок «Внешние IP» | выделение адресов на организации |
| «требует ALB + VS ≥ 3» | блок Штурвала (150) | обязательное условие из чек-листа |
## 5. Результат сверки с YAML
Проверено построчно (коммит `6791e8f`, дополнение — связь `vApp → Внешние IP`).
| Утверждение на схеме | Источник |
|---|---|
| Организация: типы `iaas / saas` | `19_vc_org.yaml:47-53` |
| Выделение IP через `vcOrg modify (vIPConfigure)` | `19_vc_org.yaml:115` |
| Для внешних IP нужны vDC и Edge | `19_vc_org.yaml:112` (`man`) |
| vDC: `cpuGuaranteed: 0% / 50% / 80%` | `21_vc_vdc.yaml:71-78` |
| vDC: `cpuAllocated`, `memAllocated`, `storageConfig` | `21_vc_vdc.yaml:49,83,93` |
| Edge: AVI Load Balancer | `22_vc_nsxt.yaml` (`needEnableAVI`) |
| Edge: routed-сеть `10.10.102.0/24` | `22_vc_nsxt.yaml:5` |
| Edge: SNAT настраивается операцией `modify` | `22_vc_nsxt.yaml` (`modify → ipSpaceName`) |
| vApp → vDC (`vdcUid`), Edge (`nsxtUid`) | `26_vapp.yaml:38,56` |
| ВМ → vApp (`vappUid`), `vmCpu · vmRam · vmDisk` | `28_vc_vm_v3.yaml:39,49,59,69` |
| Штурвал: `vdcUid`, `nsxtUid`; `sizingPolicy · sizingDisk · count` | `150_k8s_sthutrval_cluster.yaml:41,47,132,141,148` |
| Публичные IP: NAT/DNAT через сервис 25 | `25_vcexternalip.yaml:41,57,100` |
| **Публичные IP требуют «Каталог» (vApp)** | `25_vcexternalip.yaml:5` (`service_man`) |
| Штурвал: обязательны ALB и VS ≥ 3; суммарно 3 внешних адреса | `150_k8s_sthutrval_cluster.yaml` (`service_man`, чек-лист) |
## 6. Известные ограничения
1. **Переиспользование инстансов** («vDC и Edge могут быть переиспользованы»)
не написано в YAML буквально — это следствие семантики ссылок на UUID
существующего инстанса. Уверенность средняя, не высокая.
2. Блок «Внешние IP» объединяет два механизма: выделение адресов на организации
(`vcOrg modify`) и правила NAT у сервиса 25 (`vcexternalip`). Они подписаны
раздельно внутри блока, но нарисованы одной вершиной.
3. Остальные 33 сервиса каталога на схеме не показаны — отрисованы только
инфраструктурные цепочки и их прямые потребители.
4. Схема — компоновка по контрактам, а не результат наблюдения за развёртыванием.
Фактический порядок и доступность нужно подтверждать отдельно.
## 7. История
| Дата | Изменение |
|---|---|
| 2026-09-26 | Создана Mermaid-версия (`infra_services_flow.*`) |
| 2026-09-26 | Исправлена связь «вычислительный пул»: от vDC, а не от внешних IP |
| 2026-09-26 | Добавлен генератор стилизованной схемы (`render_infra_diagram.py`) |
| 2026-09-26 | Убрана дублирующая подпись `modify: SNAT` у блока Edge |
| 2026-09-26 | Сверка с YAML; добавлена связь `vApp → Внешние IP` — предусловие сервиса 25 |
| 2026-09-26 | Все артефакты перенесены в `docs/diagrams/` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

@@ -113,6 +113,9 @@
<path d="M870 320 L870 400 L1140 400 L1140 395" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1140,395 1143.78,404.0 1136.22,404.0" fill="#7A8794"/>
<text x="880" y="386" font-size="10" fill="#7A8794" dominant-baseline="hanging">modify: SNAT</text>
<path d="M1050 210 L1050 245" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1050,245 1046.22,236.0 1053.78,236.0" fill="#7A8794"/>
<text x="1060" y="218" font-size="10" fill="#7A8794" dominant-baseline="hanging">требует Каталог</text>
<path d="M1183 270 L1255 270 L1255 160 L1327 160" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1327,160 1318.0,163.78 1318.0,156.22" fill="#7A8794"/>
<text x="1187" y="222" font-size="10" fill="#7A8794" dominant-baseline="hanging">внешний IP</text>
@@ -128,7 +131,7 @@
<text x="1052" y="613" font-size="10" fill="#7A8794" dominant-baseline="hanging">(ссылка на UUID существующего инстанса)</text>
<path d="M980 650 L1040 650" fill="none" stroke="#7A8794" stroke-width="2" stroke-dasharray="9 6" stroke-linejoin="round"/>
<polygon points="1040,650 1031.0,653.78 1031.0,646.22" fill="#7A8794"/>
<text x="1052" y="641" font-size="11" fill="#2E3A45" dominant-baseline="hanging">Пунктир — выделение ресурса / операция modify</text>
<text x="1052" y="641" font-size="11" fill="#2E3A45" dominant-baseline="hanging">Пунктир — выделение ресурса / modify / предусловие</text>
<rect x="980" y="684" width="12" height="12" rx="3" fill="#1565C0"/>
<text x="996" y="682" font-size="10" fill="#2E3A45" dominant-baseline="hanging">Тенант</text>
<rect x="1038" y="684" width="12" height="12" rx="3" fill="#F57C00"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Генератор схемы зависимостей облачных сервисов (PNG + SVG).
Содержание подтверждено YAML-контрактами generated/dev/resources_yaml.
PNG рисуется в 2x и уменьшается (сглаживание), SVG — вектор.
"""
import os
from PIL import Image, ImageDraw, ImageFont
W, H = 1600, 720
SS = 2 # supersample
FONT_R = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
FONT_B = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
DARK = "#1A1A1A"
GRAY = "#5F6B76"
MUTED = "#7A8794"
TEXT = "#2E3A45"
RED = "#C62828"
LINE = "#455A64"
COLS = [
(16, "Шаг 1. Тенант", "базовый уровень изоляции", "#1565C0", "#E8F3FB"),
(340, "Шаг 2. Пул ресурсов", "CPU / RAM / Storage", "#E65100", "#FDF1E3"),
(664, "Шаг 3. Сетевой периметр", "маршрутизация и балансировка", "#4527A0", "#EEEAF8"),
(988, "Шаг 4. Среда запуска", "контейнер и внешние адреса", "#EF6C00", "#FDF4E3"),
(1312, "Шаг 5. Потребители", "что разворачивается в итоге", "#2E7D32", "#E9F5EA"),
]
COL_W = 270
# блоки: (x, y, w, h, заголовок-строки, подпись, строки, бейдж, рамка, фон, пунктир)
BLOCKS = [
(31, 150, 240, 150, ["Организация", "в Cloud Director"], "Сервис 19 · vcOrg",
["Типы: iaas / saas"], None, "#0288D1", "#FFFFFF", False),
(355, 130, 240, 230, ["Виртуальный", "датацентр (vDC)"], "Сервис 21 · vcVdc",
["cpuGuaranteed: 0% / 50% / 80%", "cpuAllocated, memAllocated", "storageConfig"],
"здесь выделяются CPU / RAM / Диск", "#F57C00", "#FFF3E0", False),
(679, 120, 240, 200, ["Сетевой шлюз", "периметра (Edge)"], "Сервис 22 · vcNsxt",
["AVI Load Balancer", "routed-сеть 10.10.102.0/24"],
"SNAT настраивается операцией modify", "#5E35B1", "#EDE7F6", False),
(1003, 100, 240, 110, ["Каталог ВМ (vApp)"], "Сервис 26 · vapp",
["логический контейнер для ВМ"], None, "#F57C00", "#FFF3E0", False),
(1003, 245, 180, 150, ["Внешние IP"], "Сервис 25",
["выделение: vcOrg", "NAT: сервис 25", "квота IP (ipSpace)"],
"здесь выделяются IP", "#9E9E9E", "#FAFAFA", True),
(1327, 100, 240, 120, ["Виртуальная машина"], "Сервис 28 · vc_vm_v3",
["vmCpu · vmRam · vmDisk"], None, "#2E7D32", "#E8F5E9", False),
(1327, 255, 240, 170, ["Kubernetes кластер", "Штурвал"], "Сервис 150",
["sizingPolicy · sizingDisk", "· count"], "требует ALB + VS ≥ 3",
"#2E7D32", "#E8F5E9", False),
]
# связи: (точки, цвет, толщина, пунктир, подпись, позиция подписи, вертикальная)
EDGES = [
([(271, 225), (355, 225)], LINE, 2, False, "organizationUid", (313, 262), True),
([(595, 200), (679, 200)], LINE, 2, False, "vdcUid", (637, 186), False),
([(500, 130), (500, 92), (961, 92), (961, 140), (1003, 140)], LINE, 2, False,
"vdcUid", (706, 96), False),
([(919, 180), (1003, 180)], LINE, 2, False, "nsxtUid", (961, 166), False),
([(1243, 140), (1327, 140)], LINE, 2, False, "vappUid", (1285, 126), False),
([(500, 360), (500, 470), (1447, 470), (1447, 425)], LINE, 2, False,
"vdcUid", (1180, 456), False),
([(799, 320), (799, 505), (1467, 505), (1467, 425)], LINE, 2, False,
"nsxtUid", (1180, 491), False),
([(151, 300), (151, 540), (1050, 540), (1050, 395)], MUTED, 2, True,
"modify: vIPConfigure", (161, 526), False),
([(870, 320), (870, 400), (1140, 400), (1140, 395)], MUTED, 2, True,
"modify: SNAT", (880, 386), False),
([(1050, 210), (1050, 245)], MUTED, 2, True, "требует Каталог", (1060, 218), False),
([(1183, 270), (1255, 270), (1255, 160), (1327, 160)], MUTED, 2, True,
"внешний IP", (1187, 222), False),
([(1183, 350), (1327, 350)], RED, 2, True, "≥ 3 адреса", (1187, 330), False),
]
CAPTION = [
"Порядок создания: Организация → vDC → Edge → (vApp + внешние IP) → ВМ / Штурвал.",
"SNAT — не отдельный сервис, а операция modify у Edge.",
"vDC и Edge могут быть переиспользованы существующие.",
]
def op_rect(x, y, w, h, fill, outline, lw=2, r=10, dash=False):
return ("rect", (x, y, w, h, fill, outline, lw, r, dash))
def op_text(x, y, s, size, color, bold=False, anchor="lt", vertical=False):
return ("text", (x, y, s, size, color, bold, anchor, vertical))
def op_path(pts, color, lw, dash):
return ("path", (pts, color, lw, dash))
def op_dot(x, y, r, color):
return ("dot", (x, y, r, color))
def build():
ops = []
for cx, title, sub, accent, tint in COLS:
ops.append(op_rect(cx, 8, COL_W, 552, tint, "#FFFFFF", 0, 12))
ops.append(op_rect(cx, 14, COL_W, 44, accent, accent, 0, 8))
ops.append(op_text(cx + COL_W / 2, 25, title, 15, "#FFFFFF", True, "mt"))
ops.append(op_text(cx + COL_W / 2, 68, sub, 12, MUTED, False, "mt"))
for x, y, w, h, titles, sub, lines, badge, border, fill, dashed in BLOCKS:
ops.append(op_rect(x, y, w, h, fill, border, 2, 10, dashed))
ops.append(op_rect(x + 12, y + 12, 22, 22, border, border, 0, 5))
tx = x + 42
ty = y + 12
for i, t in enumerate(titles):
ops.append(op_text(tx, ty + i * 20, t, 15, DARK, True))
sy = ty + len(titles) * 20
ops.append(op_text(tx, sy, sub, 11, GRAY))
ly = sy + 22
for ln in lines:
ops.append(op_dot(x + 20, ly + 7, 2.4, border))
ops.append(op_text(x + 30, ly, ln, 10, TEXT))
ly += 20
if badge:
ops.append(op_rect(x + 12, y + h - 42, w - 24, 30, "#FFFFFF", border, 1, 6))
ops.append(op_text(x + w / 2, y + h - 32, badge, 10, border, False, "mt"))
for pts, color, lw, dash, label, pos, vertical in EDGES:
ops.append(op_path(pts, color, lw, dash))
ops.append(op_text(pos[0], pos[1], label, 10, color, False,
"lt" if not vertical else "lt", vertical))
for i, s in enumerate(CAPTION):
ops.append(op_text(24, 590 + i * 26, "> " + s, 14, TEXT))
ops.append(op_path([(980, 606), (1040, 606)], LINE, 2, False))
ops.append(op_text(1052, 597, "Сплошная линия — обязательная зависимость", 11, TEXT))
ops.append(op_text(1052, 613, "(ссылка на UUID существующего инстанса)", 10, MUTED))
ops.append(op_path([(980, 650), (1040, 650)], MUTED, 2, True))
ops.append(op_text(1052, 641, "Пунктир — выделение ресурса / modify / предусловие", 11, TEXT))
swatches = [("#1565C0", "Тенант"), ("#F57C00", "Ресурсы"), ("#5E35B1", "Сеть"),
("#2E7D32", "Потребители"), ("#9E9E9E", "Адреса")]
sx = 980
for color, name in swatches:
ops.append(op_rect(sx, 684, 12, 12, color, color, 0, 3))
ops.append(op_text(sx + 16, 682, name, 10, TEXT))
sx += 20 + int(len(name) * 6.4)
return ops
def accent_of(border):
return border
def dash_segments(p1, p2, on=9, off=6):
(x1, y1), (x2, y2) = p1, p2
segs, total = [], ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
if total == 0:
return segs
ux, uy = (x2 - x1) / total, (y2 - y1) / total
pos = 0.0
while pos < total:
end = min(pos + on, total)
segs.append(((x1 + ux * pos, y1 + uy * pos), (x1 + ux * end, y1 + uy * end)))
pos = end + off
return segs
def arrow_head(d, tip, tail, color, size=9):
(tx, ty), (px, py) = tip, tail
dx, dy = tx - px, ty - py
n = (dx * dx + dy * dy) ** 0.5 or 1
ux, uy = dx / n, dy / n
bx, by = tx - ux * size, ty - uy * size
ox, oy = -uy * size * 0.42, ux * size * 0.42
d.polygon([(tx, ty), (bx + ox, by + oy), (bx - ox, by - oy)], fill=color)
def draw_polyline(d, pts, color, lw, dash):
for a, b in zip(pts, pts[1:]):
segs = dash_segments(a, b) if dash else [(a, b)]
for s in segs:
d.line([s[0], s[1]], fill=color, width=lw)
if len(pts) >= 2:
tail = pts[-2]
tip = pts[-1]
dx, dy = tip[0] - tail[0], tip[1] - tail[1]
n = (dx * dx + dy * dy) ** 0.5 or 1
shrink = 9
tip2 = (tip[0] - dx / n * shrink, tip[1] - dy / n * shrink)
d.line([tail, tip2], fill=color, width=lw)
arrow_head(d, tip, tail, color)
def render_png(ops, path):
img = Image.new("RGB", (W * SS, H * SS), "#FFFFFF")
d = ImageDraw.Draw(img)
fonts = {}
def fnt(size, bold):
key = (size, bold)
if key not in fonts:
fonts[key] = ImageFont.truetype(FONT_B if bold else FONT_R, size * SS)
return fonts[key]
for kind, a in ops:
if kind == "rect":
x, y, w, h, fill, outline, lw, r, dashed = a
box = [x * SS, y * SS, (x + w) * SS, (y + h) * SS]
if dashed:
d.rounded_rectangle(box, radius=r * SS, fill=fill)
for a1, b1 in [((x, y), (x + w, y)), ((x + w, y), (x + w, y + h)),
((x + w, y + h), (x, y + h)), ((x, y + h), (x, y))]:
for s in dash_segments((a1[0] * SS, a1[1] * SS), (b1[0] * SS, b1[1] * SS),
on=8 * SS, off=6 * SS):
d.line([s[0], s[1]], fill=outline, width=max(1, lw * SS))
else:
d.rounded_rectangle(box, radius=r * SS, fill=fill,
outline=outline if lw else None,
width=max(1, lw * SS) if lw else 0)
elif kind == "text":
x, y, s, size, color, bold, anchor, vertical = a
f = fnt(size, bold)
if vertical:
bbox = f.getbbox(s)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tmp = Image.new("RGBA", (tw + 4, th + 4), (0, 0, 0, 0))
ImageDraw.Draw(tmp).text((-bbox[0] + 2, -bbox[1] + 2), s, font=f, fill=color)
tmp = tmp.rotate(90, expand=True)
img.paste(tmp, (int(x * SS - tmp.width / 2), int(y * SS - tmp.height / 2)), tmp)
else:
d.text((x * SS, y * SS), s, font=f, fill=color, anchor=anchor)
elif kind == "path":
pts, color, lw, dash = a
draw_polyline(d, [(px * SS, py * SS) for px, py in pts], color,
max(1, lw * SS), dash)
elif kind == "dot":
x, y, r, color = a
d.ellipse([(x - r) * SS, (y - r) * SS, (x + r) * SS, (y + r) * SS], fill=color)
img = img.resize((W, H), Image.LANCZOS)
img.save(path)
return path
def render_svg(ops, path):
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
f'viewBox="0 0 {W} {H}" font-family="DejaVu Sans, Verdana, sans-serif">',
f'<rect width="{W}" height="{H}" fill="#FFFFFF"/>']
def esc(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
for kind, a in ops:
if kind == "rect":
x, y, w, h, fill, outline, lw, r, dashed = a
stroke = f' stroke="{outline}" stroke-width="{lw}"' if lw else ""
dash = ' stroke-dasharray="9 6"' if dashed else ""
out.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" '
f'fill="{fill}"{stroke}{dash}/>')
elif kind == "text":
x, y, s, size, color, bold, anchor, vertical = a
weight = ' font-weight="bold"' if bold else ""
if vertical:
ta = ' text-anchor="middle" dominant-baseline="middle"'
transform = f' transform="rotate(-90 {x} {y})"'
else:
ta = (' text-anchor="middle" dominant-baseline="hanging"' if anchor == "mt"
else ' dominant-baseline="hanging"')
transform = ""
out.append(f'<text x="{x}" y="{y}" font-size="{size}" fill="{color}"'
f'{weight}{ta}{transform}>{esc(s)}</text>')
elif kind == "path":
pts, color, lw, dash = a
d = " ".join(("M" if i == 0 else "L") + f"{p[0]} {p[1]}"
for i, p in enumerate(pts))
dash = ' stroke-dasharray="9 6"' if dash else ""
out.append(f'<path d="{d}" fill="none" stroke="{color}" '
f'stroke-width="{lw}"{dash} stroke-linejoin="round"/>')
(tx, ty), (px, py) = pts[-1], pts[-2]
dx, dy = tx - px, ty - py
n = (dx * dx + dy * dy) ** 0.5 or 1
ux, uy = dx / n, dy / n
s_ = 9
bx, by = tx - ux * s_, ty - uy * s_
ox, oy = -uy * s_ * 0.42, ux * s_ * 0.42
out.append(f'<polygon points="{tx},{ty} {bx + ox},{by + oy} '
f'{bx - ox},{by - oy}" fill="{color}"/>')
elif kind == "dot":
x, y, r, color = a
out.append(f'<circle cx="{x}" cy="{y}" r="{r}" fill="{color}"/>')
out.append("</svg>")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(out))
return path
if __name__ == "__main__":
# Файлы кладём рядом со скриптом: docs/diagrams/
base = os.path.dirname(os.path.abspath(__file__))
ops = build()
print(render_png(ops, os.path.join(base, "infra_services_diagram.png")))
print(render_svg(ops, os.path.join(base, "infra_services_diagram.svg")))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB