iac/docs/architecture/build_archimate.py

380 lines
18 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.

"""Собирает .archimate (нативный формат Archi) из профиля контура и матрицы связей.
py build_archimate.py <профиль.json> <матрица.json> <выход.archimate> [ещё...]
Необязательные аргументы распознаются по расширению: .json — граф межсервисных
вызовов (scan_calls.py), .md — описания компонентов прикладного слоя. Файлов
с описаниями может быть несколько, каждый следующий переопределяет предыдущий:
в модель идёт короткая версия (app-summaries.md), развёрнутая (app-docs.md)
остаётся справочником.
Все имена, зависящие от конкретной инсталляции (кластер, узлы, домены, реестр),
приходят из профиля — в самом скрипте их нет. См. contour-profile.example.json.
"""
import json, pathlib, sys, io, re
from xml.sax.saxutils import escape, quoteattr
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
if len(sys.argv) < 4:
sys.exit(__doc__)
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"))
OUT = pathlib.Path(sys.argv[3])
def parse_app_docs(path):
"""Разбирает app-docs.md: заголовок `## <id>` -> текст до следующего заголовка."""
blocks = re.split(r"^## ", path.read_text(encoding="utf-8"), flags=re.M)[1:]
out = {}
for b in blocks:
head, _, body = b.partition("\n")
out[head.strip()] = body.strip()
return out
CALLS, APPDOCS = None, {}
for extra in sys.argv[4:]:
p = pathlib.Path(extra)
if p.suffix.lower() == ".md":
APPDOCS.update(parse_app_docs(p))
else:
CALLS = json.loads(p.read_text(encoding="utf-8"))
elements, relations, _rc = [], [], [0]
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=""):
elements.append((folder, eid, etype, name, doc)); return eid
def R(rtype, src, tgt):
_rc[0] += 1
rid = f"rel-{_rc[0]:03d}"
relations.append((rid, rtype, src, tgt))
REL_INDEX[(rtype, src, tgt)] = rid
return rid
# ---------------------------------------------------------------- технологический слой
T = "technology"
nd_cluster = el(T, "nd-cluster", "Node", PROFILE["cluster"]["name"], PROFILE["cluster"].get("doc", ""))
for s in PROFILE["software"]:
el(T, s["id"], "SystemSoftware", s["name"], s.get("doc", ""))
R("Composition", nd_cluster, s["id"])
cam_ids = []
for n in PROFILE.get("camunda_sub", []):
if isinstance(n, str):
n = {"name": n}
cid = "ss-cam-" + n["name"].lower().replace(" ", "-")
el(T, cid, "SystemSoftware", n["name"], n.get("doc", ""))
R("Composition", "ss-camunda", cid); cam_ids.append(cid)
def serves_of(x):
"""serves — id обслуживаемого элемента, либо список: у каталога предприятия
их несколько (провайдер идентификации, брокер, служба федерации)."""
s = x.get("serves")
return [] if not s else ([s] if isinstance(s, str) else list(s))
for x in PROFILE["external"]:
el(T, x["id"], "Node", x["name"], x.get("doc", ""))
for a in x.get("artifacts", []):
el(T, a["id"], "Artifact", a["name"])
R("Composition", x["id"], a["id"])
for tgt in serves_of(x):
R("Serving", x["id"], nd_cluster if tgt == "cluster" else tgt)
fn = PROFILE["frontend_node"]
el(T, fn["id"], "Node", fn["name"], fn.get("doc", ""))
R("Assignment", fn["id"], fn["assigned_to"])
for ts in PROFILE["services"]:
el(T, ts["id"], "TechnologyService", ts["name"], ts.get("doc", ""))
for r in ts["realizers"]:
R("Realization", r, ts["id"])
if ts.get("serves_software"):
R("Serving", ts["id"], ts["serves_software"])
# ---------------------------------------------------------------- прикладной слой
app_id = {a: "ac-" + a for a in DATA["apps"]}
for a in DATA["apps"]:
el("application", app_id[a], "ApplicationComponent", a,
APPDOCS.get(a, f"apps/{a}/<contour>/"))
consumers = {}
for ts in PROFILE["services"]:
key = ts.get("key")
if not key:
consumers[ts["id"]] = []; continue
if key == "istio":
lst = [a for a in DATA["apps"] if a in DATA["exposed"]]
else:
lst = [a for a in DATA["apps"] if DATA["matrix"][a].get(key)]
consumers[ts["id"]] = lst
for a in lst:
R("Serving", ts["id"], app_id[a])
# ---------------------------------------------------------------- представления
views, _oc = [], [0]
def view(name, doc=""):
v = {"id": f"view-{len(views)+1}", "name": name, "doc": doc, "objects": []}
views.append(v); return v
def obj(v, eid, x, y, w, h, parent=None):
_oc[0] += 1
o = {"id": f"do-{_oc[0]:04d}", "el": eid, "b": (x, y, w, h), "children": [], "src": [], "tgt": []}
(parent["children"] if parent else v["objects"]).append(o)
return o
def conn(rtype, so, to):
key = (rtype, so["el"], to["el"])
if key not in REL_INDEX:
raise KeyError(f"нет отношения в модели: {key}")
_oc[0] += 1
cid = f"cn-{_oc[0]:04d}"
so["src"].append({"id": cid, "rel": REL_INDEX[key], "tgt": to["id"]})
to["tgt"].append(cid)
SERVICES = PROFILE["services"]
SW = {s["id"]: s for s in PROFILE["software"]}
v1 = view("T1. Технологический слой",
"Что развёрнуто в кластере и какие технологические сервисы это даёт. "
"Приложения-потребители вынесены в отдельные представления T2 и далее.")
o_ts = {ts["id"]: obj(v1, ts["id"], 20 + i * 165, 30, 150, 60) for i, ts in enumerate(SERVICES)}
o_cluster = obj(v1, nd_cluster, 20, 140, 1270, 400)
o_ss, ids = {}, [s["id"] for s in PROFILE["software"]]
for i, sid in enumerate(ids[:5]):
o_ss[sid] = obj(v1, sid, 20 + i * 240, 45, 220, 70, o_cluster)
for i, sid in enumerate(ids[5:9]):
o_ss[sid] = obj(v1, sid, 20 + i * 240, 145, 220, 70, o_cluster)
if len(ids) > 9:
o_ss[ids[9]] = obj(v1, ids[9], 20, 245, 1230, 130, o_cluster)
for i, cid in enumerate(cam_ids):
obj(v1, cid, 10 + i * 174, 50, 162, 60, o_ss[ids[9]])
o_ext, ext_y = {}, 140
for x in PROFILE["external"]:
h = 130 if x.get("artifacts") else 70
o_ext[x["id"]] = obj(v1, x["id"], 1320, ext_y, 330, h)
for j, a in enumerate(x.get("artifacts", [])):
obj(v1, a["id"], 15 + j * 155, 50, 145, 60, o_ext[x["id"]])
ext_y += h + 20
o_front = obj(v1, fn["id"], 20, 570, 300, 60)
for ts in SERVICES:
for r in ts["realizers"]:
conn("Realization", o_ss.get(r) or o_ext[r], o_ts[ts["id"]])
if ts.get("serves_software"):
conn("Serving", o_ts[ts["id"]], o_ss[ts["serves_software"]])
conn("Assignment", o_front, o_ss[fn["assigned_to"]])
for x in PROFILE["external"]:
for tgt in serves_of(x):
to = o_cluster if tgt == "cluster" else (o_ss.get(tgt) or o_ext[tgt])
conn("Serving", o_ext[x["id"]], to)
# --- представление «Развёртывание по узлам»
GROUPS, EXTRA_USES = PROFILE.get("node_groups", []), {}
if GROUPS:
claimed = {a for g in GROUPS if isinstance(g.get("runs_apps"), list) for a in g["runs_apps"]}
for g in GROUPS:
el(T, g["id"], "Node", g["name"], g.get("doc", ""))
g["_hosts"] = []
for hn in g.get("hosts", []):
hid = "nd-host-" + hn
el(T, hid, "Node", hn); R("Composition", g["id"], hid); g["_hosts"].append(hid)
g["_apps"] = ([a for a in DATA["apps"] if a not in claimed]
if g.get("runs_apps") == "*" else list(g.get("runs_apps") or []))
for a in g["_apps"]:
R("Assignment", g["id"], app_id[a])
for s in g.get("runs_software", []):
R("Aggregation", g["id"], s)
# компоненты, которых нет в манифестах кластера (заданы профилем вручную)
g["_extra"] = []
for c in g.get("components", []):
el("application", c["id"], "ApplicationComponent", c["name"],
APPDOCS.get(c["id"], c.get("doc", "")))
R("Assignment", g["id"], c["id"]); g["_extra"].append(c["id"])
for ts_id in c.get("uses", []):
R("Serving", ts_id, c["id"])
EXTRA_USES.setdefault(ts_id, []).append(c["id"])
vd = view("T2. Развёртывание по узлам",
"Какие группы узлов какие компоненты несут. Вложенность заменяет стрелки: "
"хост внутри группы — composition, приложение — assignment, системное ПО — aggregation. "
"Имена хостов заданы в профиле контура.")
def host_row(v, g, parent, y):
for i, hid in enumerate(g["_hosts"]):
obj(v, hid, 14 + i * 125, y, 115, 44, parent)
return y + (54 if g["_hosts"] else 0)
# левая колонка — небольшие группы
left = [g for g in GROUPS if g.get("runs_apps") != "*"]
y = 20
for g in left:
items = g["_apps"] + list(g.get("runs_software", [])) + g["_extra"]
rows = (len(items) + 1) // 2
h = 40 + (54 if g["_hosts"] else 0) + rows * 62 + 10
og = obj(vd, g["id"], 20, y, 400, h)
yy = host_row(vd, g, og, 34)
for i, it in enumerate(items):
eid = app_id.get(it, it)
obj(vd, eid, 14 + (i % 2) * 195, yy + (i // 2) * 62, 180, 50, og)
y += h + 20
# центр — группа прикладных серверов
big = next((g for g in GROUPS if g.get("runs_apps") == "*"), None)
if big:
per, bw = 6, 180
rows = (len(big["_apps"]) + per - 1) // per
h = 40 + (54 if big["_hosts"] else 0) + rows * 62 + 10
og = obj(vd, big["id"], 450, 20, per * bw + 28, max(h, y - 40))
yy = host_row(vd, big, og, 34)
for i, a in enumerate(big["_apps"]):
obj(vd, app_id[a], 14 + (i % per) * bw, yy + (i // per) * 62, bw - 15, 50, og)
# --- представление «Внешние интеграции»
IV = PROFILE.get("integration_view")
if IV:
vi = view(f"T{len(views)+1}. {IV['name']}", IV.get("doc", ""))
CW, CH = 320, 110
shown = {}
for ci, col in enumerate(IV["columns"]):
for ri, eid in enumerate(col):
shown[eid] = obj(vi, eid, 20 + ci * (CW + 60), 20 + ri * CH, CW, 70)
# связи не перечисляем: берём те, что уже есть в модели между показанными
# элементами, — иначе представление начнёт расходиться с моделью
for _rid, rtype, src, tgt in relations:
if src in shown and tgt in shown:
conn(rtype, shown[src], shown[tgt])
# --- прикладной слой: межсервисные вызовы
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
for ts in SERVICES:
lst = consumers[ts["id"]]
extra = EXTRA_USES.get(ts["id"], [])
if not lst and not extra:
continue
n = len(lst)
v = view(f"T{len(views)+1}. Потребители: {ts['name']}",
f"Сервисом {plural(n, 'пользуется', 'пользуются', 'пользуются')} "
f"{n} из {len(DATA['apps'])} приложений контура.")
ots = obj(v, ts["id"], 20, 20, 260, 60)
for i, r in enumerate(ts["realizers"]):
conn("Realization", obj(v, r, 320 + i * 280, 20, 260, 60), ots)
for i, eid in enumerate([app_id[a] for a in lst] + extra):
conn("Serving", ots,
obj(v, eid, 20 + (i % PER_ROW) * 190, 150 + (i // PER_ROW) * 80, 170, 55))
# ---------------------------------------------------------------- сериализация
FOLDERS = [("Strategy", "strategy"), ("Business", "business"), ("Application", "application"),
("Technology & Physical", "technology"), ("Motivation", "motivation"),
("Implementation & Migration", "implementation_migration"), ("Other", "other"),
("Relations", "relations"), ("Views", "diagrams")]
def emit_obj(o, ind):
# Имена тегов — как их пишет сам Archi: <child xsi:type="archimate:DiagramObject">,
# соединение через атрибут relationship. Имена EClass из метамодели
# (DiagramModelArchimateObject) в XML не используются.
p = " " * ind
tgt = f' targetConnections={quoteattr(" ".join(o["tgt"]))}' if o["tgt"] else ""
x, y, w, h = o["b"]
s = f'{p}<child xsi:type="archimate:DiagramObject" id="{o["id"]}" archimateElement="{o["el"]}"{tgt}>\n'
s += f'{p} <bounds x="{x}" y="{y}" width="{w}" height="{h}"/>\n'
for c in o["src"]:
s += (f'{p} <sourceConnection xsi:type="archimate:Connection" id="{c["id"]}" '
f'source="{o["id"]}" target="{c["tgt"]}" relationship="{c["rel"]}"/>\n')
for c in o["children"]:
s += emit_obj(c, ind + 2)
return s + f'{p}</child>\n'
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<archimate:model xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
'xmlns:archimate="http://www.archimatetool.com/archimate" '
f'name={quoteattr(PROFILE["model"]["name"])} id="model-contour" version="5.0.0">',
f' <purpose>{escape(PROFILE["model"]["purpose"])}</purpose>']
for fname, ftype in FOLDERS:
inner = ""
if ftype in ("technology", "application"):
for folder, eid, etype, name, doc in elements:
if folder != ftype:
continue
if doc:
inner += (f' <element xsi:type="archimate:{etype}" name={quoteattr(name)} id="{eid}">\n'
f' <documentation>{escape(doc)}</documentation>\n </element>\n')
else:
inner += f' <element xsi:type="archimate:{etype}" name={quoteattr(name)} id="{eid}"/>\n'
elif ftype == "relations":
for rid, rtype, src, tgt in relations:
inner += (f' <element xsi:type="archimate:{rtype}Relationship" id="{rid}" '
f'source="{src}" target="{tgt}"/>\n')
elif ftype == "diagrams":
for v in views:
inner += f' <element xsi:type="archimate:ArchimateDiagramModel" name={quoteattr(v["name"])} id="{v["id"]}">\n'
if v["doc"]:
inner += f' <documentation>{escape(v["doc"])}</documentation>\n'
for o in v["objects"]:
inner += emit_obj(o, 6)
inner += ' </element>\n'
out.append(f' <folder name={quoteattr(fname)} id="folder-{ftype}" type="{ftype}">\n{inner} </folder>'
if inner else f' <folder name={quoteattr(fname)} id="folder-{ftype}" type="{ftype}"/>')
out.append('</archimate:model>')
OUT.write_text("\n".join(out) + "\n", encoding="utf-8", newline="\n")
print(f"записано: {OUT}")
print(f"элементов: {len(elements)} отношений: {len(relations)} представлений: {len(views)}")
for v in views:
print(f" {v['name']}")