本文是对仓库 https://github.com/patrickchugh/terravision 的源码分析笔记,重点关注它是 如何从 Terraform 资源构建出“适合画架构图的节点与连线” 的。可以作为实现类似能力的设计参考。
1. 总体流水线概览
TerraVision 从 Terraform 到最终图形,大致分成四层:
-
Terraform 执行与原始依赖图(
modules/tfwrapper.py) -
变量 / locals / 模块输出解释 + 元数据合并(
modules/interpreter.py+fileparser) -
资源图结构构建与精修(
modules/graphmaker.py+helpers+resource_handlers) -
布局与渲染(Graphviz)(
modules/drawing.py+resource_classes)
顶层入口在 terravision/terravision.py 的 draw():
tfdata = compile_tfdata(source, varfile, workspace, debug, annotate, planfile, graphfile)
if simplified:
graphmaker.simplify_graphdict(tfdata)
drawing.render_diagram(tfdata, show, final_outfile, format, source)
整个过程就是:先把 Terraform 变成一个富信息的 graphdict,再交给 Graphviz 做布局渲染。
2. Terraform 执行与原始图构建(tfwrapper)
2.1 运行 terraform,生成 plan 和 graph
核心函数:tf_initplan() 和 tf_makegraph()。
def tf_initplan(source, varfile, workspace, debug=True) -> Dict[str, Any]:
# 1) 写 override.tf,强制 local backend,避免 remote state
override_dest = _write_override(codepath)
# 2) terraform init + workspace
subprocess.run(["terraform", "init", "--upgrade", "-reconfigure"], ...)
subprocess.run(["terraform", "workspace", "select", "-or-create=True", workspace], ...)
# 3) terraform plan -out=tfplan.bin
subprocess.run(["terraform", "plan", "-refresh=false", "-out", tfplan_path, ...])
# 4) terraform show -json tfplan.bin > tfplan.json
subprocess.run(["terraform", "show", "-json", tfplan_path], stdout=tfplan_json_path, ...)
# 5) terraform graph > tfgraph.dot
subprocess.run(["terraform", "graph"], stdout=tfgraph_path, ...)
# 6) 用 Graphviz 把 DOT 转成 JSON(xdot_json)
graphdata = convert_dot_to_json(tfgraph_path)
tfdata["plandata"] = plandata
tfdata["tfgraph"] = graphdata
convert_dot_to_json() 通过:
dot -Txdot_json -o graph.json tfgraph.dot
得到包含 _gvid、objects、edges 的 JSON。
2.2 初始化节点和 meta_data(setup_tfdata)
def setup_tfdata(tfdata: Dict[str, Any]) -> Dict[str, Any]:
cloud_config = load_config(detected_provider)
HIDDEN_NODES = cloud_config.AWS_HIDE_NODES / AZURE_HIDE_NODES / ...
tfdata["graphdict"] = {}
tfdata["meta_data"] = {}
tfdata["node_list"] = []
tfdata["hidden"] = HIDDEN_NODES
# 每一条 resource_changes 都变成一个 node
for obj in tfdata["tf_resources_created"]:
if obj["mode"] == "managed":
node = obj["address"] # e.g. aws_instance.web
if "index" in obj:
# 处理 count/for_each
suffix = f"~{int(obj['index']) + 1}" 或 "[index]"
node = node + suffix
tfdata["graphdict"][node] = []
tfdata["node_list"].append(node)
details = obj["change"]["after"]
# 把 after_unknown 里的字段标记为“部署后才知道”
...
tfdata["meta_data"][node] = details
这一步得到的结果:
-
graphdict: 节点都有了,但连接还几乎为空; -
meta_data: 来自 plan 的属性(不含 HCL 原始变量表达式); -
node_list: 所有资源节点。
2.3 从 Terraform graph 填充初始连接(tf_makegraph)
def tf_makegraph(tfdata, debug):
tfdata = setup_tfdata(tfdata)
# 1) 建立 gvid -> name 映射表
gvid_table = ["" for _ in tfdata["tfgraph"]["objects"]]
for item in tfdata["tfgraph"]["objects"]:
gvid = item["_gvid"]
if item.get("name").startswith("module."):
gvid_table[gvid] = item["name"]
else:
gvid_table[gvid] = item["label"]
# 2) 遍历所有 node,根据 tfgraph.edges 填充 graphdict
for node in dict(tfdata["graphdict"]):
node_id = find_node_in_gvid_table(node, gvid_table)
for connection in tfdata["tfgraph"]["edges"]:
head = connection["head"]
tail = connection["tail"]
if node_id == head and ...:
conn = gvid_table[tail] # 依赖资源名
# 针对 count/for_each 做一次「best match」
...
if conn_type in REVERSE_ARROW_LIST:
# 特定类型:反向箭头,conn -> node
tfdata["graphdict"][conn].append(node)
else:
tfdata["graphdict"][node].append(conn)
# 3) 特殊补充:VPC -> Subnet(CIDR overlap)
tfdata = add_vpc_implied_relations(tfdata)
tfdata["original_graphdict"] = deepcopy(tfdata["graphdict"])
tfdata["original_metadata"] = deepcopy(tfdata["meta_data"])
这里使用的是 Terraform 自身的 graph 结果 + provider 配置中的 REVERSE_ARROW_LIST,构建出基本的依赖有向图。
3. 变量、locals、模块输出与 HCL 元数据(interpreter)
这一层的目标是:让 meta_data 里的字段尽量是“真值”,而不是 var.xxx、local.xxx 之类的表达式,以便后续关系扫描和布局策略使用。
3.1 resolve_all_variables 流程
入口在 terravision.py::_enrich_graph_data:
tfdata = interpreter.prefix_module_names(tfdata)
tfdata = interpreter.resolve_all_variables(tfdata, debug, already_processed)
resolve_all_variables 主要做四件事:
-
get_variable_values:从-
.tf里variable块的 default, -
--varfile传入的 tfvars, -
module参数
里,构造:
tfdata["variable_list"] # 所有变量 -> 值 tfdata["variable_map"] # 按 module 组织的变量表 -
-
extract_locals:把all_locals按 module 聚合为:tfdata["all_locals"] = { module_name: { local_name: value, ... } } -
merge_metadata:扫描all_resource里的 HCL 源码,把原始属性合并回meta_data,保留了还未展开的 Terraform 表达式,例如:subnet_id = aws_subnet.private[0].id -
handle_metadata_vars:把meta_data[resource][key]里的值进行多轮替换:-
var.xxx→replace_var_values,从variable_map里取; -
local.xxx→replace_local_values,从all_locals; -
data.xxx→replace_data_values,部分有内置替换表,其他标记为"UNKNOWN"; -
module.xxx→replace_module_vars/handle_module_vars,解析模块输出,并支持递归替换。
-
最终 meta_data 变成更“接近真实部署后状态”的属性视图,同时仍然保留了一些必要的信息(如 count)。
4. 图结构的精修(graphmaker)
graphmaker 是 TerraVision 的核心,它在 Terraform 的原始依赖上实现了一整套“架构图友好”的处理步骤:
4.1 注入 data source 节点
inject_data_source_nodes(tfdata):
从 plan 的
prior_state中找出mode == "data"的资源;过滤掉只做 lookup 的类型(
EXCLUDED_DATA_SOURCE_TYPES);在
all_resource的字符串里搜"data.<type>.<name>",只有真的被引用的 data source 才变成图中的节点;-
把它们加入到:
tfdata["graphdict"][node_name] = [] tfdata["node_list"].append(node_name) tfdata["meta_data"][node_name] = { ..., "_data_source": True } 从 Terraform graph 中重建 data source 节点与其他资源之间的边;
如果某些 data source 表示子网/ALB,并包含
vpc_id,但图中没有 VPC,则通过_synthesize_vpc_from_data_sources()合成一个aws_vpc.<vpc_id>节点,把它作为容器。
这是把 plan 中“只作为数据引用”的资源,提升为图里的显式节点。
4.2 扫描并补充关系(add_relations)
add_relations(tfdata) 主要调用 _scan_node_relationships:
for node in tfdata["node_list"]:
nodename = _get_base_node_name(node, tfdata)
if _should_skip_node(node, nodename):
continue
dg = _get_metadata_generator(node, nodename, tfdata) # 遍历 meta_data 的叶子
for param_item_list in dg:
matching_result = check_relationship(node, param_item_list, tfdata)
if matching_result:
tfdata = _process_connection_pairs(matching_result, tfdata)
check_relationship 的关键逻辑:
- 用
_find_matching_resources解析字符串里的:aws_xxx.yyy${aws_xxx.yyy.id}-
resource[0]/resource[count.index]等;
- 用
_find_implied_connections按关键词匹配“隐式连接”(例如日志、监控等服务); - 配合
REVERSE_ARROW_LIST决定边的方向,是resource -> matched还是反过来。
scan_module_relationships 会:
- 分析 module 输出和 module 参数中
module.other_module.resource_type.name这样的引用; - 在 module 粒度上补充 module→module 的关系。
4.3 节点合并与多实例展开
4.3.1 合并节点(consolidate_nodes)
根据 provider config 中的 CONSOLIDATED_NODES,把一些本质上属于一个“逻辑服务”的节点合并,例如:
-
aws_lb/aws_alb/aws_nlb合成一个 ALB 节点; - EC2 Auto Scaling Group / Launch Template 等合成一个更高层抽象。
处理方式:
- 如果某个资源的
consolidated_node_check有结果,就把它的 metadata 和连接合并到目标节点; - 替换原连接中的指向,避免内部成环;
- 删除被合并的原节点。
4.3.2 count / for_each 的多实例(create_multiple_resources)
多实例展开的大致流程:
-
detect_and_set_counts:利用配置*_MULTI_INSTANCE_PATTERNS扫描一些有“多引用”的资源(例如一个 ALB 监听多个 target group),推断 count,并对关联资源设置相同 count。 -
create_multiple_resources:遍历所有有
count / desired_count / max_capacity / target_size的资源;-
通过
handle_count_resources:for i in range(max_i): new_name = f"{resource}~{i+1}" tfdata["graphdict"][new_name] = ... # 复制并重写连接 tfdata["meta_data"][new_name] = deepcopy(meta_data[resource]) add_multiples_to_parents(i, resource, multi_resources, tfdata) 对连接中的节点,如果也应该有多实例(但原来是单节点),也生成对应的
~i副本;特殊处理安全组与子网,根据资源在
node_list中的位置,只连接到对应 subnet 的那一个实例;最后删除原始未带
~的资源节点。
4.3.3 简化网络 / 清理多余连线
-
cleanup_cross_subnet_connections:如果多个 numbered 节点在不同 subnet 中实例化,且某 un-numbered 资源只存在于一部分 subnet,中间的跨 subnet 连线会删掉,减少图的混乱。 -
extend_sg_groups:安全组跟着多实例一起扩展,保持“安全组 N 对应实例 N”的结构。
4.4 方向调整与双向边
4.4.1 reverse_relations
根据 provider config 中的:
-
FORCED_DEST: 某些资源总是认为是“目的地”(箭头朝它); -
FORCED_ORIGIN: 某些资源总是“起点”;
再遍历 graphdict 调整边的方向,确保整个图符合“从入口 → 业务 → 数据”的流向。
4.4.2 双向边
helpers.find_bidirectional_links(tfdata) 识别 (A -> B) 与 (B -> A) 同时存在的情况,用一个集合 bidirectional_edges 记录,后续在画图时改用双向箭头而不是删掉其中一条。
5. 布局与渲染(drawing + Graphviz)
TerraVision 自己并不计算 x/y 坐标,而是用 Graphviz 的布局引擎(neato)+ cluster + 隐形边等机制,让 Graphviz 做布局,TerraVision 负责“给出合适的结构和约束”。
5.1 加载 provider-specific 绘图配置
render_diagram(tfdata, ...) 中:
constants = _load_provider_constants(tfdata)
CONSOLIDATED_NODES = constants["CONSOLIDATED_NODES"]
GROUP_NODES = constants["GROUP_NODES"]
DRAW_ORDER = constants["DRAW_ORDER"]
OUTER_NODES = constants["OUTER_NODES"]
EDGE_NODES = constants["EDGE_NODES"]
SHARED_SERVICES = constants["SHARED_SERVICES"]
ALWAYS_DRAW_LINE = constants["ALWAYS_DRAW_LINE"]
NEVER_DRAW_LINE = constants["NEVER_DRAW_LINE"]
这些配置定义了:
- 哪些资源是容器/分组(VPC、子网、SG);
- 各种资源的绘制顺序;
- 哪些资源在云边界之外(Region/AZ);
- 哪些资源是“共享服务”节点,画线需要谨慎;
- 哪些边永远画 / 永远不画(用
invisedge 只作为布局约束)。
5.2 构建 Graphviz DOT 图
-
Canvas(engine='neato', direction='TB')作为整体画布。 - 为 provider 创建大 cluster(
AWSGroup/AzureGroup/GCPGroup)。 - 按
DRAW_ORDER遍历资源类型:-
draw_objects(node_type_list, ...)→ 对每种类型调用:-
handle_group:如果是 GROUP_NODES(VPC、Subnet、SG 等),创建 cluster,递归处理子 group 和子节点; -
handle_nodes:画普通资源节点,使用resource_classes里定义的类(带 icon / label / 颜色)。
-
- 连接时通过
ok_to_connect / always_draw_edge决定是否画边、边是否可见(solidorinvis),进一步影响 Graphviz 的布局。
-
5.3 Graphviz 布局与后处理
绘制工作只定义了“节点、cluster 和边”的结构与样式,具体坐标由 Graphviz 决定:
-
Canvas.pre_render()生成初始 DOT; - 调用
gvpr -f shiftLabel.gvpr调整 cluster label、标题等的位置; - 使用 Graphviz 引擎(neato)输出 PNG/SVG 等格式;
- 如果格式是
drawio,则用graphviz2drawio把 dot 转成.drawio。
这一策略的优点是:
- TerraVision 专注于“图结构”和“分组 / 图例 / 边可见性”等高层逻辑;
- 布局细节(线走向、节点碰撞等)全部交给 Graphviz。