
OpenTofu 静态评估 RFC 深度解析在 init 阶段求值常量变量与 locals 的架构设计与落地【免费下载链接】opentofuOpenTofu lets you declaratively manage your cloud infrastructure.项目地址: https://gitcode.com/gh_mirrors/op/opentofu本文是对 OpenTofu 官方 RFCrfc/20240513-static-evaluation.md的深度解读并对照当前仓库源码internal/configs 等验证其落地状态。读者将掌握OpenTofu 为什么需要init 期静态求值、它如何在不依赖动态信息的前提下求值变量与 locals、tofu init阶段配置加载与图引用求值的完整调用链以及 backend 配置、module source、加密配置、provider 迭代等被阻塞功能的统一解法。一、背景与动机为什么tofu init阶段需要求值能力OpenTofu 的许多用户在多个本应支持变量和 locals的位置上遇到了限制常见场景包括但不限于模块源地址module sourcesmodule x { source ... }的地址Provider 指派provider assignmentsprovider ...Backend 配置terraform { backend ... { ... } }加密配置encryption configuration。这些信息都必须在tofu init期间确定无法纳入常规的tofu plan/apply系统。init 建立的是对项目配置的静态理解后续 plan/apply 在此基础上运作。这是理解本 RFC 的第一把钥匙求值被分为两个阶段——init 期的静态阶段和 plan/apply 期的动态阶段。二、提议的解决方案总览简单来说该提案旨在增强 OpenTofu 的配置处理能力支持对不依赖任何动态信息资源、数据源、provider 等的变量和 local 引用进行求值。需要特别强调的是该提案本身并不直接增加面向用户的端到端新功能而是一个支撑系统support system供上面提到的 backend 配置、module source 等示例建立在它之上。在 internal/configs/static_evaluator.go 中可以看到这一支撑系统的落地形态StaticIdentifier保存可引用对象及其声明位置、StaticModuleVariables变量求值回调函数类型、StaticModuleCall调用某个模块所需的静态信息集合等核心类型均已实现。三、用户可见的行为设计错误消息与限制示例RFC 指出作为支撑系统它必须直接与用户交互。以下是基于真实场景的错误消息设计RFC 原文注明所有错误/警告均为简化占位符实际实现会附带更好的措辞、格式与源码位置。3.1 可配置 Backend 示例先看一个同时依赖变量和 locals 的 backend 配置variable key { type string } locals { region us-east-1 key_check md5sum(var.key) } terraform { backend somebackend { region local.region key var.key key_check local.key_check } }首次运行tofu init时会产生两个错误terraform.backend.key要求提供变量keyterraform.backend.key_check要求提供 localkey_check而它又依赖变量key。变量key可以通过terraform.tfvars文件或 CLI 参数-var keysomevalue提供这意味着-varCLI 参数需要被添加到大多数 OpenTofu 命令中。注RFC 原注在这种场景下除了报错也可以改为要求用户提供所需变量值——这在 provider 配置流程中已经发生。可以统一该流程以减少代码重复和现有的各种变通写法。接下来考虑运行tofu apply时的情况如果terraform.tfvars或-var key发生改变backend 配置将与tofu init时不再匹配并向用户返回错误。这要求用户团队谨慎决定允许哪些变量进入 backend 以及如何管理它们。引用追踪的重要性正如用户今天试图在 backend 中使用动态值资源/数据源一样未来一定会有人写出这样的配置resource mycloud_account { } locals { account_id mycloud.account.id } terraform { backend somebackend { account_id local.account_id } }此时会产生如下错误消息terraform.backend.account_id无法被解析字段account_id依赖 localaccount_id而它又依赖资源mycloud.account此处不允许使用。这类示例旨在强调**引用追踪reference tracking**的价值——它能向用户清晰解释为什么他们尝试的操作不被允许。3.2 模块源地址示例模块是一个静态求值必须与之交互的复杂概念。先看一个不涉及子模块的简单示例# main.tf variable version { type string } module helper { source gitgithub.com:org/my-utils?ref${var.version} }这将产生与 backend 示例相同类型的错误消息。再看带子模块源的复杂示例# main.tf variable version { type string } module common_first { source ./common version var.version region us-east-1 } module common_second { source ./common version var.version region us-east-2 }# ./common/main.tf variable version { type string } variable region { type string } module helper { source gitgithub.com:org/my-utils?ref${var.version} region var.region }这给引用错误增加了新的维度。当变量version未提供时会产生如下错误模块common_first.helper的 source 无法确定common_first.helper.source依赖变量common_first.version它又依赖变量version而后者未被提供模块common_second.helper的 source 无法确定common_second.helper.source依赖变量common_second.version它又依赖变量version而后者未被提供一旦提供了version的值一切即可正常工作。该配置还可以用for_each进一步简化# main.tf variable version { type string } module common { for_each {first us-east-1, second us-east-2} source ./common version var.version region each.value }这会合并为单个错误而不是多个模块common.helper的 source 无法确定common.helper.source依赖变量common.version它又依赖变量version而后者未被提供注意for_each完全没有与静态引用系统交互。对for_each/count的禁止那么如果用户在必须静态已知的字段中使用each.value会怎样# main.tf variable version { type string } module common { for_each {first us-east-1, second us-east-2} source ./common version ${var.version}_${each.key} region each.value }无论变量version的值是什么都会产生如下错误模块common.helper的 source 无法确定common.helper.source依赖变量common.version而它又依赖一个for_eachkey此处禁止使用支持静态表达式中的for_each/count存在重大技术障碍RFC 明确禁止之。更多背景见 rfc/20240513-static-evaluation/module-expansion.md该附属文档记录了原型阶段的探索结论若要在配置层支持静态模块展开需要把addrs.Module全面迁移到addrs.ModuleInstance让超过一半的 OpenTofu 组件理解实例化概念开发与测试成本巨大因此暂缓实现。四、技术基础表达式求值的两阶段模型虽然该方案在 OpenTofu 中的范围主要局限于一两个包但理解它将模仿并与哪些复杂系统交互至关重要。注RFC 原注强烈建议在深入本文前先阅读 docs/architecture.md。下文将对该文档中的许多概念从理解本提案的角度进行展开。4.1 表达式Expressions表达式如1 var.bar的求值取决于表达式引用的值和函数。上例中你需要知道var.bar的值。这种依赖通过HCL Traversals概念获知——它表示属性访问路径并可被转换为强类型的OpenTofu References。实践中可以说该表达式依赖一个名为 bar 的 OpenTofu 变量。一旦知道表达式的需求hcl.Expression就可以构建求值上下文hcl.EvalContext来提供这些需求或在缺失时返回错误。上例中求值上下文必须包含{var: {bar: somevalue}}。当前表达式求值分为两个阶段配置加载config loading和图引用求值graph reference evaluation。4.2 配置加载Config Loading在配置加载期间HCL 或 JSON 配置被 hcl 包拆分为 Blocks 和 Attributes。Block 可以包含 Attributes 和嵌套 BlocksAttribute 只是命名表达式例如foo 1 var.bar。some_block { some_attribute some value }这些 Blocks/Attributes 是配置的抽象表示尚未被求值为可操作的值。处理某个 block 或 attribute 时会决定是立即求值如必要还是保留抽象表示供后续处理。若保留后续将由图引用求值转换为值。以具体例子说明module - source字段必须在配置加载期间已知因为继续下一轮加载过程需要它而module - for_each这类属性可能依赖资源属性值或其他配置加载期未知的信息因此以表达式形式存储留给图引用求值resource aws_instance example { name server-${count.index} count 5 # (other resource arguments...) } module dnsentries { source ./dnsentries hostname each.value for_each toset(aws_instance.example.*.name) }在整个配置加载过程中不会构建或提供任何求值上下文。因此由于缺少求值上下文配置加载期间不得使用任何函数、locals 或变量——这正是本 RFC 想要解决的限制。4.3 图引用求值Graph Reference Evaluation配置完全加载后会被转换和处理为有向无环图DAG中的节点。这些节点使用其 blocks/attributes 中配置加载时未求值的部分携带的OpenTofu References来构建图中的依赖边并在这些引用可用后构建求值上下文见 internal/addrs/parse_ref.go。这一理论简单的过程被**模块依赖树及其中的展开expansion**极大地复杂化。由于for_each和count在所需引用可用时才被求值子图sub-graph被动态创建。该过程的大部分逻辑存在于紧密耦合的tofu和lang两个包中。例如一个模块的for_each可能需要资源中的数据for_each resource.aws_s3_bucket.foo.tags。在求值前模块必须等待OpenTofu 资源引用aws_s3_bucket.foo可用。这会表示为模块节点与具体资源节点之间的依赖边求值上下文随后包含{resource: {aws_s3_bucket: {foo: {tags: provided value}}}}。注RFC 原注一个常见误解是模块是对象。实际上模块更接近命名空间只要没有引用循环它们可以互相引用彼此的 vars/outputs。4.4 背景小结如上面所述配置加载阶段缺少求值上下文导致任何含引用的表达式都无法展开该阶段目前只允许基本类型和纯表达式。通过引入在配置加载期间构建和管理求值上下文的能力就可以让某些引用在配置加载过程中被求值。例如许多用户期望能在module - source中使用local值以简化升级、减少配置重复这在目前不可行因为module - source的值必须在配置加载阶段已知不能推迟到图求值local { gitrepo git://... } module mymodule { source locals.gitrepo }利用 Traversals/References可以追踪哪些值在整个配置加载过程中是静态已知的。这将遵循与图引用求值类似的模式但在可解析的内容上有限制。在把 Attribute/Block 求值为值时任何缺失的引用都必须以用户易于调试和理解的方式报告贯穿模块树的变量也必须携带其关联信息如引用向下传递。五、当前加载/求值流程附源码佐证在 OpenTofu 中执行操作init/plan/apply 等大体遵循以下步骤简化版。一个 command 包中的命令根据 CLI 参数创建并执行执行以下动作5.1 解析并加载配置模块从根模块当前目录开始创建config.Config结构。该结构是一棵树的根节点代表组成项目的所有module {}调用。树中每个节点包含一个config.Module和一个addrs.Module路径。树的构建方式安装模块源码 → 加载模块 → 检查模块调用 → 深度优先递归。// 伪代码对应实现见 internal/configs/config_build.go func buildConfig(source string) configs.Config { c : configs.Config{} path installModule(source) // internal/initwd/module_install.go c.module loadModule(path) // internal/configs/parser_config_dir.go for name, call : range c.module.calls { // internal/configs/config_build.go c.children[name] buildConfig(call.source) } return c } root buildConfig(.)当前仓库中这一流程已演化为 internal/configs/config_build.go 的BuildConfig它通过ModuleWalker接口加载所有后代模块先构建符号库buildSymbolLibraries再对根模块执行Finalize随后递归buildChildModules加载子模块、buildTestModules加载测试模块最后在无错误时解析 provider 类型并做验证。configs.Module结构是模块的表示混合了两类字段配置过程中即计算的字段与推迟到稍后求值的字段。例如module - source必须在配置加载完成前已知而资源配置体可以推迟到图节点求值期间。模块加载流程取一个目录把每个 hcl/json 文件转成configs.File结构合并后返回configs.Module。// 伪代码对应实现见 internal/configs/parser_config_dir.go 与 internal/configs/module.go func loadModule(path string) configs.Module { var files []file.File for filepath in range(list_files(path)) { files append(files, loadFile(filepath)) } module : configs.Module{} for _, file in range(files) { module.appendFile(file) } return module } func loadFile(filepath string) configs.File { file : configs.File{} hclBody hcl.parse(filepath) for _, hclBlock in range(hclBody) { switch (hclBlock.Type) { case module: file.ModuleCalls append(file.ModuleCalls, decodeModuleCall(hclBlock)) case variable: file.Variables append(file.Variables, decodeVariable(hclBlock)) // 其余受支持 block 的模式省略 } } return file }在 internal/configs/module_call.go 中可以看到ModuleCall的字段设计除了Sourcehcl.Expression与解析后的SourceAddraddrs.ModuleSource还保存了VariablesStaticModuleVariables与Workspace注释明确写道Used when building the corresponding StaticModuleCall——这正是 RFC 提出的存储表达式、需要时求值模式的落地。5.2 Backend 被加载命令从配置构造 backend。backend 是与状态存储交互的组件负责实际执行和管理状态操作plan/apply。5.3 操作被执行命令使用 backend 和配置执行操作。随后从已加载配置构建图并转换使其可被遍历。转换的关键环节包括基于节点间检测到的引用进行转换和链接internal/tofu/transform_reference.go。节点依赖通过检查 blocks 和 attributes 确定这些 blocks/attributes 在lang包中被转换为引用资源节点被链接到其所需的 provider 节点模块变量通过模块调用从父模块映射到子模块。图随后被求值在每个节点的依赖被求值后遍历各节点。求值某个图节点时使用tofu.EvalContext由tofu.BuiltinEvalContext实现见 internal/tofu/eval_context_builtin.go基于节点指定的引用构建和使用lang.Scope——由于转换后图所表示的依赖结构这些引用应当都已求值完毕。lang.Scope负责获取 OpenTofu 引用并从当前可用的值与函数中构建hcl.EvalContext。整体请求流程可参考架构图六、提议的变更Static Evaluation Context 设计需要修改上述设计在配置加载过程中以不同作用域追踪引用/值这被称为Static Evaluation Context静态求值上下文。加载模块时必须提供静态上下文从command等外部包调用时静态上下文包含来自 CLI 选项和 .tfvars 文件的 tfvars从configs.Config树构建过程内部调用时它会把来自config.ModuleCall的值作为已提供的变量以静态引用方式传入无论哪种情况内置 OpenTofu 命令均可用。// 伪代码 func buildConfig(source string, ctx StaticContext) configs.Config { c : configs.Config{} path installModule(source) c.module loadModule(path, ctx) for name, call : range c.module.calls { // 此时应具备求值子模块 source 字段所需的信息 source : ctx.Evaluate(call.source) // 基于 call 的配置属性构建新的 StaticContext childCtx ctx.FromModuleCall(call.Name, call.Config) c.children[name] buildConfig(source, childCtx) } return c } func loadModule(path string, ctx StaticContext) configs.Module { var files []file.File for filepath in range(list_files(path)) { files append(files, loadFile(filepath)) } module : configs.Module{} for _, file in range(files) { module.appendFile(file) } // 接入当前变量和 locals ctx.AddVariables(module.Variables) ctx.AddLocals(module.Locals) // 可在此使用 StaticContext 对模块字段进行额外处理 return module } root buildConfig(., StaticContextFromTFVars(command.TFVars))对照当前仓库internal/configs/static_evaluator.go 中StaticEvaluator的注释明确写道——一个静态求值器包含构建 EvalContext 所需的信息该 EvalContext 只理解静态非状态数据internal/configs/static_scope.go 则承载了静态作用域的实现同时服务于配置中 backend/cloud/encryption 等需要早期求值的场景这点可从internal/configs/backend.go、internal/encryption/keyprovider.go等处对静态求值类型的引用得到印证。这些实现与 RFC 的伪代码设计一一对应。6.1 静态上下文设计要求项目核心是一个求值上下文与tofu和lang包中现有上下文目的相似但需求有差异。任何静态求值器都必须能够把 hcl 表达式或 block 求值为单个 cty 值并对为什么某表达式/block 无法转为 cty 值提供详细洞察用来自父静态上下文对应父模块的变量构造——主要用于沿模块调用栈向下传递值同时维护引用理解当前 locals 及其依赖。6.2 三种实现路径RFC 探讨了三条潜在实现路径为当前问题及其用例构建定制方案不与现有求值上下文共享原型阶段即采用此方法开发期灵活不破坏其他包但测试必须从零编写。以新管道复用tofu与lang包的现有组件可建立在已验证逻辑之上组件可按需替换、较为灵活但可能需要重构拟复用的组件且可能因现有测试覆盖不足而意外破坏其他包。复用tofu与lang包中的现有求值器/作用域构造需要重新设计这些组件以在两种模式下工作核心逻辑大多已实现但由于现有测试覆盖不佳破坏其他包的可能性高重构规模越大越可能需要别扭的适配。该决策被推迟到实际实施阶段——这是深度的技术调研与讨论不会显著影响本 RFC 的提议方案。但所有方案都应适配足够相似的接口以免阻塞依赖任务的开发。七、可被静态求值解锁的依赖问题为更好理解要解决的确切问题RFC 概述了可用静态求值上下文解决的部分问题。7.1 Backend 配置核心实现落地后这可能是最容易实现的部分。原型阶段的笔记configs.Backend在配置加载时保存配置体而不求值它internal/command/meta_backend.go 的backendInitFromConfig()负责求值该体——它发生在图构建/求值之前可视为配置加载阶段的延伸可以把StaticContext的副本暂存在configs.Backend中并在backendInitFromConfig()中使用它提供求值上下文以便解码为给定 backend schema——原型中将其暂存于此处是让其工作起来的简单方式别忘了更新configs.Backend.Hash()函数它用于检测任何变更。7.2 模块源地址模块源地址必须在 init 时已知因为它们会被下载并归入.terraform/modules。可通过使用限定在当前模块作用域的静态求值器检查 source 的hcl.Expression来实现。原型笔记在config.ModuleCall中创建SourceExpression字段初始不设置config.ModuleCall.Source字段在NewModule构造函数中利用可用的静态上下文求值所有config.ModuleCall的 source 字段检查错误引用及其他错误。对照源码internal/configs/module_call.go 中ModuleCall.Source保存原始hcl.ExpressionSourceAddr/SourceAddrRaw/SourceSet保存解析结果正是先存表达式、需要时再解析模式的体现。许多其他被阻塞的问题都遵循在配置加载的第一阶段存储表达式、需要时再求值这一高度相似的模式故在此略去。7.3 加密配置加密功能试图成为独立包努力限制对 OpenTofu 代码的依赖未来有可能被独立使用。它直接使用 hcl 库不遵循 OpenTofu 代码库的其他模式这可能对 Static Evaluation Context 接口的设计产生显著影响从源码引用关系看internal/encryption/keyprovider.go 与 internal/encryption/methods.go 均已接入静态求值类型。7.4 Provider 迭代由于展开复杂性该部分在配套的 rfc/20240513-static-evaluation-providers.mdProvider 求值 RFC中详述。核心要点允许在provider块中使用for_eachalias必须同时设置默认 provider 配置始终是单例for_each参数最初仅允许早求值已知的值输入变量及其派生的 locals动态引用如数据源暂被禁止provider 引用语法扩展为aws.by_region[us]形式——方括号内的索引段可以是任意 HCL 表达式但必须仅由规划期已知的值派生、结果可转换为 string 且必须匹配已声明 provider 配置的某个实例 keyresource/module块的providers参数可引用动态实例 key如aws.by_region[each.key]此处的表达式由主语言运行时动态求值不受静态求值约束测试场景语言.tftest.hcl中的provider/mock_provider/run.providers也随之调整与主模块对应配置必须保持for_each一致性明确不支持providerfor_each与关联资源的for_each使用完全相同表达式OpenTofu 会检测并给出过于相似警告因为销毁资源时 provider 实例必须比资源实例至少多存活一个 plan/apply 周期把 provider 引用当普通值传递一个资源跨多个 provider 配置在模块间传递 provider 实例集合provider块中使用count参数保留但暂不实现。对照源码internal/configs/provider.go 中Provider结构已含ForEach hcl.Expression与Instances map[addrs.InstanceKey]instances.RepetitionData字段并在alias缺失时禁止for_each第 110-115 行同时通过evalchecks.EvaluateForEachExpression求值for_each并填充实例映射第 172-191 行——provider 迭代功能已按 RFC 设计落地。八、开发路径、阻塞项与性能考量8.1 分阶段开发路径鉴于变更范围这是一项可能跨越多个版本发布的大量工作。RFC 明确反对在特性分支上长期开发或冻结所有相关代码而是将其拆分为更小、离散、可测试的组件部分组件可并行开发。每个组件添加/修改之前、期间、之后都补充测试。核心实现主要由 OpenTofu 核心团队完成社区成员可以在静态求值解锁的、隔离且定义良好的 issue 上独立工作。8.2 阻塞项OpenTofu 现有测试零散且比期望稀疏实现各阶段前后都需要补充测试覆盖重构某个组件前应检查其代码覆盖率以指导所需补充的测试目标不是 100%而是用覆盖率作为理解现有测试的工具应编写一份全面的 e2e 测试指南跟踪 issue 见 RFC 正文。8.3 性能考量多次解析配置由于 command 包的部分重构当前目录中的配置在许多步骤中被多次加载、解析和求值。静态求值会增加该动作的开销值得聚焦于容易消除重复配置加载的地方应创建/更新 issue 追踪 command 包清理工作静态求值器开销选择静态求值器实现方案时应关注性能。九、开放问题是否支持在变量缺失时交互式询问值这已是既有模式但可能需要额外工作或许推迟到后续迭代更稳妥参考 3.1 节关于 provider 配置的注记是否在静态求值上下文中支持 OpenTofu 核心函数很可能支持因为接入相当简单是否在静态求值阶段支持 provider 函数倾向不支持无充分理由时开发成本可能显著而收益甚微。检测有人试图在表达式/体中调用 provider 函数并标记该表达式结果为 dynamic 是很容易的。十、未来考量静态模块输出一个很有价值的场景是引入单个模块来定义组织内多个项目依赖的 sources 和版本从而支持module mycompany { source git::.../sources } module capability { source ${module.mycompany.some_component} } module other_capability { source ${module.mycompany.other_component} }父模块引用的所有模块都会被下载并加入配置图而无需理解任何相互依赖关系。要实现这一点需要重写配置构建器以感知状态求值器并增加该组件的复杂度。RFC 作者对工程投入是否值得持保留态度但认为值得调研。Static Module Expansionrfc/20240513-static-evaluation/module-expansion.md因所需架构变更巨大而暂被禁止当前实现了一个受限版本——仅允许 provider 别名通过 for_each/count 指定。十一、潜在替代方案Terragrunt 等工具在 OpenTofu 之上提供抽象层许多用户认为有益。把部分功能内建到 OpenTofu 意味着开箱即用、无需额外工具同时 terragrunt 可以更专注于编排多个 OpenTofu 项目间的复杂基础设施这类难题而非修补 OpenTofu 的限制。独立的预处理器pre-processor需要设计并实现一套完全独立的语言来预处理配置且难以与任何现有 OpenTofu 构造集成。结语从 RFC 到源码的落地轨迹通过对照当前仓库可以看到这份 2024 年 5 月的 RFC 提出的Static Evaluation Context设计已在 OpenTofu 中逐步落地为真实代码StaticModuleCall、StaticEvaluator、StaticIdentifier构成了静态求值核心internal/configs/static_evaluator.go 与 internal/configs/static_scope.goBuildConfig的递归加载链把静态调用信息沿模块树传递internal/configs/config_build.goproviderfor_each及其实例映射已写入configs.Providerinternal/configs/provider.go配套的 provider 求值 RFC 中的地址语法、测试场景规则与状态格式设计也一并在 rfc/20240513-static-evaluation-providers.md 中给出。对读者而言理解本文的静态/动态两阶段求值模型是深入阅读 OpenTofu 配置加载与图求值源码configs、lang、tofu三个包的最佳切入点。【免费下载链接】opentofuOpenTofu lets you declaratively manage your cloud infrastructure.项目地址: https://gitcode.com/gh_mirrors/op/opentofu创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考