Files
tf_provider/docs/diagrams/render_infra_diagram.py
T

304 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")))