TerraVision 布局流程与算法说明

本文是对仓库 https://github.com/patrickchugh/terravision 的源码分析笔记,重点关注它是 如何从 Terraform 资源构建出“适合画架构图的节点与连线” 的。可以作为实现类似能力的设计参考。


1. 总体流水线概览

TerraVision 从 Terraform 到最终图形,大致分成四层:

  1. Terraform 执行与原始依赖图modules/tfwrapper.py
  2. 变量 / locals / 模块输出解释 + 元数据合并modules/interpreter.py + fileparser
  3. 资源图结构构建与精修modules/graphmaker.py + helpers + resource_handlers
  4. 布局与渲染(Graphviz)modules/drawing.py + resource_classes

顶层入口在 terravision/terravision.pydraw()

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

得到包含 _gvidobjectsedges 的 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.xxxlocal.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 主要做四件事:

  1. get_variable_values:从

    • .tfvariable 块的 default,
    • --varfile 传入的 tfvars,
    • module 参数
      里,构造:
    tfdata["variable_list"]  # 所有变量 -> 值
    tfdata["variable_map"]   # 按 module 组织的变量表
    
  2. extract_locals:把 all_locals 按 module 聚合为:

    tfdata["all_locals"] = { module_name: { local_name: value, ... } }
    
  3. merge_metadata:扫描 all_resource 里的 HCL 源码,把原始属性合并回 meta_data,保留了还未展开的 Terraform 表达式,例如:

    subnet_id = aws_subnet.private[0].id
    
  4. handle_metadata_vars:把 meta_data[resource][key] 里的值进行多轮替换:

    • var.xxxreplace_var_values,从 variable_map 里取;
    • local.xxxreplace_local_values,从 all_locals
    • data.xxxreplace_data_values,部分有内置替换表,其他标记为 "UNKNOWN"
    • module.xxxreplace_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 等合成一个更高层抽象。

处理方式:

  1. 如果某个资源的 consolidated_node_check 有结果,就把它的 metadata 和连接合并到目标节点;
  2. 替换原连接中的指向,避免内部成环;
  3. 删除被合并的原节点。

4.3.2 count / for_each 的多实例(create_multiple_resources

多实例展开的大致流程:

  1. detect_and_set_counts:利用配置 *_MULTI_INSTANCE_PATTERNS 扫描一些有“多引用”的资源(例如一个 ALB 监听多个 target group),推断 count,并对关联资源设置相同 count。
  2. 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);
  • 哪些资源是“共享服务”节点,画线需要谨慎;
  • 哪些边永远画 / 永远不画(用 invis edge 只作为布局约束)。

5.2 构建 Graphviz DOT 图

  1. Canvas(engine='neato', direction='TB') 作为整体画布。
  2. 为 provider 创建大 cluster(AWSGroup / AzureGroup / GCPGroup)。
  3. 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 决定是否画边、边是否可见(solid or invis),进一步影响 Graphviz 的布局。

5.3 Graphviz 布局与后处理

绘制工作只定义了“节点、cluster 和边”的结构样式,具体坐标由 Graphviz 决定:

  1. Canvas.pre_render() 生成初始 DOT;
  2. 调用 gvpr -f shiftLabel.gvpr 调整 cluster label、标题等的位置;
  3. 使用 Graphviz 引擎(neato)输出 PNG/SVG 等格式;
  4. 如果格式是 drawio,则用 graphviz2drawio 把 dot 转成 .drawio

这一策略的优点是:

  • TerraVision 专注于“图结构”和“分组 / 图例 / 边可见性”等高层逻辑;
  • 布局细节(线走向、节点碰撞等)全部交给 Graphviz。
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容