
深入 Terraform 内部测试骨架mainbundle 合成源码包如何支撑 Stacks 运行时的大规模测试【免费下载链接】terraformTerraform enables you to safely and predictably create, change, and improve infrastructure. It is a source-available tool that codifies APIs into declarative configuration files that can be shared amongst team members, treated as code, edited, reviewed, and versioned.项目地址: https://gitcode.com/GitHub_Trending/te/terraformTerraform 源码仓库中几乎不存在“为每个测试单独维护一份完整配置包”的做法stackruntime包用一套位于internal/stacks/stackruntime/testdata/mainbundle下的共享“合成源码包”synthetic source bundle解决了这个问题。本文以该目录下的 README.md 为骨架结合helper_test.go的加载实现与plan_test.go、validate_test.go、apply_test.go等真实测试用例完整讲解这套测试夹具的设计动机、目录约定、加载链路与新增场景的标准流程。读完本文你将能理解 Stacks 运行时测试为何能高效复用同一份 bundle并掌握向test/目录添加新测试场景的全部步骤。从测试需求说起为什么需要一个“共享的合成源码包”Terraform 的 Stacks 功能是“零个或多个 Terraform 模块树之上的编排层”其整体架构可以在 internal/stacks/README.md 中看到其中stackconfig负责 stacks 语言的加载、解析与静态解码而stackruntime负责全部动态行为——包括基于期望状态与实际状态对比生成 plan以及执行这些 plan。作为运行时实现所在包doc.go 对它的定位是Package stackruntime contains the runtime implementation of the Stacks language, allowing the various operations over a stack configuration and its associated state and plans.stackruntime的测试数量极其庞大仅plan_test.go、apply_test.go、validate_test.go、apply_destroy_test.go、plan_refresh_test.go等文件中就有上百处测试入口。如果为每个测试都单独构造一份完整的“模块源码包 地址清单”夹具维护成本将不可接受。因此该包采用了一个统一策略正如 mainbundle 的 README.md 开头所写Since the tests in this package are concerned primilary with configuration evaluation and less concerned about configuration bundling or loading, most of our tests can just use subdirectories of the only package in this synthetic source bundle to avoid the inconvenience of maintaining an entire source bundle for each separate test.这句话点出了设计的根本动机该包的测试聚焦于“配置求值”configuration evaluation而不是“打包与加载”bundling or loading。既然加载细节已被sourcebundle层抽象掉测试只需关心给定一份配置后求值结果是否符合预期那么所有测试共享同一个“合成”的远端源码包各自只使用其中的一个子目录就是性价比最高的方案。目录解剖mainbundle 的三层结构整个夹具位于internal/stacks/stackruntime/testdata/mainbundle其顶层结构如下场景目录仅节选代表testdata/mainbundle/ ├── README.md # 本夹具的使用说明 ├── terraform-sources.json # 把 test/ 伪装成一个远端 Git 模块包的清单 └── test/ ├── empty/ # 空组件配置 ├── aliased-provider/ # provider 别名 ├── with-single-resource/ # 最基础的组件资源 ├── with-single-output/ # 单输出组件 ├── with-single-input/ # 单输入组件其下再细分几十个场景 ├── state-manipulation/ # moved / removed / import 等状态迁移 ├── policy-evaluation/ # 策略评估 ├── deferrable-component/ # 可延迟组件 ├── destroy-partial-state/ # 销毁部分状态 └── ... # 共约 50 个顶层场景目录README 中把test/称为“本合成源码包中唯一的 package”而terraform-sources.json正是支撑这一说法的关键。terraform-sources.json把本地目录伪装成一个远端模块源terraform-sources.json 的内容非常简短{ terraform_source_bundle: 1, packages: [ { source: git::https://example.com/test.git, local: test, meta: {} } ] }它声明了一个版本为1的 Terraform source bundle其中包含一个“package”外部视角的源码地址是git::https://example.com/test.git而本地磁盘上对应的目录是local: test即仓库内的test/目录。也就是说test/下的每一个子目录在地址空间中都被视为“远端 Git 仓库git::https://example.com/test.git里的一个子目录”。测试代码正是利用这一约定来构造“远端源码地址”的。helper_test.go 中的mainBundleSourceAddrStr用一行代码完成了拼接func mainBundleSourceAddrStr(dirName string) string { return git::https://example.com/test.git// dirName }//后面的部分对应 Git 模块源中的“子目录路径”而dirName就是test/下的某个场景目录名。例如mainBundleSourceAddrStr(with-single-resource)得到git::https://example.com/test.git//with-single-resource。场景目录的内部组织.tfcomponent.hcl 与 .tf 的组合每个场景目录的内容视测试目标不同而繁简各异但最常见的组合是一份描述组件/配置的 HCL 文件与一份承载底层模块实现的.tf文件。以最基础的 with-single-resource 为例with-single-resource.tfcomponent.hcl 声明了组件所需的内置 provider 与一个指向当前目录的组件required_providers { terraform { source terraform.io/builtin/terraform } } provider terraform default { } component self { source ./ providers { terraform provider.terraform.default } } output obj { type object({ input string output string }) value component.self }而同目录的 with-single-resource.tf 则是component self的底层模块源码——声明了terraform_data资源并暴露两个输出terraform { required_providers { terraform { source terraform.io/builtin/terraform } } } resource terraform_data main { input hello } output input { value terraform_data.main.input } output output { value terraform_data.main.output }当场景需要模拟多个组件、嵌套模块、for_each 展开或多 provider 时目录会进一步扩展为子目录结构。例如 with-single-input 下就并列了valid、failed-child、for-each-component、provider-for-each、removed-component、validation-complex等数十个细分场景而 with-single-input/valid/valid.tfcomponent.hcl 展示了带外部 provider 与输入变量的形态required_providers { testing { source hashicorp/testing version 0.1.0 } } provider testing default {} variable input { type string } variable id { type string default null } component self { source ../ providers { testing provider.testing.default } inputs { id var.id input var.input } }可见场景目录本身就是“一棵微缩的 stacks 配置树”外层 HCL 定义组件与输入输出被引用的.tf文件或嵌套子目录提供组件的模块实现。凡是需要用到 deployment 配置的场景还会额外出现.tfdeploy.hcl例如 plan-variable-defaults/deployments.tfdeploy.hcl 与 with-plantimestamp/deployments.tfdeploy.hcl。加载链路loadMainBundleConfigForTest 到底做了什么README 中给出的核心用法是调用loadMainBundleConfigForTest帮助函数并传入测试目录名作为 source directory。该函数的完整实现位于 helper_test.go// loadMainBundleConfigForTest is a convenience wrapper around // loadConfigForTest that knows the location and package address of our // main source bundle, in ./testdata/mainbundle, so that we can use that // conveniently without duplicating its location and synthetic package address // in every single test function. // // dirName should begin with the name of a subdirectory thats present in // ./testdata/mainbundle/test . It can optionally refer to subdirectories // thereof, using forward slashes as the path separator just as wed do // in the subdirectory portion of a remote source address (which is exactly // what were using this as.) func loadMainBundleConfigForTest(t *testing.T, dirName string) *stackconfig.Config { t.Helper() fullSourceAddr : mainBundleSourceAddrStr(dirName) return loadConfigForTest(t, ./testdata/mainbundle, fullSourceAddr) }注意两点关键信息函数签名返回*stackconfig.Config即返回的是经过完整加载与解析的 stack 配置对象可直接交给后续 plan / apply / validate 逻辑使用dirName允许用/表达嵌套路径例如state-manipulation/moved或with-single-input/valid与远端模块源地址中的子目录写法完全一致。真正执行加载的是 loadConfigForTest链路分四步func loadConfigForTest(t *testing.T, bundleRoot string, configSourceAddr string) *stackconfig.Config { t.Helper() sources, err : sourcebundle.OpenDir(bundleRoot) // 1. 按目录打开 source bundle if err ! nil { t.Fatalf(cannot load source bundle: %s, err) } // 2. 把配置源地址解析为远端源码地址 sourceAddr, err : sourceaddrs.ParseRemoteSource(configSourceAddr) if err ! nil { t.Fatalf(invalid config source address: %s, err) } // 3. 从 bundle 内加载该地址对应的 stack 配置 cfg, diags : stackconfig.LoadConfigDir(sourceAddr, sources) reportDiagnosticsForTest(t, diags) // 4. 任何诊断都直接终止测试 return cfg }sourcebundle.OpenDir与sourceaddrs.ParseRemoteSource来自外部模块github.com/hashicorp/go-slug参见 helper_test.go 顶部 import负责把testdata/mainbundle目录解析为一个 bundle并把git::https://example.com/test.git//xxx这样的字符串解析为结构化的远端源码地址stackconfig.LoadConfigDir是 stacks 语言加载器的入口它与顶层configs包之于模块语言的角色相对应任何加载/解析诊断都会通过reportDiagnosticsForTest直接使测试失败保证调用方拿到的永远是可用配置。loadConfigForTest的注释还解释了“为什么刻意使用远端源码地址”的工程决策We force using remote source addresses here because that avoids us having to deal with the extra version constraints argument that registry sources require. Exactly what source address type we use isnt relevant for tests in this package, since its the sourcebundle packages responsibility to make sure its abstraction works for all of the source types.也就是说改用远端地址只是为了省去 registry 源所需的额外版本约束参数地址类型本身对求值测试无影响因为“为所有源类型提供统一抽象”正是 sourcebundle 包的职责。这也再次呼应了 README 的核心论断——本包测试不必关心打包与加载的细节。测试中的两种消费形态在测试代码中loadMainBundleConfigForTest大体上有两种消费方式分别对应“表驱动批量验证”与“单点深度验证”。形态一共享配置表驱动的参数化测试由于一批配置在 validate 与 plan 阶段都“应当合法”或“应当报出相同错误”validate_test.go 定义了同时被两阶段测试复用的共享表。例如validConfigurations直接把场景目录名作为键var ( // validConfigurations are shared between the validate and plan tests. validConfigurations map[string]validateTestInput{ empty: {}, plan-variable-defaults: {}, variable-output-roundtrip: {}, variable-output-roundtrip-nested: {}, aliased-provider: {}, planning-action-lifecycle: {}, filepath.Join(with-single-input, input-from-component): {}, filepath.Join(with-single-input, input-from-component-list): { planInputVars: map[string]cty.Value{ components: cty.SetVal([]cty.Value{ cty.StringVal(one), cty.StringVal(two), cty.StringVal(three), }), }, }, filepath.Join(with-single-input, valid): { planInputVars: map[string]cty.Value{ input: cty.StringVal(input), }, }, // ... } )这套结构说明某些场景如input-from-component-list、valid还要求向 plan 请求注入输入变量由planInputVars提供而另一些空配置则什么都不需要。随后validate 与 plan 的用例只需遍历这张表对每个键调用loadMainBundleConfigForTest(t, name)即可完成加载。类似地invalidConfigurations表见 validate_test.go则为每个非法场景绑定一个返回期望诊断的diags函数例如const-variable-in-component会精确断言诊断的摘要、详情乃至诊断位置的文件名使用mainBundleSourceAddrStr(const-variable-in-component/const-variable-in-component.tf)还原出合成地址下的报错文件路径。形态二针对单一场景的定向测试当测试想细致验证某个具体语义时会直接在测试函数体内加载单一目录。比如 plan_test.go 中的敏感输出用例、plan_test.go 的复杂输入用例或 apply_test.go 中的空组件 provider 用例cfg : loadMainBundleConfigForTest(t, path.Join(empty-component, valid-providers))配合filepath.Join/path.Join组合多级路径是绝大多数针对state-manipulation、with-single-input/...、with-single-input-and-output/...子场景的测试采用的标准写法。事实上整个stackruntime包的六类测试文件——plan_test.go、plan_refresh_test.go、apply_test.go、apply_destroy_test.go、validate_test.go 以及 telemetry_test.go——都依赖这套 bundle 提供输入配置覆盖范围包括主题域代表场景目录验证点基础组件empty、with-single-resource、with-single-output空配置、单组件与资源、输出引用输入输出与变量variable-output-roundtrip(-nested)、sensitive-output(-as-input/-nested)、variable-ephemeral变量类型往返、敏感标记传播、临时性变量Provider 管理aliased-provider、with-provider-config、with-built-in-provider(-explicitly-defined)、with-provider-functions、invalid-providersprovider 别名、配置传递、内建 provider、provider 函数依赖关系dependent-component、failed-dependency、with-single-input/depends-on组件依赖、失败依赖的传播延迟与 deferreddeferrable-component、deferred-action、deferred-dependent、deferred-component-for-each-from-component-of-invalid-typedeferred 语义与 for_each 交互状态迁移state-manipulation/moved|removed|import|cross-type-moved|deferredmoved/removed/import 的规划行为销毁与清理destroy-partial-state、removed-offline、forget_with_dependency、with-single-input/removed-*部分状态销毁、离线移除、forget策略评估policy-evaluation(-deferred/-removed/-embedded-stack)策略钩子与移除后的策略行为plan 语义with-plantimestamp、plan-variable-defaults、plan-no-value-for-required-variableplan 时间戳、默认值、缺失必填变量静态校验错误invalid-configuration、invalid-local、validate-cyclic-dependency、validate-undeclared-variable非法配置的精确诊断表内各目录均已在test/下确认存在验证点依据目录命名与对应测试函数推断。实操如何为 stackruntime 新增一个测试场景把 README 的使用说明还原为一份可执行的 Checklist新增场景只需四步1. 在test/下创建以场景命名的子目录。命名应能概括被测行为如plan-no-value-for-required-variable、sensitive-output-as-input。若被测点是某个主题的细分可放入现有主题目录下例如with-single-input/validation-complex。2. 填充场景文件。至少需要一份定义组件/provider/变量/输出的 HCL 文件并按需提供被组件引用的.tf模块源码。参考最小样例 empty/empty.tfcomponent.hcl需要 deployment 配置时再加.tfdeploy.hcl。3. 在测试中调用帮助函数。单点测试直接写cfg : loadMainBundleConfigForTest(t, your-scenario-name) // 或嵌套路径 cfg : loadMainBundleConfigForTest(t, path.Join(with-single-input, your-case))如果该场景属于“合法配置”或“非法配置”的通用集合则应把它登记进 validate_test.go 的共享表validConfigurations/invalidConfigurations这样 validate 与 plan 两套测试会自动覆盖它需要输入变量的场景记得在planInputVars中提供 cty 值。4. 运行验证。通过go test ./internal/stacks/stackruntime/即可看到新场景被既有测试框架加载并执行。README 特意强调“helper 会自动构造用于在 bundle 内定位该子目录的合成远端源地址”因此测试代码里无需关心terraform-sources.json的维护。这套设计模式的收益与边界从工程角度看mainbundle 体现了一种“共享夹具 按场景分子目录”的测试组织模式其收益与边界都相当清晰收益首先避免了每个测试各自维护一份完整 bundle 的重复成本——新增一个场景通常只需要新增几个小文件。其次测试的关注点被刻意收窄到“配置求值”加载细节统一由sourcebundlestackconfig两层负责符合单一职责。最后合成地址git::https://example.com/test.git//xxx让配置在测试眼中与真实远端模块源别无二致诊断信息如 validate_test.go 中断言错误位置时还原的完整路径也因此具备与生产路径一致的表达。边界需要清醒认识的是这套 bundle 是为“求值类”测试量身定制的——正如 README 所言它并不专门覆盖“配置打包或加载”本身的边界问题那些问题属于 sourcebundle 包与go-slug的职责范围在引入新场景时也不要破坏共享目录的命名秩序与场景独立性否则共享夹具反而会退化为互相耦合的“测试杂烩”。小结internal/stacks/stackruntime/testdata/mainbundle表面上只是一个 16 行的 README 加上若干目录但它背后是一套与stackruntime上百个测试用例深度绑定的测试骨架terraform-sources.json用三个字段把一个本地目录伪装成远端 Git 模块源loadMainBundleConfigForTest与mainBundleSourceAddrStr把它折叠成一行可用的加载调用而test/下数十个语义化命名、按主题分组的场景目录共同支撑起 Terraform Stacks 运行时对配置求值行为的系统性验证。理解这套夹具是阅读乃至贡献stackruntime测试代码最快的切入点。【免费下载链接】terraformTerraform enables you to safely and predictably create, change, and improve infrastructure. It is a source-available tool that codifies APIs into declarative configuration files that can be shared amongst team members, treated as code, edited, reviewed, and versioned.项目地址: https://gitcode.com/GitHub_Trending/te/terraform创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考