TerraVision 布局流程與算法說明

本文是對(duì)倉庫 https://github.com/patrickchugh/terravision 的源碼分析筆記,重點(diǎn)關(guān)注它是 如何從 Terraform 資源構(gòu)建出“適合畫架構(gòu)圖的節(jié)點(diǎn)與連線” 的??梢宰鳛閷?shí)現(xiàn)類似能力的設(shè)計(jì)參考。


1. 總體流水線概覽

TerraVision 從 Terraform 到最終圖形,大致分成四層:

  1. Terraform 執(zhí)行與原始依賴圖modules/tfwrapper.py
  2. 變量 / locals / 模塊輸出解釋 + 元數(shù)據(jù)合并modules/interpreter.py + fileparser
  3. 資源圖結(jié)構(gòu)構(gòu)建與精修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)

整個(gè)過程就是:先把 Terraform 變成一個(gè)富信息的 graphdict,再交給 Graphviz 做布局渲染。


2. Terraform 執(zhí)行與原始圖構(gòu)建(tfwrapper)

2.1 運(yùn)行 terraform,生成 plan 和 graph

核心函數(shù):tf_initplan()tf_makegraph()。

def tf_initplan(source, varfile, workspace, debug=True) -> Dict[str, Any]:
    # 1) 寫 override.tf,強(qiáng)制 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 轉(zhuǎn)成 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 初始化節(jié)點(diǎn)和 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 都變成一個(gè) 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 里的字段標(biāo)記為“部署后才知道”
            ...
            tfdata["meta_data"][node] = details

這一步得到的結(jié)果:

  • graphdict: 節(jié)點(diǎn)都有了,但連接還幾乎為空;
  • meta_data: 來自 plan 的屬性(不含 HCL 原始變量表達(dá)式);
  • node_list: 所有資源節(jié)點(diǎn)。

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,根據(jù) 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]  # 依賴資源名
                # 針對(duì) 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) 特殊補(bǔ)充: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 結(jié)果 + provider 配置中的 REVERSE_ARROW_LIST,構(gòu)建出基本的依賴有向圖。


3. 變量、locals、模塊輸出與 HCL 元數(shù)據(jù)(interpreter)

這一層的目標(biāo)是:meta_data 里的字段盡量是“真值”,而不是 var.xxxlocal.xxx 之類的表達(dá)式,以便后續(xù)關(guān)系掃描和布局策略使用。

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 參數(shù)
      里,構(gòu)造:
    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 表達(dá)式,例如:

    subnet_id = aws_subnet.private[0].id
    
  4. handle_metadata_vars:把 meta_data[resource][key] 里的值進(jìn)行多輪替換:

    • var.xxxreplace_var_values,從 variable_map 里取;
    • local.xxxreplace_local_values,從 all_locals;
    • data.xxxreplace_data_values,部分有內(nèi)置替換表,其他標(biāo)記為 "UNKNOWN";
    • module.xxxreplace_module_vars / handle_module_vars,解析模塊輸出,并支持遞歸替換。

最終 meta_data 變成更“接近真實(shí)部署后狀態(tài)”的屬性視圖,同時(shí)仍然保留了一些必要的信息(如 count)。


4. 圖結(jié)構(gòu)的精修(graphmaker)

graphmaker 是 TerraVision 的核心,它在 Terraform 的原始依賴上實(shí)現(xiàn)了一整套“架構(gòu)圖友好”的處理步驟:

4.1 注入 data source 節(jié)點(diǎn)

inject_data_source_nodes(tfdata)

  • 從 plan 的 prior_state 中找出 mode == "data" 的資源;

  • 過濾掉只做 lookup 的類型(EXCLUDED_DATA_SOURCE_TYPES);

  • all_resource 的字符串里搜 "data.<type>.<name>",只有真的被引用的 data source 才變成圖中的節(jié)點(diǎn);

  • 把它們加入到:

    tfdata["graphdict"][node_name] = []
    tfdata["node_list"].append(node_name)
    tfdata["meta_data"][node_name] = { ..., "_data_source": True }
    
  • 從 Terraform graph 中重建 data source 節(jié)點(diǎn)與其他資源之間的邊;

  • 如果某些 data source 表示子網(wǎng)/ALB,并包含 vpc_id,但圖中沒有 VPC,則通過 _synthesize_vpc_from_data_sources() 合成一個(gè) aws_vpc.<vpc_id> 節(jié)點(diǎn),把它作為容器。

這是把 plan 中“只作為數(shù)據(jù)引用”的資源,提升為圖里的顯式節(jié)點(diǎn)。

4.2 掃描并補(bǔ)充關(guān)系(add_relations

add_relations(tfdata) 主要調(diào)用 _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 的關(guān)鍵邏輯:

  • _find_matching_resources 解析字符串里的:
    • aws_xxx.yyy
    • ${aws_xxx.yyy.id}
    • resource[0] / resource[count.index] 等;
  • _find_implied_connections 按關(guān)鍵詞匹配“隱式連接”(例如日志、監(jiān)控等服務(wù));
  • 配合 REVERSE_ARROW_LIST 決定邊的方向,是 resource -> matched 還是反過來。

scan_module_relationships 會(huì):

  • 分析 module 輸出和 module 參數(shù)中 module.other_module.resource_type.name 這樣的引用;
  • 在 module 粒度上補(bǔ)充 module→module 的關(guān)系。

4.3 節(jié)點(diǎn)合并與多實(shí)例展開

4.3.1 合并節(jié)點(diǎn)(consolidate_nodes

根據(jù) provider config 中的 CONSOLIDATED_NODES,把一些本質(zhì)上屬于一個(gè)“邏輯服務(wù)”的節(jié)點(diǎn)合并,例如:

  • aws_lb / aws_alb / aws_nlb 合成一個(gè) ALB 節(jié)點(diǎn);
  • EC2 Auto Scaling Group / Launch Template 等合成一個(gè)更高層抽象。

處理方式:

  1. 如果某個(gè)資源的 consolidated_node_check 有結(jié)果,就把它的 metadata 和連接合并到目標(biāo)節(jié)點(diǎn);
  2. 替換原連接中的指向,避免內(nèi)部成環(huán);
  3. 刪除被合并的原節(jié)點(diǎn)。

4.3.2 count / for_each 的多實(shí)例(create_multiple_resources

多實(shí)例展開的大致流程:

  1. detect_and_set_counts:利用配置 *_MULTI_INSTANCE_PATTERNS 掃描一些有“多引用”的資源(例如一個(gè) ALB 監(jiān)聽多個(gè) target group),推斷 count,并對(duì)關(guān)聯(lián)資源設(shè)置相同 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] = ...  # 復(fù)制并重寫連接
          tfdata["meta_data"][new_name] = deepcopy(meta_data[resource])
          add_multiples_to_parents(i, resource, multi_resources, tfdata)
      
    • 對(duì)連接中的節(jié)點(diǎn),如果也應(yīng)該有多實(shí)例(但原來是單節(jié)點(diǎn)),也生成對(duì)應(yīng)的 ~i 副本;

    • 特殊處理安全組與子網(wǎng),根據(jù)資源在 node_list 中的位置,只連接到對(duì)應(yīng) subnet 的那一個(gè)實(shí)例;

    • 最后刪除原始未帶 ~ 的資源節(jié)點(diǎn)。

4.3.3 簡化網(wǎng)絡(luò) / 清理多余連線

  • cleanup_cross_subnet_connections:如果多個(gè) numbered 節(jié)點(diǎn)在不同 subnet 中實(shí)例化,且某 un-numbered 資源只存在于一部分 subnet,中間的跨 subnet 連線會(huì)刪掉,減少圖的混亂。
  • extend_sg_groups:安全組跟著多實(shí)例一起擴(kuò)展,保持“安全組 N 對(duì)應(yīng)實(shí)例 N”的結(jié)構(gòu)。

4.4 方向調(diào)整與雙向邊

4.4.1 reverse_relations

根據(jù) provider config 中的:

  • FORCED_DEST: 某些資源總是認(rèn)為是“目的地”(箭頭朝它);
  • FORCED_ORIGIN: 某些資源總是“起點(diǎn)”;

再遍歷 graphdict 調(diào)整邊的方向,確保整個(gè)圖符合“從入口 → 業(yè)務(wù) → 數(shù)據(jù)”的流向。

4.4.2 雙向邊

helpers.find_bidirectional_links(tfdata) 識(shí)別 (A -> B)(B -> A) 同時(shí)存在的情況,用一個(gè)集合 bidirectional_edges 記錄,后續(xù)在畫圖時(shí)改用雙向箭頭而不是刪掉其中一條。


5. 布局與渲染(drawing + Graphviz)

TerraVision 自己并不計(jì)算 x/y 坐標(biāo),而是用 Graphviz 的布局引擎(neato)+ cluster + 隱形邊等機(jī)制,讓 Graphviz 做布局,TerraVision 負(fù)責(zé)“給出合適的結(jié)構(gòu)和約束”。

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、子網(wǎng)、SG);
  • 各種資源的繪制順序;
  • 哪些資源在云邊界之外(Region/AZ);
  • 哪些資源是“共享服務(wù)”節(jié)點(diǎn),畫線需要謹(jǐn)慎;
  • 哪些邊永遠(yuǎn)畫 / 永遠(yuǎn)不畫(用 invis edge 只作為布局約束)。

5.2 構(gòu)建 Graphviz DOT 圖

  1. Canvas(engine='neato', direction='TB') 作為整體畫布。
  2. 為 provider 創(chuàng)建大 cluster(AWSGroup / AzureGroup / GCPGroup)。
  3. DRAW_ORDER 遍歷資源類型:
    • draw_objects(node_type_list, ...) → 對(duì)每種類型調(diào)用:
      • handle_group:如果是 GROUP_NODES(VPC、Subnet、SG 等),創(chuàng)建 cluster,遞歸處理子 group 和子節(jié)點(diǎn);
      • handle_nodes:畫普通資源節(jié)點(diǎn),使用 resource_classes 里定義的類(帶 icon / label / 顏色)。
    • 連接時(shí)通過 ok_to_connect / always_draw_edge 決定是否畫邊、邊是否可見(solid or invis),進(jìn)一步影響 Graphviz 的布局。

5.3 Graphviz 布局與后處理

繪制工作只定義了“節(jié)點(diǎn)、cluster 和邊”的結(jié)構(gòu)樣式,具體坐標(biāo)由 Graphviz 決定:

  1. Canvas.pre_render() 生成初始 DOT;
  2. 調(diào)用 gvpr -f shiftLabel.gvpr 調(diào)整 cluster label、標(biāo)題等的位置;
  3. 使用 Graphviz 引擎(neato)輸出 PNG/SVG 等格式;
  4. 如果格式是 drawio,則用 graphviz2drawio 把 dot 轉(zhuǎn)成 .drawio

這一策略的優(yōu)點(diǎn)是:

  • TerraVision 專注于“圖結(jié)構(gòu)”和“分組 / 圖例 / 邊可見性”等高層邏輯;
  • 布局細(xì)節(jié)(線走向、節(jié)點(diǎn)碰撞等)全部交給 Graphviz。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容