258 lines
13 KiB
Python
258 lines
13 KiB
Python
"""Endor concept topology: explicit meshes, storage dependencies and recovery paths."""
|
|
from pathlib import Path
|
|
import drawsvg as d
|
|
|
|
INK = '#233b38'
|
|
GREEN = '#2d8b77'
|
|
BLUE = '#467fb4'
|
|
PURPLE = '#8a6ab4'
|
|
AMBER = '#b58b47'
|
|
MUTED = '#6d817c'
|
|
LINE = '#dce8e3'
|
|
FONT = 'ui-sans-serif, system-ui, -apple-system, PingFang SC, Microsoft YaHei, sans-serif'
|
|
|
|
|
|
def build_architecture(output: Path) -> None:
|
|
svg = d.Drawing(2000, 1860)
|
|
svg.append_title('Endor 架构:全球入口、区域网关、独立存储分片与统一备份')
|
|
svg.append(d.Rectangle(0, 0, 2000, 1860, fill='#fcfdfb'))
|
|
|
|
def rect(x, y, w, h, fill='white', stroke=LINE, radius=12, dash=None):
|
|
svg.append(d.Rectangle(x, y, w, h, rx=radius, fill=fill, stroke=stroke,
|
|
stroke_width=1.2, stroke_dasharray=dash))
|
|
|
|
def text(s, x, y, size=16, color=INK, weight='400', anchor='start'):
|
|
svg.append(d.Text(s, size, x, y, fill=color, font_family=FONT,
|
|
font_weight=weight, text_anchor=anchor))
|
|
|
|
def path(points, color=GREEN, dash=None, width=1.6, arrow=False, opacity=1):
|
|
p = d.Path(fill='none', stroke=color, stroke_width=width,
|
|
stroke_dasharray=dash, stroke_linecap='round',
|
|
stroke_linejoin='round', opacity=opacity)
|
|
p.M(*points[0])
|
|
for pt in points[1:]:
|
|
p.L(*pt)
|
|
svg.append(p)
|
|
if arrow:
|
|
x, y = points[-1]
|
|
px, py = points[-2]
|
|
import math
|
|
angle = math.atan2(y-py, x-px)
|
|
a = d.Path(fill='none', stroke=color, stroke_width=width,
|
|
stroke_linecap='round', opacity=opacity)
|
|
for sign in [-1, 1]:
|
|
a.M(x-7*math.cos(angle+sign*.48), y-7*math.sin(angle+sign*.48)).L(x, y)
|
|
svg.append(a)
|
|
|
|
def dot(x, y, color=GREEN, r=3):
|
|
svg.append(d.Circle(x, y, r, fill='#fcfdfb', stroke=color, stroke_width=1.5))
|
|
|
|
def icon(kind, x, y, color=GREEN, scale=1):
|
|
# Icons are vector geometry rather than font glyphs or downloaded artwork.
|
|
g = d.Group(transform=f'translate({x},{y}) scale({scale})',
|
|
stroke=color, stroke_width=1.8, fill='none',
|
|
stroke_linecap='round', stroke_linejoin='round')
|
|
if kind == 'globe':
|
|
g.append(d.Circle(0, 0, 23)); g.append(d.Ellipse(0, 0, 10, 23))
|
|
g.append(d.Line(-22, -8, 22, -8)); g.append(d.Line(-22, 8, 22, 8))
|
|
elif kind == 'client':
|
|
g.append(d.Rectangle(-26,-20,52,33,rx=4)); g.append(d.Line(-32,21,32,21))
|
|
g.append(d.Lines(-26,13,-32,21,32,21,26,13))
|
|
g.append(d.Line(-6,17,6,17))
|
|
elif kind == 'edge':
|
|
g.append(d.Circle(0,0,24)); g.append(d.Lines(-10,-6,0,-14,11,-6,0,2,-10,-6))
|
|
g.append(d.Lines(-10,3,0,11,11,3)); g.append(d.Lines(-10,11,0,19,11,11))
|
|
elif kind == 'shield':
|
|
g.append(d.Path().M(0,-25).L(22,-16).L(20,5).Q(17,19,0,27).Q(-17,19,-20,5).L(-22,-16).Z())
|
|
g.append(d.Lines(-9,0,-2,7,11,-7))
|
|
elif kind == 'lb':
|
|
g.append(d.Circle(0,-17,7)); g.append(d.Circle(-22,19,7)); g.append(d.Circle(22,19,7))
|
|
g.append(d.Lines(0,-10,0,1,-22,1,-22,12)); g.append(d.Lines(0,1,22,1,22,12))
|
|
elif kind == 'gateway':
|
|
# Routing diamond: deliberately distinct from a Runtime server.
|
|
g.append(d.Lines(0,-23,23,0,0,23,-23,0,0,-23))
|
|
g.append(d.Line(-34,0,-12,0)); g.append(d.Lines(-17,-5,-12,0,-17,5))
|
|
for yy in [-12,0,12]:
|
|
g.append(d.Lines(0,0,9,yy,34,yy))
|
|
g.append(d.Lines(29,yy-4,34,yy,29,yy+4))
|
|
elif kind == 'runtime':
|
|
for dy in [-14,2]:
|
|
g.append(d.Rectangle(-25,dy,50,12,rx=3))
|
|
g.append(d.Circle(-16,dy+6,1.3,fill=color)); g.append(d.Line(-8,dy+6,16,dy+6))
|
|
if kind == 'runtime':
|
|
g.append(d.Line(-13,20,13,20)); g.append(d.Line(0,14,0,20))
|
|
elif kind == 'vm':
|
|
g.append(d.Rectangle(-17,-13,34,26,rx=4))
|
|
for a in [-10,0,10]:
|
|
g.append(d.Line(a,-18,a,-13)); g.append(d.Line(a,13,a,18))
|
|
for a in [-6,6]:
|
|
g.append(d.Line(-22,a,-17,a)); g.append(d.Line(17,a,22,a))
|
|
g.append(d.Path().M(-6,-5).L(0,0).L(-6,5)); g.append(d.Line(4,6,10,6))
|
|
elif kind == 'db':
|
|
g.append(d.Path().M(-21,-12).L(-21,15).C(-21,27,21,27,21,15).L(21,-12))
|
|
g.append(d.Ellipse(0,-12,21,8)); g.append(d.Path().M(-21,1).C(-21,12,21,12,21,1))
|
|
elif kind == 'pool':
|
|
for dx,dy in [(-12,-9),(12,-9),(0,12)]:
|
|
g.append(d.Rectangle(dx-11,dy-10,22,20,rx=5))
|
|
g.append(d.Circle(dx,dy,3,fill=color,stroke='none'))
|
|
elif kind == 'bucket':
|
|
g.append(d.Ellipse(0,-15,23,7)); g.append(d.Lines(-23,-15,-18,21,18,21,23,-15))
|
|
g.append(d.Path().M(-17,6).Q(0,17,17,6))
|
|
elif kind == 'backup':
|
|
g.append(d.Rectangle(-25,-15,50,36,rx=4)); g.append(d.Line(-29,-15,29,-15))
|
|
g.append(d.Lines(-6,5,0,-2,6,5)); g.append(d.Line(0,-2,0,14))
|
|
svg.append(g)
|
|
|
|
def badge(s, x, y, color=GREEN, bg='#edf5ee', w=118):
|
|
rect(x, y-17, w, 25, bg, 'none', 5)
|
|
text(s, x+w/2, y, 12, color, '500', 'middle')
|
|
|
|
text('ENDOR / SYSTEM ATLAS', 40, 42, 14, GREEN, '600')
|
|
text('01 全球接入', 40, 83, 13, MUTED, '600')
|
|
path([(160,143),(550,143)], arrow=True)
|
|
text('动态 API / 对象访问',280,131,13,GREEN)
|
|
path([(110,113),(110,100),(1300,100),(1300,114)],BLUE,arrow=True)
|
|
text('静态资源 / 可缓存内容',1000,91,13,BLUE)
|
|
path([(110,179),(110,214),(1750,214),(1750,179)],PURPLE,'5 6',arrow=True)
|
|
text('登录 / 换取凭证',1470,204,12,PURPLE)
|
|
icon('client',110,143)
|
|
text('全球客户端',110,191,17,INK,'600','middle')
|
|
icon('globe',600,143)
|
|
text('endorphin API',654,138,24,INK,'600')
|
|
text('全球流量入口 · 按健康与策略选区',654,165,14,MUTED)
|
|
icon('edge',1300,143,BLUE)
|
|
text('CDN',1340,137,21,INK,'600')
|
|
text('命中直接返回',1340,162,13,MUTED)
|
|
icon('shield',1750,143,PURPLE)
|
|
text('身份服务',1790,138,18,INK,'600')
|
|
text('登录 / 签发凭证',1790,162,12,MUTED)
|
|
|
|
# A real fan-out includes an open-ended third branch, not just two regions.
|
|
path([(835,180),(835,243),(1900,243),(1900,306)],GREEN,arrow=True)
|
|
rect(1855,315,110,132,'#f8faf7',LINE,12,'3 5')
|
|
text('… / etc',1910,354,23,GREEN,'500','middle')
|
|
text('其他区域',1910,388,15,INK,'600','middle')
|
|
text('按需扩展',1910,418,12,MUTED,'400','middle')
|
|
|
|
regions = [('北美','NA',40,GREEN),('亚洲','AS',960,BLUE)]
|
|
# Each storage family owns its own shard set. No PG/Aerarium/S3 triplets.
|
|
families = [('PostgreSQL','PG','db',150),
|
|
('Aerarium','AO','pool',435),
|
|
('S3','S3','bucket',720)]
|
|
backup_ports = []
|
|
for name,prefix,ox,accent in regions:
|
|
center = ox+435
|
|
rect(ox,275,870,1025,'#ffffff',LINE,18)
|
|
text(f'{name}区域',ox+28,314,24,INK,'600')
|
|
text(f'{prefix} / 独立故障域',ox+28,340,12,MUTED)
|
|
badge('区域入口',ox+708,316,accent,'#eff5f0',132)
|
|
path([(835,180),(835,243),(center,243),(center,352)],GREEN,arrow=True)
|
|
icon('lb',center,377,accent,.72)
|
|
text('区域负载均衡',center+42,372,16,INK,'600')
|
|
text('健康检查 / TLS 路由',center+42,396,12,MUTED)
|
|
gateways = [ox+265,ox+605]
|
|
runtime_x = [ox+105+i*165 for i in range(5)]
|
|
runtime_y = [605,619,605,619,605]
|
|
for gx in gateways:
|
|
path([(center,403),(center,423),(gx,423),(gx,444)],accent,arrow=True)
|
|
text('TRAEFIK',ox+26,467,12,MUTED,'600')
|
|
text('生产冗余 · 可选',ox+26,491,12,MUTED)
|
|
for i,gx in enumerate(gateways):
|
|
icon('gateway',gx,467,accent,.85)
|
|
text(f'Traefik {i+1}',gx,510,14,INK,'500','middle')
|
|
if i == 1:
|
|
badge('HA 副本',gx+49,469,accent,'#edf5f0',85)
|
|
for nx,ny in zip(runtime_x,runtime_y):
|
|
path([(gx,523),(nx,ny-30)],accent,width=1.35,opacity=.65)
|
|
dot(gx,523,accent)
|
|
text('K₂,₅ 全连接',ox+23,556,11,accent)
|
|
for i,(nx,ny) in enumerate(zip(runtime_x,runtime_y)):
|
|
dot(nx,ny-30,accent)
|
|
icon('runtime',nx,ny,accent)
|
|
text(f'Nexus Runtime {i+1:02}',nx,ny+39,13,INK,'600','middle')
|
|
# VM deliberately offset to the lower right of its host.
|
|
path([(nx+26,ny+5),(nx+64,ny+5),(nx+64,ny+70),(nx+50,ny+70)],accent,'2 4',width=1)
|
|
icon('vm',nx+35,ny+70,accent,.55)
|
|
text('Aster VM',nx+35,ny+100,11,INK,'400','middle')
|
|
# Route storage traffic outside the label/VM, not through them.
|
|
path([(nx-26,ny),(nx-61,ny),(nx-61,747)],accent,width=1.1,opacity=.75)
|
|
path([(runtime_x[0]-61,747),(runtime_x[-1]-61,747)],accent,width=1.2)
|
|
path([(center,747),(center,793)],accent,arrow=True)
|
|
text('分片定位 / 对象访问',center+16,775,12,MUTED)
|
|
text('02 独立存储分片群',ox+26,775,13,INK,'600')
|
|
|
|
ys = [880,1020,1160]
|
|
for label,code,kind,dx in families:
|
|
cx=ox+dx
|
|
rect(cx-95,802,190,468,'#f8faf7','#e3ebe2',10)
|
|
text(label,cx,829,17,INK,'600','middle')
|
|
text('Object data pool' if code=='AO' else '独立分片群',cx,850,11,MUTED,'400','middle')
|
|
for i,sy in enumerate(ys):
|
|
icon(kind,cx,sy,accent,.8)
|
|
text(f'{prefix}-{code}-{i+1:02}',cx,sy+36,12,INK,'500','middle')
|
|
path([(cx,sy+43),(cx,sy+54)],accent,'2 4',width=1)
|
|
icon(kind,cx,sy+69,accent,.32)
|
|
text('区域 HA',cx+23,sy+73,10,MUTED)
|
|
dot(cx,1270,AMBER)
|
|
backup_ports.append((prefix,code,cx))
|
|
# K3,3 on each side: every Aerarium shard can address every PG/S3 shard.
|
|
# The two storage families do not gain a fictitious direct PG-to-S3 edge.
|
|
pg,ao,s3 = ox+150,ox+435,ox+720
|
|
for ay in ys:
|
|
path([(ao-25,ay),(ao-75,ay)],accent,width=1.1)
|
|
path([(ao+25,ay),(ao+75,ay)],accent,width=1.1)
|
|
for by in ys:
|
|
path([(ao-75,ay),(pg+75,by)],accent,width=1.15,opacity=.55)
|
|
path([(ao+75,ay),(s3-75,by)],accent,width=1.15,opacity=.55)
|
|
dot(ao-75,ay,accent,2.5); dot(ao+75,ay,accent,2.5)
|
|
for sy in ys:
|
|
path([(pg+75,sy),(pg+25,sy)],accent,width=1.1,arrow=True)
|
|
path([(s3-75,sy),(s3-25,sy)],accent,width=1.1,arrow=True)
|
|
dot(pg+75,sy,accent,2.5); dot(s3-75,sy,accent,2.5)
|
|
text('Aerarium ↔ PG / S3:各分片群全连接,按归属与映射定位数据',center,1290,12,MUTED,'400','middle')
|
|
|
|
# Identity is a control-plane relation; representative endpoints are labeled.
|
|
path([(1750,180),(1750,227),(935,227),(935,653)],PURPLE,'5 6')
|
|
path([(935,653),(900,653),(900,605),(871,605)],PURPLE,'5 6',arrow=True)
|
|
path([(935,653),(985,653),(985,605),(1038,605)],PURPLE,'5 6',arrow=True)
|
|
rect(870,667,130,49,'#fcfdfb','none',4)
|
|
text('JWKS / 凭证验证',935,686,11,PURPLE,anchor='middle')
|
|
text('Nexus 本地验证',935,705,10,PURPLE,anchor='middle')
|
|
# CDN fills from an S3 shard, without crossing other storage labels.
|
|
path([(1300,180),(1300,207),(1975,207),(1975,885),(1808,885),(1808,880),(1705,880)],BLUE,'2 6',arrow=True)
|
|
rect(1948,559,49,51,'#fcfdfb','none',3)
|
|
text('CDN',1972,580,11,BLUE,anchor='middle')
|
|
text('回源',1972,601,11,BLUE,anchor='middle')
|
|
|
|
# One system, not a North America / Asia pair of backup silos.
|
|
for i,(prefix,code,cx) in enumerate(backup_ports):
|
|
dest={'PG':370,'AO':980,'S3':1580}[code]
|
|
lane=1325+i*18
|
|
path([(cx,1270),(cx,lane),(dest,lane),(dest,1460)],AMBER,'6 6',arrow=True)
|
|
rect(40,1460,1790,280,'#fbf8f1','#e9dec8',16)
|
|
text('03 统一备份系统',68,1502,23,INK,'600')
|
|
text('全区域 / 全部分片 · 统一编排、持久化、校验与恢复',68,1531,14,MUTED)
|
|
for kind,cx,label,line1,line2 in [
|
|
('db',370,'PostgreSQL 备份','基础备份 + WAL / 恢复点','各区域 PostgreSQL 分片的事务恢复材料'),
|
|
('pool',980,'Aerarium 备份','对象池状态 + 索引 / 版本映射','关联对象、PG 恢复点与 S3 对象版本'),
|
|
('bucket',1580,'S3 备份','对象版本 + Blob / 快照','各区域 S3 分片的实际对象与文件数据'),
|
|
]:
|
|
icon(kind,cx-145,1596,AMBER,1.05)
|
|
text(label,cx-95,1587,18,INK,'600')
|
|
text(line1,cx-95,1615,14,MUTED)
|
|
text(line2,cx-95,1641,12,MUTED)
|
|
path([(90,1670),(1780,1670)],'#e3d5b9',width=1)
|
|
text('统一恢复目录:区域 / 存储类型 / 分片 ID / 恢复清单 / 校验和 / 重试状态',935,1709,14,AMBER,'500','middle')
|
|
path([(50,1790),(105,1790)],GREEN,arrow=True)
|
|
text('请求 / 存储依赖',118,1795,12,MUTED)
|
|
path([(365,1790),(420,1790)],PURPLE,'5 6')
|
|
text('身份控制 / 公钥',433,1795,12,MUTED)
|
|
path([(680,1790),(735,1790)],BLUE,'2 6')
|
|
text('缓存回源',748,1795,12,MUTED)
|
|
path([(965,1790),(1020,1790)],AMBER,'6 6')
|
|
text('统一备份采集',1033,1795,12,MUTED)
|
|
text('实线为逻辑可达关系;全连接不表示广播,也不表示任意分片拥有相同数据。',40,1837,12,MUTED)
|
|
text('备份保证以已持久化且已校验的恢复点为边界。',1830,1837,12,AMBER,anchor='end')
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
svg.save_svg(str(output))
|