diff --git a/TOOLS/render_infra_diagram.py b/TOOLS/render_infra_diagram.py new file mode 100644 index 0000000..8f85e68 --- /dev/null +++ b/TOOLS/render_infra_diagram.py @@ -0,0 +1,302 @@ +#!/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), + ([(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'', + f''] + + def esc(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + 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'') + 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'{esc(s)}') + 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'') + (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'') + elif kind == "dot": + x, y, r, color = a + out.append(f'') + + out.append("") + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(out)) + return path + + +if __name__ == "__main__": + base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "docs") + base = os.path.normpath(base) + 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"))) diff --git a/docs/infra_services_diagram.png b/docs/infra_services_diagram.png new file mode 100644 index 0000000..40154c0 Binary files /dev/null and b/docs/infra_services_diagram.png differ diff --git a/docs/infra_services_diagram.svg b/docs/infra_services_diagram.svg new file mode 100644 index 0000000..22cd070 --- /dev/null +++ b/docs/infra_services_diagram.svg @@ -0,0 +1,142 @@ + + + + +Шаг 1. Тенант +базовый уровень изоляции + + +Шаг 2. Пул ресурсов +CPU / RAM / Storage + + +Шаг 3. Сетевой периметр +маршрутизация и балансировка + + +Шаг 4. Среда запуска +контейнер и внешние адреса + + +Шаг 5. Потребители +что разворачивается в итоге + + +Организация +в Cloud Director +Сервис 19 · vcOrg + +Типы: iaas / saas + + +Виртуальный +датацентр (vDC) +Сервис 21 · vcVdc + +cpuGuaranteed: 0% / 50% / 80% + +cpuAllocated, memAllocated + +storageConfig + +здесь выделяются CPU / RAM / Диск + + +Сетевой шлюз +периметра (Edge) +Сервис 22 · vcNsxt + +AVI Load Balancer + +routed-сеть 10.10.102.0/24 + +SNAT настраивается операцией modify + + +Каталог ВМ (vApp) +Сервис 26 · vapp + +логический контейнер для ВМ + + +Внешние IP +Сервис 25 + +выделение: vcOrg + +NAT: сервис 25 + +квота IP (ipSpace) + +здесь выделяются IP + + +Виртуальная машина +Сервис 28 · vc_vm_v3 + +vmCpu · vmRam · vmDisk + + +Kubernetes кластер +Штурвал +Сервис 150 + +sizingPolicy · sizingDisk + +· count + +требует ALB + VS ≥ 3 + + +organizationUid + + +vdcUid + + +vdcUid + + +nsxtUid + + +vappUid + + +vdcUid + + +nsxtUid + + +modify: vIPConfigure + + +modify: SNAT + + +внешний IP + + +≥ 3 адреса +> Порядок создания: Организация → vDC → Edge → (vApp + внешние IP) → ВМ / Штурвал. +> SNAT — не отдельный сервис, а операция modify у Edge. +> vDC и Edge могут быть переиспользованы существующие. + + +Сплошная линия — обязательная зависимость +(ссылка на UUID существующего инстанса) + + +Пунктир — выделение ресурса / операция modify + +Тенант + +Ресурсы + +Сеть + +Потребители + +Адреса + \ No newline at end of file