Terraform快速参考
Terraform用于实现基础设施即代码(infrastructure as code)—— 通过代码(配置文件)来描述基础设施的拓扑结构,并确保云上资源和此结构完全对应。Terraform有三个版本,我们主要关注Terraform CLI。
Terraform CLI主要包含以下组件:
- 命令行前端
- Terraform Language(以下简称TL,衍生自HashiCorp配置语言HCL)编写的、描述基础设施拓扑结构的配置文件。配置文件的组织方式是模块。本文使用术语“配置”(Configuration)来表示一整套描述基础设施的Terraform配置文件
- 针对各种云服务商的驱动(Provider),实现云资源的创建、更新和删除
云上资源不单单包括基础的IaaS资源,还可以是DNS条目、SaaS资源。事实上,通过开发Provider,你可以用Terraform管理任何资源。
Terraform会检查配置文件,并生成执行计划。计划描述了那些资源需要被创建、修改或删除,以及这些资源之间的依赖关系。Terraform会尽可能并行的对资源进行变更。当你更新了配置文件后,Terraform会生成增量的执行计划。
直接到https://www.terraform.io/downloads.html下载,存放到$PATH下即可。
使用选项 -chdir=DIR
使用 terraform -install-autocomplete安装自动完成脚本,使用 terraform -uninstall-autocomplete删除自动完成脚本。
很多子命令接受资源地址参数,下面是一些例子:
1 2 3 4 5 6 |
# 资源类型.资源名 aws_instance.foo # 资源类型.资源列表名[索引] aws_instance.bar[1] # 子模块foo的子模块bar中的 module.foo.module.bar.aws_instance.baz |
配置文件的路径可以通过环境变量 TF_CLI_CONFIG_FILE设置。非Windows系统中, $HOME/.terraformrc为默认配置文件路径。配置文件语法类似于TF文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
# provider缓存目录 plugin_cache_dir = "$HOME/.terraform.d/plugin-cache" # disable_checkpoint = true # 存放凭证信息,包括模块仓库、支持远程操作的系统的凭证 credentials "app.terraform.io" { token = "xxxxxx.atlasv1.zzzzzzzzzzzzz" } # 改变默认安装逻辑 provider_installation { # 为example.com提供本地文件系统镜像,这样安装example.com/*/*的provider时就不会去网络上请求 # 默认路径是: # ~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target} # 例如: # ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1 filesystem_mirror { path = "/usr/share/terraform/providers" include = ["example.com/*/*"] } direct { exclude = ["example.com/*/*"] } # Terraform会在terraform init的时候,校验Provider的版本和checksum。Provider从Registry或者本地 # 目录下载Provider。当我们开发Provider的时候,常常需要方便的测试临时Provider版本,这种Provider还 # 没有关联版本号,也没有在Registry中注册Chencksum # 为了简化开发,可以配置dev_overrides,它能覆盖所有配置的安装方法 dev_overrides { "hashicorp.com/edu/hashicups-pf" = "$(go env GOBIN)" } } |
配置工作目录,为使用其它命令做好准备。
Terraform命令需要在一个编写了Terraform配置文件的目录(配置根目录)下执行,它会在此目录下存储设置、缓存插件/模块,以及(默认使用Local后端时)存储状态数据。此目录必须进行初始化。
初始化后,会生成以下额外目录/文件:
.terraform目录,用于缓存provider和模块
如果使用Local后端,保存状态的terraform.tfstate文件。如果使用多工作区,则是terraform.tfstate.d目录。
对配置的某些变更,需要重新运行初始化,包括provider需求的变更、模块源/版本约束的变更、后端配置的变更。需要重新初始化时,其它命令可能会无法执行并提示你进行初始化。
命令 terraform get可以仅仅下载依赖的模块,而不执行其它init子任务。
运行 terraform init -upgrade会强制拉取最新的、匹配约束的版本并更新依赖锁文件。
校验配置是否合法。
显示执行计划,即当前配置将请求(结合state)哪些变更。Terraform的核心功能时创建、修改、删除基础设施对象,使基础设施的状态和当前配置匹配。当我们说运行Terraform时,主要是指plan/apply/destroy这几个命令。
terraform plan命令评估当前配置,确定其声明的所有资源的期望状态。然后比较此期望状态和真实基础设施的当前状态。它使用state来确定哪些真实基础设施对象和声明资源的对应关系,并且使用provider的API查询每个资源的当前状态。当确定到达期望状态需要执行哪些变更后,Terraform将其打印到控制台,它并不会执行任何实际的变更操作。
terraform plan命令得到的计划可以保存起来,并被后续的terraform apply使用:
1 |
terraform plan -out=FILE |
plan命令支持两种备选的工作模式:
- 销毁模式:创建一个计划,其目标是销毁所有当前存在于配置中的远程对象,留下一个空白的state。对应选项 -destroy
- 仅刷新模式:创建一个计划,其目标仅仅是更新state和根模块的输出值,以便和从Terraform之外对基础设施对象的变更匹配。对应选项 -refresh-only
使用选项 -var 'NAME=VALUE'可以指定输入变量,该选项可以使用多次。
使用选项 -var-file=FILENAME可以从文件读取输入变量,某些文件会自动读取,参考输入变量一节。
选项 -parallelism=n限制操作最大并行度,默认10。
选项 | 说明 |
-refresh=false |
默认情况下,Terraform在检查配置变更之前,会将state和远程基础设施进行同步。此选项禁用此行为 该选项可能会提升性能,因为减少了远程API请求数量。但是可能会无法识别某些Terraform外部对基础设施资源的变更 |
-replace=ADDRESS |
提示Terraform去计划替换掉具有指定ADDRESS的单个资源。对于0.15.2+可用,老版本可以使用terraform taint命令代替 ADDRESS就是针对某个资源实例的引用表达式,例如 aws_instance.example[0] |
-target=ADDRESS | 提示Terraform仅仅针对指定ADDRESS的资源(以及它依赖的资源)指定执行计划 |
-input=false | 禁止Terraform交互式的提示用户提供根模块的输入变量,对于批处理方式运行Terraform很重要 |
应用执行计划,创建、更新设施对象。
apply会做plan的任何事情,并在其基础上,直接执行变更操作。默认情况下,apply即席的执行一次plan,你也可以直接使用已保存的plan
命令格式: terraform apply [options] [plan file]
选项 -auto-approve可以自动确认并执行所需操作,不需要人工确认。
如果指定plan file参数,则读取先前保存的计划并执行。
支持plan命令中关于计划模式的选项。
-input=false、-parallelism=n等选项含义和plan命令相同。特有选项:
选项 | 说明 |
-lock-timeout=DURATION | 对状态加锁的最大时间 |
删除先前创建的基础设施对象。
当前配置(+工作区)管理的所有资源都会被删除,destroy会使用状态数据确定哪些资源需要删除。
在交互式命令中估算Terraform表达式。
格式化配置文件
强制解除当前工作区的状态锁。当其它terraform进程锁定状态后,没有正常解锁时使用。如果其它进程仍然在运作,可能导致状态不一致。
安装或升级远程Terraform模块。格式: terraform get [options] PATH。
选项:
-update 检查已经下载的模块的新版本,如果存在匹配约束的新版本则更新
-no-color 禁用彩色输出
生成操作中包含步骤的图形化表示。
导入现有的基础设施对象,让其关联到配置中的资源定义。
获取并保存远程服务(例如模块私服)的登录凭证。
删除远程服务(例如模块私服)的登录凭证。
显示根模块的输出值。格式: terraform output [options] [NAME]
显示此模块依赖的providers
更新状态,使其和远程基础设施匹配。
显示当前状态或保存的执行计划。
将资源实例标记为“非功能完备(fully functional)”的。
所谓非功能完备,通常意味着资源创建过程出现问题,存在部分失败。此外taint子命令也可以强制将资源标记为非功能完备。
因为上述两种途径,进入tainted状态的资源,不会立即影响基础设施对象。但是在下一次的plan中,会销毁并重新创建对应基础设施对象。
命令格式: terraform [global options] taint [options] <address>
解除资源的tainted状态。
管理和切换工作区。
配置文件由若干块(Block)组成,块的语法如下:
1 2 3 4 5 |
# Block header, which identifies a block <BLOCK TYPE> "<BLOCK LABEL>" "<BLOCK LABEL>" "..." { # Block body <IDENTIFIER> = <EXPRESSION> # Argument } |
块是一个容器,它的作用取决于块的类型。块常常用来描述某个资源的配置。
取决于块的类型,标签的数量可以是0-N个。对于resource块,标签数量为两个。某些特殊的块,可能支持任意数量的标签。某些内嵌的块,例如network_interface,则不支持标签。
块体中可以包含若干参数(Argument),或者其它内嵌的块。参数用于将一个表达式分配到一个标识符,常常对应某个资源的一条属性。表达式可以是字面值,或者引用其它的值,正是这种引用让Terraform能够识别资源依赖关系。
直接位于配置文件最外层的块,叫做顶级块(Top-level Block),Terraform支持有限种类的顶级块。大部分Terraform特性,例如resource,基于顶级块实现。
下面是一个例子:
1 2 3 |
resource "aws_vpc" "main" { cidr_block = var.base_cidr_block } |
在HCL语言中,Argument被称作Attribute。但是在TL中,Attribute术语另有它用。例如各种resource都具有一个名为id的属性,它可以被参数表达式饮用,但是不能被赋值(因而不是参数)。
参数其实一个赋值表达式,它将一个值分配给一个名称。
上下文决定了哪些参数可用,其类型是什么。不同的资源(由resource标签识别)支持不同的参数集。
参数名、块类型名、大部分Terraform特有结构的名字(例如resource名,即其第二标签),都是标识符。
标识符由字母、数字、-、_组成,第一个字符不能是数字。
#或者 //开头的是单行注释。 /**/作为多行注释的边界。
类型 | 说明 |
string |
Unicode字符序列,基本形式 "hello" |
number | 数字,形式 6.02 |
bool | true或 false |
list/tuple | 一系列的值,形式 ["us-west-1a", "us-west-1c"] |
map/object | 键值对,形式 {name = "Mabel", age = 52} |
空值使用 null表示。
转义字符:
\n 换行
\r 回车
\t 制表
\" 引号
\\ 反斜杠
\uNNNN Unicode字符
\UNNNNNNNN Unicode字符
注意,在Heredoc中反斜杠不用于转义,可以使用:
$${ 字符串插值标记${
%%{ 模板指令标记%{
支持Unix的Heredoc风格的字符串:
1 2 3 4 5 6 |
block { value = <<EOT hello world EOT } |
Heredoc还支持缩进编写:
1 2 3 4 5 6 |
block { value = <<-EOT hello world EOT } |
要将对象转换为JSON或YAML,可以调用函数:
1 2 3 4 |
example = jsonencode({ a = 1 b = "hello" }) |
不管在普通字符串格式,还是Heredoc中,都可以使用字符串模板。模板需要包围在 ${ 或 %{}中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# ${ ... } 中包含的是表达式 "Hello, ${var.name}!" # %{ ... } 则定义了一条模板指令,可以用于实现条件分支或循环 # %{if <BOOL>}/%{else}/%{endif} 条件分支 "Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!" # %{for <NAME> in <COLLECTION>} / %{endfor} 循环 <<EOT %{ for ip in aws_instance.example.*.private_ip } server ${ip} %{ endfor } EOT |
可以在模板指令开始或结尾添加 ~,用于去除前导或后缀的空白符:
1 2 3 4 5 6 7 |
<<EOT %{ for ip in aws_instance.example.*.private_ip ~} # 这后面的换行符被~去除 server ${ip} # 这后面的换行符被保留 %{ endfor ~} EOT |
<RESOURCE TYPE>.<NAME>引用指定类型、名称的资源。 其值可能是:
- 如果资源没有count/for_each参数,那么值是一个object,可以访问资源的属性
- 如果资源使用count参数,那么值是list
- 如果资源使用for_each参数,那么值是map
var.<NAME>引用指定名称的输入变量。如果变量使用type参数限定其类型,对此引用进行赋值时,Terraform会自动进行必要的类型转换。
local.<NAME>引用指定名称的本地值。本地值可以引用其它本地值,甚至是在同一个local块中,唯一的限制就是不得出现循环依赖。
module.<MODULE NAME>引用指定子模块声明的结果输出。如果module块(表示对目标模块的调用)没有count/for_each参数,则其值是一个object,可以直接访问某个输出值: module.<MODULE NAME>.<OUTPUT NAME>
如果module块使用了for_each,则引用的值是一个map,其key是for_each中的每个key,其值是子模块输出。
如果module块使用了count,则引用的值是一个list,其元素是子模块输出。
data.<DATA TYPE>.<NAME>引用指定类型的数据资源,取决于数据资源是否使用count/foreach,其值可能是object/list/map。
path.module 表达式所在模块的路径。
path.root表示当前配置根模块的路径。
path.cwd 表示当前工作模块的路径,默认情况下和path.root一样。但是Terraform CLI可以指定不同的工作目录。
terraform.workspace是当前选择的工作区的名称。
在特定的块内部,在特定的上下文下(例如使用count/for_each的情况下),可以引用一些特殊值:
count.index 使用count原参数时,表示当前索引
each.key /
each.value 使用for_each原参数时,表示当前迭代的键值
self可以在provisioner和connection块中使用,表示当前上下文资源
逻辑操作符:
!
&&
||
算数操作符:
*
/
%
+
-
比较操作符:
>, >=, <, <= ==, !=
函数调用:
1 2 3 4 |
<FUNCTION NAME>(<ARGUMENT 1>, <ARGUMENT 2>) # 支持参数展开 min([55, 2453, 2]...) |
1 2 3 |
condition ? true_val : false_val var.a != "" ? var.a : "default-a" |
使用for表达式可以通过转换一种复杂类型输出,生成另一个复杂类型结果。输入中的每个元素,可以对应结果的0-1个元素。任何表达式可以用于转换,下面是使用upper函数将列表转换为大写:
1 |
[for s in var.list : upper(s)] |
作为for表达式的输入的类型可以是list / set / tuple / map / object。可以为for声明两个临时符号,前一个表示index或key:
1 |
[for k, v in var.map : length(k) + length(v)] |
结果的类型取决于包围for表达式的定界符:
[] 表示生成的结果是元组
{} 表示生成的结果是object,你必须使用
=>符号:
{for s in var.list : s => upper(s)}
包含一个可选的if子句可以对输入元素进行过滤: [for s in var.list : upper(s) if s != ""]
示例:
1 2 3 4 5 6 7 8 9 10 11 12 |
variable "users" { type = map(object({ is_admin = boolean })) } locals { admin_users = { for name, user in var.users : name => user if user.is_admin } } |
for表达式可能将无序类型(map/object/set)转换为有序类型(list/tuple):
对于map/object,键/属性名的字典序,决定结果的顺序
对于set,如果值是字符串,则使用其字典序。如果值是其它类型,则结果的顺序可能随着Terraform的版本改变
如果输出是对象,通常要求键的唯一性。Terraform支持分组模式,允许键重复。要激活此模式,在表达式结尾添加 ...:
1 2 3 4 5 6 7 8 9 10 11 |
variable "users" { type = map(object({ role = string })) } locals { users_by_role = { for name, user in var.users : user.role => name... } } |
对于顶级块,表达式仅能在给参数赋值的时候,用在右侧。某些情况下,我们需要在特定条件下,重复、启用/禁用某个子块,表达式就没法实现了,此时可以利用dynamic块。
dynamic块可以遍历一个列表,为每个元素生成一个块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
resource "aws_elastic_beanstalk_environment" "tfenvtest" { name = "tf-test-name" application = "${aws_elastic_beanstalk_application.tftest.name}" solution_stack_name = "64bit Amazon Linux 2018.03 v2.11.4 running Go 1.12.6" # 对于每个设置,生成一个setting子块。dynamic块的标签,对应生成的块类型 dynamic "setting" { # 迭代对象 for_each = var.settings # 子块的标签,可选 labels: [] # 子块的体(参数) content { namespace = setting.value["namespace"] # 块类型.key对应映射的键或者列表的索引,.value对应当前迭代的值 name = setting.value["name"] value = setting.value["value"] } } } |
注意dynamic块仅可能为resource、data、provider、provisioner块生成参数(子块),不能用于生成源参数块,例如lifecycle。
允许dynamic块的多级嵌套。
splat表达式提供了更简单语法,在某些情况下代替for表达式:
1 2 3 4 5 6 7 |
[for o in var.list : o.id] # 等价于 var.list[*].id [for o in var.list : o.interfaces[0].name] # 等价于 var.list[*].interfaces[0].name |
注意splat表达式仅仅用于列表,不能用于map/object
splat表达式还有个特殊用途,将单值转换为列表:
1 |
for_each = var.website[*] |
module/provider的作者可以利用类型约束来校验用户输入。Terraform的类型系统比较强大,比如他不但可以限制类型为map,还可以规定其中有哪些键,每个键的值是什么类型。
复杂类型可以将多个值组合为单个值,复杂类型由类型构造器(type constructor)定义,复杂类型分为两类:
- 集合类型:组合相似(类型)的值
- 结构类型:组合可能不同的值
集合类型包括map/list/set等,我们可以限制集合的成员类型:
1 2 3 4 5 6 7 |
# 字符串列表 list(string) # 数字列表 list(number) # 任意元素列表,等价于list list(any) |
结构类型包括object和tuple。
object定义了多个命名的属性,以及属性的类型:
1 2 3 4 5 6 7 8 9 10 |
object( { <KEY> = <TYPE>, <KEY> = <TYPE>, ... } ) # 示例 object({ name=string, age=number }) # 下面是符合此类型的实例 { name = "John" age = 52 } |
元组则是定义了限定元素个数、每个元素类型的列表:
1 2 3 |
tuple([string, number, bool]) # 下面是符合此类型的实例 ["a", 15, true] |
大部分情况下,相似的集合类型和结构类型的行为类似。Terraform文档某些时候也不去区分,这是由于以下类型转换行为:
- 可能的情况下,Terraform会自动在相似复杂类型之间进行转换:
- object和map是相似的,只要map包含object的schema所要求的键集合,即可自动转换。多余的键在转换过程中被丢弃,这意味着map - object - mapl两重转换可能丢失信息
- tuple和list是相似的,但是转换仅仅在list元素数量恰好满足tuple的schema时发生
- set和tuple/list是相似的:
- 当list/tuple转换为set,重复的值会被丢弃,元素顺序消失
- 当set转换为list/tuple,元素的顺序是任意的,一个例外是set(string),将会按照元素字典序生成list/tuple
- 可能的情况下,Terraform会自动转换复杂类型的元素的类型,如果元素是复杂类型,则递归的处理
每当提供的值,和要求的值类型不一致时,自动转换都会发生。
module/provider的作者应该注意不同类型的差别,特别是在限制输入方面能力的不同。
特殊关键字any用做尚未决定的类型的占位符,其本身并非一个类型。当解释类型约束的时候,Terraform会尝试寻找单个的实际类型,替换any关键字,并满足约束。
例如,对于list(any)这一类型约束,对于给定的值["a", "b", "c"]其实际类型是tuple([string, string, string]),当将该值赋值给list(any)变量时,Terraform分析过程如下:
- tuple和list是相似类型,因此应用上问的tuple-list转换规则
- tuple的元素类型是string,满足any约束,因此将其替换,结果类型是list(string)
从0.14开始,支持这一实验特性。可以在object类型约束中,标注某个属性为可选的:
1 2 3 4 5 6 |
variable "with_optional_attribute" { type = object({ a = string # 必须属性 b = optional(string) # 可选属性 }) } |
默认情况下,对于必须属性来说,如果类型转换时的源(例如map)不具备对应的键,会导致报错。
版本约束是一个特殊的字符串值,在引用module、使用provider时,或者通过terraform块的required_version时,需要用到版本约束:
1 2 3 4 5 6 7 8 |
# 版本范围区间 version = ">= 1.2.0, < 2.0.0" # 操作符 = 等价于无操作符,限定特定版本 != 排除特定版本 > >= < <= 限制版本范围 ~> 允许最右侧的版本号片段的变化 |
所有可用函数:https://www.terraform.io/docs/language/functions/index.html
资源是TL语言中最重要的元素,由resource块定义。正如其名字所示,资源声明了某种云上基础设施的规格,这些基础设施可以是虚拟机、虚拟网络、DNS记录,等等。
数据源是一种特殊的资源,由data块定义。
当你第一次为某个资源编写配置时,它值存在于配置文件中,尚未代表云上的某个真实基础设施对象。通过应用Terraform配置,触发创建/更新/销毁等操作,实现云上环境和配置文件的匹配。
当一个新的资源被创建后,对应真实基础设施对象的标识符被保存到Terraform的State中。这个标识符作为后续更新/删除的依据。对于State中已经存在关联的标识符的那些Resource块,Terraform会比较真实基础设施对象和Resource参数的区别,并在必要的时候更新对象。
概括起来说,当Terraform配置被应用时:
- 创建存在于配置文件中,但是在State中没有关联真实基础设施对象的资源
- 销毁存在于State中,但是不存在于配置文件的资源
- 更新参数发生变化的资源
- 删除、重新创建参数发生变化,但是不能原地(in-palce)更新的资源。不能更新通常是因为云API的限制,例如腾讯云VPC的CIDR不支持修改
以上4点,适用于所有资源类型。但是需要注意,底层发生的事情,取决于Provider,Terraform只是去调用Provider的相应接口。
在相同模块中,你可以在表达式中访问某个资源的属性,语法 <RESOURCE TYPE>.<NAME>.<ATTRIBUTE>
除了配置文件中列出的资源参数之外,资源还提供一些只读的属性。属性表示了一些提供云API拉取到的信息。这些信息通畅需要在资源创建后才可获知,例如随机生成的资源ID vpc-d8o3c8vq
很多Provider还提供特殊的数据源(data)资源,这种特殊的资源仅仅用来查询信息。
某些资源必须在另外一些资源之后创建,例如CVM必须在其所属Subnet创建后才能创建,这意味着某些资源存在依赖关系。
Terraform能够自动分析资源依赖关系,其分析依据就是资源的参数的值表达式。如果表达式中引用了另一个资源,则当前资源依赖于该资源。
对于无法通过配置文件分析的隐含依赖,需要你手工配置 depends_on元参数。
Terraform会自动并行处理没有依赖关系的资源。
这类特殊的资源不会对应某个云上基础设施对象,而是仅仅存在于Terraform本地State中。Local-Only资源用于一些中间计算过程,包括生成随机ID、创建私钥等。
Local-Only资源的行为和普通资源一致,只是其结果数据仅仅存在于State中,删除时也仅仅是从State中移除对应数据。
1 2 3 |
resource "resource_type" "local_name" { # arguments... } |
两个标签分别代表资源的类型和本地名称。
资源类型提示正在描述的是那种云上基础设施,资源类型决定可用的参数集。本地名称用于在模块的其它地方饮用该资源,此外没有意义。资源类型+本地名称是资源的唯一标识,必须在模块范围内唯一。
每一种资源都由一个Provider来实现。Provider是Terraform的插件,它提供若干资源类型。通常一个云服务商提供提供一个Provider。初始化工作目录时Terraform能够自动从Terraform仓库下载大部分所需的Provider。
模块需要知道,利用哪些Provider才能管理所有的资源。此外Provider还需要经过配置才能工作,例如设置访问云API的凭证。这些配置由根模块负责。
元参数可以用于任何资源类型。
该元参数用于处理隐含的资源/模块依赖,这些依赖无法通过分析Terraform配置文件得到。从0.13版本开始,该元参数可用于模块。之前的版本仅仅用于资源。
depends_on的值是一个列表,其元素具必须是其它资源的引用,不支持任意表达式。
depends_on应当仅仅用作最后手段,避免滥用。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
resource "aws_iam_role" "example" { name = "example" assume_role_policy = "..." } # 这个策略允许运行在EC2中的实例访问S3 API resource "aws_iam_role_policy" "example" { name = "example" role = aws_iam_role.example.name policy = jsonencode({ "Statement" = [{ "Action" = "s3:*", "Effect" = "Allow", }], }) } resource "aws_iam_instance_profile" "example" { # 这是可以自动分析出的依赖 role = aws_iam_role.example.name } resource "aws_instance" "example" { ami = "ami-a1b2c3d4" instance_type = "t2.micro" # 这是可以自动分析出的依赖,包括传递性依赖 iam_instance_profile = aws_iam_instance_profile.example # 如果这个实例中的程序需要访问S3接口,我们需要用元参数显式的声明依赖 # 从而分配策略 depends_on = [ aws_iam_role_policy.example, ] } |
默认情况下,一个resource块代表单个云上基础设施对象。如果你想用一个resource块生成多个类似的资源,可以用count或for_each参数。
设置了此元参数的上下文中,可以访问名为 count的变量,它具有属性 index,为从0开始计数的资源实例索引。 示例:
1 2 3 4 5 6 7 8 9 10 11 12 |
resource "aws_instance" "server" { # 创建4个类似的实例 count = 4 ami = "ami-a1b2c3d4" instance_type = "t2.micro" tags = { # 实例的索引作为tag的一部分 Name = "Server ${count.index}" } } |
在当前模块的其它地方,可以用这样的语法访问如上资源实例:
1 2 3 |
<RESOURCE_TYPE>.<NAME>[<INDEX>] aws_instance.server[0] |
如果资源的规格几乎完全一致,可以用count,否则,需要使用更加灵活的for_each元参数。
for_each的值必须是一个映射或set(string),你可以在上下文中访问 each对象, 它具有 key和 value两个属性,如果for_each的值是集合,则key和value相等。示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
resource "azurerm_resource_group" "rg" { for_each = { a_group = "eastus" another_group = "westus2" } # 对于每个键值对都会生成azurerm_resource_group资源 name = each.key location = each.value } resource "aws_iam_user" "the-accounts" { # 数组转换为集合 for_each = toset( ["Todd", "James", "Alice", "Dottie"] ) name = each.key } |
调用子模块时使用for_each的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# 父模块 my_buckets.tf module "bucket" { for_each = toset(["assets", "media"]) # 调用publish_bucket子模块 source = "./publish_bucket" # 将键赋值给子模块的name变量 name = "${each.key}_bucket" } # 子模块 文件名 # publish_bucket/bucket-and-cloudfront.tf # 声明模块的输入参数 variable "name" {} resource "aws_s3_bucket" "example" { # 访问变量 bucket = var.name # ... } |
关于for_each的值的元素,有如下限制:
- 键必须是确定的值,如果应用配置前值无法确定会报错。例如,你不能引用CVM的ID,因为这个ID必须在配置应用之后才可知
- 如果键是函数调用,则此函数不能是impure的(非幂等),impure函数包括uuid/bcrypt/timestamp等
- 敏感(Sensitive)值不能用做键值。需要注意,大部分函数,在接受敏感值参数后,返回值仍然是敏感的
当使用集合的时候,你必须明确的将值转换为集合。例如通过 toset函数将列表、元组转换为集合。使用 flatten函数可以将多层次的嵌套结构转换为列表。
for_each所在块定义的资源A,可以赋值给另一个资源B的for_each参数。这时,B那个for_each的键值对,值是资源的完整(创建或更新过的)实例。这种用法叫chaining:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
variable "vpcs" { # 这里定义了map类型的变量,并且限定了map具有的键 type = map(object({ cidr_block = string })) } # 创建多个VPC资源 resource "aws_vpc" "example" { for_each = var.vpcs cidr_block = each.value.cidr_block } # 上述资源作为下面那个for_each的值 # 创建对应数量的网关资源 resource "aws_internet_gateway" "example" { # 为每个VPC创建一个网关 # 资源作为值 for_each = aws_vpc.example # 映射的值,在这里是完整的VPC对象 vpc_id = each.value.id } # 输出所有VPC ID output "vpc_ids" { value = { for k, v in aws_vpc.example : k => v.id } # 显式依赖网关资源,确保网关创建后,输出才可用 depends_on = [aws_internet_gateway.example] } |
引用for_each创建的资源的实例时,使用如下语法:
1 2 3 |
<TYPE>.<NAME>[<KEY>] azurerm_resource_group.rg["a_group"] |
这个参数用于指定使用的provider配置。可以覆盖Terraform的默认行为:将资源类型名的第一段(下划线分隔)作为provider的本地名称。同时使用provider的默认配置。例如资源类型google_compute_instance默认识别为google这个provider。
每个provider可以提供多个配置,配置常常用于管理多区域服务中的某个特定Region。每个provider可以有一个默认配置。
该参数的值必须是不被引号包围的 <PROVIDER>.<ALIAS>。使用该参数你可以显式的指定provider及其配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# 默认配置 provider "google" { region = "us-central1" } # 备选配置 provider "google" { alias = "europe" region = "europe-west1" } resource "google_compute_instance" "example" { # 通过使用该参数选择备选配置 provider = google.europe # ... } |
注意:资源总是隐含对它的provider的依赖,这确保创建资源前provider被配置好。
该参数可以对资源的生命周期进行控制。示例:
1 2 3 4 5 6 |
resource "azurerm_resource_group" "example" { # ... lifecycle { create_before_destroy = true } } |
lifecycle作为一个块,只能出现在resources块内。可用参数包括:
参数 | 说明 |
create_before_destroy |
默认情况下, 当Terraform无法进行in-place更新时,会删除并重新创建资源 该参数可以修改此行为:先创建新资源,然后删除旧资源 |
prevent_destroy | 如果设置为true,并且配置会导致基础设施中某个对象被删除,则报错 |
ignore_changes |
默认情况下,Terraform会对比真实基础设施中对象和当前配置文件中的所有字段,任何字段的不一致都会引发更新操作 该参数指定,在Terraform评估是否需要更新时,资源的哪些字段被忽略 如果设置为特殊值 all,则Terraform不会进行任何更新操作 |
某些资源类型提供了特殊的 timeouts内嵌块,用于指定多长时间后认定操作失败:
1 2 3 4 5 6 7 8 |
resource /* ... */ { # ... timeouts { create = "60m" update = "30s" delete = "2h" } } |
资源的绝大部分参数由资源类型决定。需要翻阅Provider的文档了解哪些参数可用。
对于大部分公共的、托管在Terraform仓库的Provider来说,其文档可以直接在仓库网站上获得。
对于一些无法使用Terraform声明式模型来表达的某些行为,可以使用Provisioner作为最后(总是不推荐的)手段。
使用Provisioner会引入复杂性和不确定性:
- Terraform无法将Provisioner执行的动作,作为计划的一部分。因为Provisioner理论上可以做任何事情
- Provisioner通常需要使用更多细节信息,例如直接访问服务器的网络、使用Terraform的登录凭证
Provisioner块不能用名字访问其所在的上下文资源,你必须使用 self对象。这个对象就指代对应的资源,你可以访问它的参数和属性。
该参数指定何时(create/update/destroy)运行Provisioner,下面是一个仅仅在删除资源时才执行的Provisioner:
1 2 3 4 5 6 7 |
resource "aws_instance" "web" { provisioner "local-exec" { # 在实际删除前调用 when = destroy command = "echo 'Destroy-time provisioner'" } } |
默认情况下,Provisioner在资源创建的时候调用,在更新/删除时不会调用。最常见的用法是使用Provisioner来初始化系统。如果Provisioner失败,则资源被标记为tainted,并且会在下一次 terraform apply时删除、重新创建。
定制Provisioner失败时的行为:
- continue 忽略错误
- fail 默认行为,导致配置应用立即失败,如果正在创建资源,则taint该资源
大部分Provisioner要求通过SSH或WinRM来访问远程资源。你可以在 connection块中声明如何连接。connection块可以内嵌在以下位置:
- resource,对资源的所有Provisioner生效
- provisioner,仅仅对当前Provisioner生效
在connection块中,你也可以使用 self来访问包含它的resource,这一点类似于provisioner
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
provisioner "file" { # Linux connection { type = "ssh" user = "root" password = "${var.root_password}" host = "${var.host}" } } provisioner "file" { # Windows connection { type = "winrm" user = "Administrator" password = "${var.admin_password}" host = "${var.host}" } } |
关于如何通过证书进行身份验证,如何通过堡垒机连接,参考官方文档。
如果你希望运行一个Provisioner,但是不想在任何真实的资源的上下文下运行,可以使用这种特殊的资源。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
resource "aws_instance" "cluster" { count = 3 # ... } resource "null_resource" "cluster" { # Provisioner的触发时机 triggers = { # cluster中任何实例改变会触发Provisioner的重新执行 # 这种通配符语法会得到一个列表 cluster_instance_ids = "${join(",", aws_instance.cluster.*.id)}" } # 仅仅连接到第一个实例 connection { # 该函数访问列表的特定元素 host = "${element(aws_instance.cluster.*.public_ip, 0)}" } provisioner "remote-exec" { # 执行命令 inline = [ "bootstrap-cluster.sh ${join(" ", aws_instance.cluster.*.private_ip)}", ] } } |
Provisioner | 说明 | ||
file |
从运行Terraform的机器,复制文件或目录到新创建的资源(通常是虚拟机)。示例:
关于整个目录的拷贝,需要注意:
|
||
local-exec |
在资源创建之后,调用一个本地(运行Terraform的机器)可执行程序 注意:即使是在资源创建之后,但是不保证sshd这样的服务已经可用了。因此不要尝试在local-exec中调用ssh命令登录到资源
|
||
remote-exec |
在资源创建之后,登录到新创建的资源,执行命令
|
数据资源,由data块来描述,让Terraform从数据源读取信息。Data是一种特殊的Resource,也是由Provider来提供。Data只支持读取操作,相对的,普通的Resource支持增删改查操作,普通资源也叫受管(Manged)资源,一般简称Resource。
数据资源要求Terraform去特定数据源(第1标签)读取数据,并将结果导出为本地名称(第2标签):
1 2 3 4 5 6 7 8 9 10 11 |
# 读取匹配参数的aws_ami类型的数据源,并存放到example data "aws_ami" "example" { # 这些参数是查询条件(query constraints),具体哪些可用取决于数据源类型 most_recent = true owners = ["self"] tags = { Name = "app-server" Tested = "true" } } |
两个标签组合起来是data在模块中的唯一标识。
如果数据资源的查询约束(参数)都是常量值,或者是已知的变量值,那么数据资源的状态会在Terraform的Refresh阶段更新,这个阶段在创建执行计划之前。
查询约束可能引用了某些值,这些值在应用配置文件之前无法获知(例如资源ID)。这种情况下,读取数据源的操作将推迟到Apply之后。在Data读取完成之前,所有对它结果(导出名称)的引用都是不可用的。
大部分数据源都对应了某种云上基础设施,需要通过云API远程的读取。
某些特殊数据源仅仅供Terraform自己使用,例如Hashicorp的Provider template,它提供的template_file数据源,用于在本地渲染模板文件。这类数据源叫Local-Only数据源,其行为和一般数据源没有区别。
数据资源的依赖解析行为,和受管资源一致。
数据资源支持的元参数,和受管资源一致。
和传统编程语言对比,模块类似于函数,输入变量类似于函数参数,输出值类似于返回值,本地值则类似于函数内的局部变量。
输入变量作为Terraform模块的参数,从而实现模块的参数化、可跨多个配置复用。
对于定义在根模块中的变量,其值可以从Terraform CLI选项传入。对于子模块中定义的变量,则必须通过module块传入其值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# 声明一个字符串类型的输入变量 # 变量名必须在模块范围内唯一 # 不得用做变量名:ource, version, providers, count, for_each, lifecycle, depends_on, locals variable "image_id" { # 类型 type = string # 描述 description = "" # 校验规则 validation { # 如果为true则校验成功 condition = bool-expr # 校验失败时的消息 error_message = "" } # 是否敏感,敏感信息不会现在Terraform UI上输出 sensitive = false } # 声明一个字符串的列表,并给出默认值 variable "availability_zone_names" { type = list(string) default = ["us-west-1a"] } # 声明一个对象的列表,并给出默认值 variable "docker_ports" { type = list(object({ internal = number external = number protocol = string })) default = [ { internal = 8300 external = 8300 protocol = "tcp" } ] } |
支持的简单类型: string、 number、 bool
支持的容器类型:
- 列表: list(<TYPE>)
- 集合: set(<TYPE>)
- 映射: map(<TYPE>)
- 对象: object({<ATTR NAME> = <TYPE>, ... })
- 元组: tuple([<TYPE>, ...])
关键字 any表示,任何类型都允许。
如果同时指定了类型和默认值,则提供的默认值必须可以转换为类型。
validation是一个块,其中condition是一个bool表达式:
1 2 3 4 5 6 7 8 9 10 11 |
variable "image_id" { type = string description = "The id of the machine image (AMI) to use for the server." validation { # can函数将错误转换为false # regex函数在找不到匹配的时候会失败 condition = can(regex("^ami-", var.image_id)) error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"." } } |
在声明输入变量的模块中,可以使用 var.<NAME>引用输入变量的值:
1 2 3 4 |
resource "aws_instance" "example" { instance_type = "t2.micro" ami = var.image_id } |
要给根模块中定义的变量赋值,有以下几种方式:
- 使用
-var命令行选项,可以多次使用,每次赋值一个变量,示例:
123terraform apply -var="image_id=ami-abc123"terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro"terraform apply -var='image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}' - 作为环境变量传入,示例:
12# 环境变量需要TF_VAR_前缀export TF_VAR_image_id=ami-abc123 - 使用
.tfvars文件,此文件可以自动载入或者通过命令行选项显式载入,示例:
12345image_id = "ami-abc123"availability_zone_names = ["us-east-1a","us-west-1c",]
1terraform apply -var-file="testing.tfvars"注意以下文件可以自动识别并载入:
- 名为 terraform.tfvars或 terraform.tfvars.json的文件
- 以 .auto.tfvars或 .auto.tfvars.json结尾的文件
如果通过多种方式给变量赋值,则优先级高的生效。优先级顺序从低到高:
- 环境变量文件
- terraform.tfvars
- terraform.tfvars.json
- *.auto.tfvars和*.auto.tfvars.json文件,多个文件,按字典序,后面的优先级高
- -var或-var-file命令行选项,多个选项,后面的优先级高
输出值是模块的“返回值”,具有以下用途:
- 子模块使用输出值将它创建的资源的属性的子集暴露给父模块
- 根模块可以利用输出值,将一些信息在terraform apply之后打印到控制台上
- 当使用远程状态(remote state)时,根模块的输出可以被其它配置通过 terraform_remote_state 数据源捕获到
1 2 3 4 5 6 7 8 9 10 11 |
# 输出的名字 output "instance_ip_addr" { # 值 value = aws_instance.server.private_ip # 描述 description = "" # 是否敏感 sensitive = "" # 依赖 depends_on = [] } |
子模块的输出值,通过这样的表达式访问:
1 2 3 4 |
module.<MODULE NAME>.<OUTPUT NAME> # 访问子模块web_server的输出值instance_ip_addr module.web_server.instance_ip_addr |
使用depends_on参数可以明确指定输出值依赖什么:
1 2 3 4 5 6 |
output "instance_ip_addr" { value = aws_instance.server.private_ip depends_on = [ aws_security_group_rule.local_access, ] } |
在一个模块内部,将表达式分配给一个名称。你可以同时声明多个本地值:
1 2 3 4 5 6 7 8 |
locals { service_name = "forum" instance_ids = concat(aws_instance.blue.*.id, aws_instance.green.*.id) common_tags = { Service = local.service_name Owner = local.owner } } |
引用本地值时,使用表达式: local.<NAME>
一套完整的配置文件(Configuration,简称配置),由1个根目录和若干文件/子目录组成。配置文件的扩展名为 .tf,此外HCL语言还提供了基于JSON的变体,这种配置文件的扩展名为 .tf.json。配置文件的编码方式为UTF-8,使用Unix风格的换行符。
所谓模块(Module)是存放在一个目录中的配置文件的集合。子目录中的文件不属于模块,不会自动包含到配置中。
将块存放在不同配置文件中仅仅是方便人类的阅读,和Terraform的行为无关。Terraform总是会评估模块中的所有文件,并将它们合并为单一的文档来看待。
Terraform命令总是在单个根模块的上下文中运行,根模块的目录通常作为命令的工作目录。此根模块会调用其它模块,这种调用关系会递归的发生,从而形成一个子模块树结构。
一个模块(通常是根模块)可以调用其它模块,从而将这些模块中定义的资源包含到配置中。当一个模块被其它模块调用时,它的角色是子模块。
子模块可以被同一个配置调用多次,不同模块也可以同时调用一个子模块。
引用子模块时,除了可以从本地文件系统获取,还可以从公共/私有仓库下载。
Terraform Registry托管了大量公共模块,用于管理各种各样的基础设施,这些模块可以被免费使用。Terraform能够自动下载这些模块的正确版本。Terraform Cloud / Enterprise版本都包含一个私服模块可以托管组织私有的模块。
使用名为 override.tf或 override.tf.json,或者后缀为 _override.tf或 _override.tf.json的文件,可以覆盖既有配置的某一部分。这些文件叫做覆盖文件。
加载配置时,Terraform最初会跳过覆盖文件。加载完成功文件后,会按照字典序处理覆盖文件。对于覆盖文件中的每个顶级块,Terraform会尝试寻找已经存在的,定义在常规文件中的对应块,并将块的内容进行合并。内容合并规则如下:
- 覆盖文件中的顶级块、普通配置文件中的顶级块,对应关系通过块头(块类型+标签)识别,相同块头的块被合并
- 顶级块中的参数被替换
- 顶级块中的内嵌块被替换,不会递归的进行合并
- 多个覆盖文件覆盖了同一个块的定义时,按覆盖文件名的字典序依次合并
此外,对于resource / data块,有如下特殊规则:
- 内嵌的lifecycle块不是简单的直接替换。假设覆盖文件仅仅设置了lifecycle的create_before_destroy属性,原始配置中任何ignore_changes参数保持原样
- 对于内嵌的provisioner块,原始配置中的(不管有几个)provisioner块直接被忽略
- 原始配置中的内嵌connection块被完全覆盖
- 元参数(meta-argument)depends_on不能出现在覆盖文件中
对于variable(变量)块,有如下特殊规则:
- 如果原始块定义了default参数(默认值)并且覆盖块修改了变量的type,则Terraform尝试将default转换为新的type,如果转换无法自动完成则报错
- 如果覆盖块修改了default,那么其值必须匹配原始块中的type
不建议过多的使用覆盖文件,这会降低配置的可读性。
对于output块,有如下特殊规则:
- 元参数depends_on不能出现在覆盖文件中
对于local块,有如下特殊规则:
- 每个local块定义(或修改)了若干具有名字的值,覆盖时使用value-by-value的方式,至于值在何处定义不影响
对于terraform块,有如下特殊规则:
- required_providers的值,按element-by-element的方式进行覆盖。这样,覆盖块可以仅仅调整单个provider的配置,而不影响其他providers
- 覆盖required_version、required_providers时,被覆盖的元素被整个替换
下面是原始文件+覆盖文件的示例:
1 2 3 4 5 6 7 8 9 10 |
# example.tf resource "aws_instance" "web" { instance_type = "t2.micro" ami = "ami-408c7f28" } # override.tf resource "aws_instance" "web" { ami = "foo" } |
所谓调用,就是将特定的值传递给子模块作为输入变量,从而将子模块中的配置包含进来。
1 2 3 4 5 6 7 8 9 10 11 |
module "servers" { # 源,必须,指定子模块的位置 source = "./app-cluster" # 版本,如果模块位于仓库中,建议制定 version = "0.0.5" # 支持一些元参数 # 大部分其它参数,都是为子模块提供输入变量 servers = 5 } |
元参数 | 说明 | ||
count | 用于创建多个模块实例,参考资源(resources)的元参数 | ||
for_each | 用于创建多个模块实例,参考资源(resources)的元参数 | ||
providers |
将Provider配置传递给子模块:
如果子模块没有定义任何Provider alias,则该元参数是可选的。不指定该元参数时,子模块会从父模块继承所有默认Provider配置,所谓默认配置就是没有alias的provider块所定义的配置 |
||
depends_on |
显式指定整个模块对特定目标的依赖,参考资源(resources)的元参数 |
module块的source参数指定了从何处下载子模块的代码。
terraform init中有一个步骤,专门负责模块的安装。它支持从本地路径、Terraform Registry、GitHub、一般性Git仓库、HTTP URL、S3桶等多种来源下载模块。
对于紧密相关的模块,例如为了减少代码重复,从单一模块拆分得到的,建议使用本地路径方式存储。
1 2 3 4 |
module "consul" { # 必须以./或../开头,提示这是一个本地模块 source = "./consul" } |
本地路径的源具有一个特殊的地方,它没有“安装”这个步骤。
对于希望公开分享的模块,可以存放在这个Terraform仓库中。这种仓库的源地址格式为 <HOSTNAME>/<NAMESPACE>/<NAME>/<PROVIDER>。示例:
1 2 3 4 5 6 |
# 这个模块创建一个Consul服务 module "consul" { # 为AWS提供的consule模块,使用Terraform公共仓库时HOSTNAME可以省略 source = "hashicorp/consul/aws" version = "0.1.0" } |
为了访问私有仓库,你可能需要在CLI配置中添加访问令牌。
如果source以github.com开头,则Terraform会尝试到GitHub拉取模块源码:
1 2 3 4 5 6 7 8 9 |
module "consul" { # 通过HTTPS source = "github.com/hashicorp/example" } module "consul" { # 通过SSH source = "git@github.com:hashicorp/example.git" } |
如果source以 git::开头,则Terraform认为模块托管在一般性的Git服务器中:
1 2 3 4 5 6 7 |
module "vpc" { source = "git::https://example.com/vpc.git" } module "storage" { source = "git::ssh://username@example.com/storage.git" } |
Terraform使用git clone命令下载模块,因此本地机器的任何Git配置都可用,包括凭证信息。
默认情况下,使用Git仓库的HEAD,要选择其它修订版,使用ref参数:
1 2 3 |
module "vpc" { source = "git::https://example.com/vpc.git?ref=v1.2.0" } |
任何可以作为git checkout参数的值,都可以作为ref参数。
如果source指定为一个普通的URL,那么Terraform会:
- 附加GET参数 terraform-get=1,请求那个URL
- 如果得到2xx应答,那么尝试从以下位置读取模块实际地址:
- 响应头 X-Terraform-Get
- HTML元素:
1<meta name="terraform-get" content="github.com/hashicorp/example" />
如果URL的结尾是可识别的压缩格式扩展名(zip tar.bz2 tbz2 tar.gz tgz tar.xz txz)则Terraform会跳过上面处理过程,直接下载压缩包:
1 2 3 |
module "vpc" { source = "https://example.com/vpc-module?archive=zip" } |
如果需要的模块位于源的子目录中,可以使用特殊的 //语法来引用:
hashicorp/consul/aws//modules/consul-cluster
git::https://example.com/network.git//modules/vpc
https://example.com/network-module.zip//modules/vpc
s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/network.zip//modules/vpc
git::https://example.com/network.git//modules/vpc?ref=v1.2.0
开发模块就是构思和编写TF文件的过程,你需要考虑模块的输入(变量)、输出(值),模块读取和创建哪些资源。你只需要将这些TF文件放在一个目录中就可以了。
如果开发的模块可能被很多配置复用,建议在独立的版本库中管理。
尽管内嵌多个子目录(作为子模块)是允许的,但是建议尽可能的让目录扁平化,可以使用模块组合,而避免深层次的目录树,这可以增强可复用性。
过度的模块化容易让配置难以理解。
一个好的模块应该通过描述架构中新概念来提升抽象层次,这个概念由一些Provider中的基本元素组成。例如,我们想基于一些CVM、一个CLB构建一个Redis集群,这样的集群就适合封装在一个模块中。
永远不要开发那种仅仅为了包装单个其它资源的模块。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
$ tree complete-module/ # 根模块,这是标准模块结构中唯一必须的元素 . ├── README.md # 文档 ├── main.tf # 建议文件名,模块主入口点,资源在此创建 ├── variables.tf # 定义模块的输入变量 ├── outputs.tf # 定义模块的输出值 ├── ... ├── modules/ # 嵌套的子模块 │ ├── nestedA/ │ │ ├── README.md │ │ ├── variables.tf │ │ ├── main.tf │ │ ├── outputs.tf │ ├── nestedB/ │ ├── .../ ├── examples/ # 使用模块的样例 │ ├── exampleA/ │ │ ├── main.tf │ ├── exampleB/ │ ├── .../ |
一个简单的Terraform配置,仅仅包含一个根模块,我们在这个扁平的结构中创建所有资源:
1 2 3 4 5 6 7 8 9 10 |
resource "aws_vpc" "example" { cidr_block = "10.1.0.0/16" } resource "aws_subnet" "example" { vpc_id = aws_vpc.example.id availability_zone = "us-west-2b" cidr_block = cidrsubnet(aws_vpc.example.cidr_block, 4, 1) } |
当引入 modules块后,配置变成层次化的,每个模块可以创建自己的资源,甚至有自己的下级子模块。 无节制的使用子模块会导致很深的树状配置结构,这是反模式。
Terraform建议保持配置的扁平,仅仅引入一层子模块。假设我们需要在AWS上创建一个Consul集群,它依赖一个子网。我们可以将子网的创建封装到模块:
1 2 3 4 5 |
module "network" { source = "./modules/aws-network" base_cidr_block = "10.0.0.0/8" } |
将Consul集群的创建封装到另外一个模块:
1 2 3 4 5 6 |
module "consul_cluster" { source = "./modules/aws-consul-cluster" vpc_id = module.network.vpc_id subnet_ids = module.network.subnet_ids } |
根模块通过上面两个module块使用这两个子模块,这种简单的单层结构叫模块组合( module composition)
上面这个例子也体现了依赖反转的原则: consul_cluster需要一个网络,但是不是它(这个模块)自己去创建网络,而是由外部创建并注入给它。
在未来进一步的重构中,可能由另外一个配置负责创建网络,而仅仅通过数据资源将读取网络信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
data "aws_vpc" "main" { tags = { Environment = "production" } } data "aws_subnet_ids" "main" { vpc_id = data.aws_vpc.main.id } module "consul_cluster" { source = "./modules/aws-consul-cluster" vpc_id = data.aws_vpc.main.id subnet_ids = data.aws_subnet_ids.main.ids } |
开发可复用模块时,一个很常见的情况是,某个依赖的资源在某些条件下不需要创建 —— 例如在某些环境下,资源预先已经存在。
这种情况下同样要应用依赖反转原则:将这些可能需要创建的依赖资源作为模块的输入参数:
1 2 3 4 5 6 7 8 |
# 下面这个变量代表依赖的资源 variable "ami" { type = object({ # 仅仅需要定义对于本模块有意义的属性 id = string architecture = string }) } |
由模块的调用者决定创建依赖资源:
1 2 3 4 5 6 7 8 9 10 |
resource "aws_ami" "example" { name = "local-copy-of-ami" source_ami_id = "ami-abc123" source_ami_region = "eu-west-1" } module "example" { source = "./modules/example" ami = aws_ami.example } |
还是直接使用已经存在的依赖资源:
1 2 3 4 5 6 7 8 9 10 11 12 |
data "aws_ami" "example" { owner = "10000" tags = { application = "example-app" environment = "dev" } } module "example" { source = "./modules/example" ami = data.aws_ami.example } |
因为,只有调用者才清楚实际环境是什么样的。
Terraform本身没有提供适配多个云服务商的相似资源的抽象,这会屏蔽云服务商的差异性。尽管如此,作为Terraform用户来说,进行多云抽象是常见需求,特别是云迁移这样的应用场景。
举例来说,任何一个云服务商的域名系统都支持域名解析。但是某些云服务商可能提供智能负载均衡、地理位置感知的解析这样高级特性。我们可以将域名系统的公共特性抽象为模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
module "webserver" { source = "./modules/webserver" } locals { fixed_recordsets = [ { name = "www" type = "CNAME" ttl = 3600 records = [ "webserver01", "webserver02", ] }, ] server_recordsets = [ for i, addr in module.webserver.public_ip_addrs : { name = format("webserver%02d", i) type = "A" records = [addr] } ] } |
上面的recordset,抽象了所有域名系统都应该支持的DNS记录集资源。
当针对某个云服务商实现此资源时,我们可以开发一个模块。
1 2 3 4 5 6 |
# 此模块在AWS Route53上实现了记录集资源 module "dns_records" { source = "./modules/route53-dns-records" route53_zone_id = var.route53_zone_id recordsets = concat(local.fixed_recordsets, local.server_recordsets) } |
需要切换云服务商时,只需要替换上述dns_records模块的source即可,指向对应的模块即可。所有这些模块都需要定义输入参数:
1 2 3 4 5 6 7 8 |
variable "recordsets" { type = list(object({ name = string type = string ttl = number records = list(string) })) } |
大部分模块都会描述需要被创建和管理的基础设施对象,偶尔的情况下,模块仅仅去抓取需要的信息。
其中一种情况是,一套系统被划分为多个子系统,这些子系统都需要获取某种信息,这些信息可以用由一个data-only模块抓取。
出于复用目的的模块可以发布到Terraform Registry, 这样模块可以很容易被所有人使用。如果仅仅在组织内部共享,可以发布到私有仓库。
通过Git、S3、HTTP等方式发布模块也是可以的,模块支持多种源。
所谓提供者,就是Terraform的插件/驱动,负责和特定云厂商的API或者其它任何API打交道。
每个Provider都可以提供若干受管资源、数据资源,供Terraform使用。 反过来说,任何资源都是由Provider提供,没有Provider,Terraform什么都做不了。
Provider和Terraform完全独立的分发,公共的Provider托管在Terraform仓库(Registry)。
在每次Terraform运行过程中,需要的Provider会自动被安装。
Terraform CLI会在初始化工作目录的时候安装Provider,它能够自动从Terraform Registry下载Provider,或者从本地镜像/缓存加载。要指定缓存位置,在CLI配置文件中设置 plugin_cache_dir。或者设置环境变量 TF_PLUGIN_CACHE_DIR
为了保证,针对一套配置,总是安装相同版本的Provider,可以使用CLI创建一个依赖锁文件,并将此文件和配置一起纳入版本管理。
每个模块都必须声明它需要哪些Provider,这样Terraform才能够安装和使用它们。声明在 required_providers块中进行:
1 2 3 4 5 6 7 8 9 10 11 12 |
terraform { required_providers { # 该块的每个参数,启用一个Provider # key是Provider的本地名称,必须是模块范围内的唯一标识符 mycloud = { # 源地址 source = "mycorp/mycloud" # 版本约束 version = "~> 1.0" } } } |
在required_providers块之外,Terraform总是使用本地名称来引用Provider:
1 2 3 |
provider "mycloud" { # 配置mycloud这个Provider } |
Provider的源地址,是它的全局标识符,这个地址当然也指明了应该从何处下载Provider。
源地址的格式为: [<HOSTNAME>/]<NAMESPACE>/<TYPE>,各字段说明如下:
- 可选的主机名,默认registry.terraform.io,即Terraform Registry的主机名
- 命名空间,通常是发布Provider的组织
- 类型,通常是Provider管理的平台/系统的简短名称
>= 1.0表示要求1.0或更高版本。 ~> 1.0.4表示仅仅允许1.0.x版本。
目前仅有一个内置于Terraform的Provider,名为terraform_remote_state。你不需要再配置文件中引入它,尽管如此它还是有自己的源地址terraform.io/builtin/terraform。
某些组织可能会开发自己的Provider,以管理特殊的基础设施。他们可能希望在Terraform中使用这些Provider,但是却不将其发布到公共仓库中。这种情况下,,构建自己的私有仓库。更简单的,可以使用更简单的Provider安装方法,例如自己将其存放在本地文件系统的特定目录。
任何Provider都必须有源地址,源地址必须包含(或者隐含默认值)一个主机名。如果通过本地文件系统分发Provider,则这个主机名只是个占位符,甚至不需要可解析。你通常可以使用terraform.yourcompany.com作为主机名。你可以这样引入私有Provider:
1 2 3 4 5 6 7 8 |
terraform { required_providers { mycloud = { source = "terraform.example.com/examplecorp/ourcloud" version = ">= 1.0" } } } |
选择一个隐式本地镜像目录(implied local mirror directories ),并创建目录terraform.example.com/examplecorp/ourcloud/1.0.0,在此目录中创建一个代表运行平台的子目录,例如linux_amd64,并将Provider的可执行文件、以及任何其它需要的文件存放到其中即可。对于Windows,可执行文件的路径可能是terraform.example.com/examplecorp/ourcloud/1.0.0/windows_amd64/terraform-provider-ourcloud.exe
除了引入Provider,你可能还需要对其进行配置才能使用。
配置时,使用provider块。只有根模块才可以配置一个Provider。子模块会自动从根模块继承Provider配置。示例:
1 2 3 4 5 |
# 本地名称,引入Provider时指定 provider "google" { project = "acme-app" region = "us-central1" } |
具体哪些配置参数可用,取决于Provider。配置时可以使用表达式,但是只能引用那些应用配置之前即可知的值 —— 可以安全的引用输入变量,但是不能使用那些由资源导出的属性。
Provider支持两个元参数,其中一个是alias,用于定义备选的Provider配置:
1 2 3 4 |
provider "aws" { alias = "west" region = "us-west-2" } |
声明资源时,可以指定使用备选的Provider配置:
1 2 3 |
resource "aws_instance" "foo" { provider = aws.west } |
这个元参数已经弃用,是旧的管理Provider版本的方式。
Terraform配置文件可以引用两类外部依赖:
- Providers,如上个章节所述,用于和外部系统交互的插件
- Modules,可复用的配置文件集合
这两类依赖都可以独立发布,并进行版本管理。引用这些依赖时,Terraform需要知道使用什么版本。
配置文件中的版本约束,指定了潜在的兼容性版本范围。但是到底选择(并锁定使用)依赖的哪个版本,由名为.terraform.lock.hcl的依赖锁文件决定。注意,当前依赖锁文件仅仅管理Provider的版本,对于Module,仍然总是拉取匹配版本约束的最新版本。
每当运行 terraform init命令时,依赖锁文件会自动创建/更新。此文件应该纳入版本管理。依赖锁文件使用和TF类似的语法。
运行terraform init时,如果:
- 依赖没有记录在依赖锁文件中,则尝试拉取匹配版本约束的最新版本。并将获取到的版本信息记录到依赖锁文件
- 依赖已经记录,则使用记录的版本
运行 terraform init -upgrade会强制拉取最新的、匹配约束的版本并更新依赖锁文件。
Terraform逻辑上划分为两个部分:核心和插件。Terraform核心通过RPC来调用插件。Terraform支持多种发现和加载插件的方式。Terraform插件有两类
- Provider:通常用于对接到某特定云服务商,在其上创建基础设施对象
- Provisioner:对接到某种provisioner,例如Bash
核心的职责包括:
- 基础设施即代码:读取和解释配置文件和模块
- 资源状态管理
- 构造资源依赖图
- 执行计划
- 通过RPC和插件交互
插件和核心一样,基于Go语言编写。Terraform使用的所有Provider和Provisioner都是插件,它们在独立进程中运行。
Provider插件的职责是:
- 初始化任何必要的库,用于进行API调用
- 与基础设施提供者进行交互
- 定义映射到特定服务的资源
Provisioner插件的职责是:
- 在特定资源创建后、销毁前执行命令或脚本
当 terraform init运行后,Terraform会读取工作目录中的配置文件,确定需要哪些插件。并在多个位置搜索以及安装的插件,下载缺失的插件,确定使用插件的什么版本,并且更新依赖锁文件,锁定插件版本。
关于插件发现,有以下规则:
- 如果已经安装了满足版本约束的插件,Terraform会使用其中最新的。即使Terraform Registry有更新的满足版本约束的插件,默认也不会主动下载。使用 terraform init -upgrade可以强制下载最新版本
- 如果没有安装满足版本约束的插件,且插件托管在Registry,则下载并存放到 .terraform/providers/目录下
Provider应该基于单一API集合,或者SDK,例如仅仅针对腾讯云API,实现对腾讯云基础实施对象CRUD的封装。
Terraform插件定义的资源,应该对应单一的云资源,作为该云资源的声明式表示。资源通常应该提供创建/读取/删除,以及可选的更新方法。
对多个云资源的组合,或者其它高级特性,应该通过模块来实现。
名称、数据类型、结构,应当尽可能匹配,除非这样做会影响Provider用户的体验。
Terraform资源应该支持 terraform import操作。
一旦Provider发布,后续就面临向后兼容性问题。
开发Provider时,有两个SDK可供选择:
- SDKv2:当前大部分现有的Provider基于此SDK开发,提供稳定的开发体验
- Terraform Plugin Framework:新的SDK,还在积极的开发中。目标是提升开发体验,对齐Terraform新的架构
如何选择:
- 如果维护既有Provider,沿用它当前使用的SDK。如果开发全新的Provider,可以考虑使用Framework
- 如果使用的Terraform版本小于v1.0.3,则只能基于SDKv2开发。Framework基于Terraform Protocol Version 6构建,旧版本的Terraform不能下载基于Version 6的Provider
- Framework的接口可能发生改变,考虑成本
- 是否需要Framework提供的新特性:
- 支持获知一个值是否在配置、状态或计划中设置
- 支持获知一个值是否null、unknown,或者是空白值
- 支持object这样的结构化类型
- 支持嵌套属性
- 支持以any类型为元素的map
- 支持获知何时一个optional或计算出的字段从配置中移除了
- 是否需要Framework尚未实现的,SDKv2已经支持的特性:
- 超时支持
- 定义资源状态upgraders
构建Provider的时候,使用go build命令,按照构建二进制文件的常规方式即可。你也可以使用GoReleaser来自动化构建多平台的、包含checksum的、自动签名的Provider。
每个发布包含以下文件:
- 一个或多个zip文件,其命名格式为
terraform-provider-{NAME}_{VERSION}_{OS}_{ARCH}.zip
- zip中包含Provider的二进制文件,命名为 terraform-provider-{NAME}_v{VERSION}
- 一个摘要文件
terraform-provider-{NAME}_{VERSION}_SHA256SUMS,包含每个zip的sha256:
1shasum -a 256 *.zip > terraform-provider-{NAME}_{VERSION}_SHA256SUMS - 一个GPG二进制文件
terraform-provider-{NAME}_{VERSION}_SHA256SUMS.sig,使用密钥对上述摘要文件进行前面:
1gpg --detach-sign terraform-provider-{NAME}_{VERSION}_SHA256SUMS - 发布必须是finalized的(不是一个私有的草稿)
本章开发和使用一个Provider,它和虚构的咖啡店应用Hashicups进行交互。此咖啡店应用支持查看和订购咖啡,它开放公共API端点:
- 列出咖啡品种
- 列出特定咖啡的成分
以及需要身份认证的API端点:
- CRUD咖啡订单
HashiCups Provider基于一个Golang客户端库,利用REST API访问以上API端点,管理咖啡订单。
首先我们从用户角度来感受一下,如何使用HashiCups Provider管理咖啡订单。本章后续会分析和重构该Provider的源码。
执行下面的命令下载使用HashiCups Provider的Terraform配置的空白项目。此项目没有Terraform配置,但是提供了在本地运行HashiCup咖啡店应用的脚本。
1 2 |
git clone https://github.com/hashicorp/learn-terraform-hashicups-provider cd learn-terraform-hashicups-provider |
执行下面的命令,在本地启动HashiCup咖啡店应用:
1 |
cd docker_compose && docker-compose up |
API监听端口是19090。检查并确认服务器正常工作:
1 |
curl localhost:19090/health |
从0.13+开始,必须在Terraform配置中声明所有依赖的Provider及其源(从哪里下载)。源的格式为[hostname]/[namespace]/[name],对于Terraform Registry中的hashicorp命名空间,hostname和namespace都是可选的。Terraform Registry对应的hostname为registry.terraform.io
这里用到的Provider没有托管在Registry,需要手工下载:
1 |
curl -LO https://github.com/hashicorp/terraform-provider-hashicups/releases/download/v0.3.1/terraform-provider-hashicups_0.3.1_linux_amd64.zip |
或者从源码编译:
1 2 3 4 |
git clone https://github.com/hashicorp/terraform-provider-hashicups go mod vendor go build -o terraform-provider-hashicups mv terraform-provider-hashicups ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64 |
并且存放到:
~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target}
~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1
1 |
curl -X POST localhost:19090/signup -d '{"username":"education", "password":"test123"}' |
登录以获得Token:
1 2 |
curl -X POST localhost:19090/signin -d '{"username":"education", "password":"test123"}' {"UserID":1,"Username":"education","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw"} |
将Token设置到环境变量:
1 |
export HASHICUPS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw |
添加下面的代码到main.tf:
1 2 3 4 5 6 7 8 |
terraform { required_providers { hashicups = { version = "~> 0.3.1" source = "hashicorp.com/edu/hashicups" } } } |
并进行初始化: terraform init
将以下内容添加到main.tf中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
# 配置Provider provider "hashicups" { username = "education" password = "test123" } # 定义一个名为edu的订单资源 resource "hashicups_order" "edu" { # 订单包含2个品种3的咖啡 items { coffee { id = 3 } quantity = 2 } # 订单包含2个品种2的咖啡 items { coffee { id = 2 } quantity = 2 } } # 输出edu资源,这个输出在资源创建后可用 output "edu_order" { value = hashicups_order.edu } |
执行下面的命令获取执行计划:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
terraform plan Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create # +表示创建操作 Terraform will perform the following actions: # 这里会列出计划中包含的操作 # 这是一个创建操作 # hashicups_order.edu will be created + resource "hashicups_order" "edu" { # 每个属性的值 这个表示未知值 + id = (known after apply) + last_updated = (known after apply) + items { + quantity = 2 + coffee { + description = (known after apply) + id = 3 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) } } + items { + quantity = 2 + coffee { + description = (known after apply) + id = 2 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) } } } Plan: 1 to add, 0 to change, 0 to destroy. # 这里显示输出值会发生的变更 Changes to Outputs: + edu_order = { + id = (known after apply) + items = [ + { + coffee = [ + { + description = (known after apply) + id = 3 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) }, ] + quantity = 2 }, + { + coffee = [ + { + description = (known after apply) + id = 2 + image = (known after apply) + name = (known after apply) + price = (known after apply) + teaser = (known after apply) }, ] + quantity = 2 }, ] + last_updated = (known after apply) } |
执行 terraform apply即可应用变更。 利用 terraform state show命令可以显示资源状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
terraform state show hashicups_order.edu # hashicups_order.edu: resource "hashicups_order" "edu" { id = "1" items { quantity = 2 coffee { id = 3 image = "/nomad.png" name = "Nomadicano" price = 150 teaser = "Drink one today and you will want to schedule another" } } items { quantity = 2 coffee { id = 2 image = "/vault.png" name = "Vaulatte" price = 200 teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" } } } |
可以看到Schema中所有属性均被填充。
我们修改一下订单配置,将items[*].quantity改一下,然后看看执行计划:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
terraform plan hashicups_order.edu: Refreshing state... [id=1] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: ~ update in-place # ~表示原地更新操作 Terraform will perform the following actions: # 这里会显示diff # hashicups_order.edu will be updated in-place ~ resource "hashicups_order" "edu" { id = "1" ~ items { ~ quantity = 2 -> 3 # (1 unchanged block hidden) } ~ items { ~ quantity = 2 -> 1 # (1 unchanged block hidden) } } Plan: 0 to add, 1 to change, 0 to destroy. Changes to Outputs: # 这里会显示diff ~ edu_order = { ~ items = [ ~ { ~ quantity = 2 -> 3 # (1 unchanged element hidden) }, ~ { ~ quantity = 2 -> 1 # (1 unchanged element hidden) }, ] # (2 unchanged elements hidden) } |
通过apply命令应用上述执行计划。
本节我们来演示如何读取已经创建的订单的咖啡的配料表:
1 2 3 4 5 6 7 8 |
data "hashicups_ingredients" "first_coffee" { # 声明多次的内嵌块,自动成为数组 coffee_id = hashicups_order.edu.items[0].coffee[0].id } output "first_coffee_ingredients" { value = data.hashicups_ingredients.first_coffee } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
terraform destroy hashicups_order.edu: Refreshing state... [id=1] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: - destroy # -表示删除操作 Terraform will perform the following actions: # hashicups_order.edu will be destroyed - resource "hashicups_order" "edu" { - id = "1" -> null - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST" -> null - items { - quantity = 3 -> null - coffee { - id = 3 -> null - image = "/nomad.png" -> null - name = "Nomadicano" -> null - price = 150 -> null - teaser = "Drink one today and you will want to schedule another" -> null } } - items { - quantity = 1 -> null - coffee { - id = 2 -> null - image = "/vault.png" -> null - name = "Vaulatte" -> null - price = 200 -> null - teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" -> null } } } Plan: 0 to add, 0 to change, 1 to destroy. Changes to Outputs: - edu_order = { - id = "1" - items = [ - { - coffee = [ - { - description = "" - id = 3 - image = "/nomad.png" - name = "Nomadicano" - price = 150 - teaser = "Drink one today and you will want to schedule another" }, ] - quantity = 3 }, - { - coffee = [ - { - description = "" - id = 2 - image = "/vault.png" - name = "Vaulatte" - price = 200 - teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" }, ] - quantity = 1 }, ] - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST" } -> null - first_coffee_ingredients = { - coffee_id = 3 - id = "3" - ingredients = [ - { - id = 1 - name = "ingredient - Espresso" - quantity = 20 - unit = "ml" }, - { - id = 3 - name = "ingredient - Hot Water" - quantity = 100 - unit = "ml" }, ] } -> null |
签出Provider源码:
1 |
git clone --branch boilerplate https://github.com/hashicorp/terraform-provider-hashicups |
注意SDKv2的依赖:
1 |
github.com/hashicorp/terraform-plugin-sdk/v2 v2.8.0 |
分支boilerplate包含一些样板文件。 程序的入口点如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
package main import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" "terraform-provider-hashicups/hashicups" ) func main() { // 启动插件的RPC服务器端 plugin.Serve(&plugin.ServeOpts{ ProviderFunc: func() *schema.Provider { return hashicups.Provider() }, }) } |
Provider函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
package hashicups import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) // 定义了一个Provider func Provider() *schema.Provider { return &schema.Provider{ // 资源映射,从资源类型名到Schema ResourcesMap: map[string]*schema.Resource{}, // 数据资源映射,从资源类型名到Schema。注意资源和数据资源本质上是同一种结构 DataSourcesMap: map[string]*schema.Resource{}, } } |
上面已经定义了Provider的骨架,这里我们实现一个咖啡数据源,此数据源能够从HasiCups服务拉取所有售卖的咖啡信息。
建议每个数据源都在独立的源文件中编写,并且文件名以 data_source_开头:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
package hashicups import ( "context" "encoding/json" "fmt" "net/http" "strconv" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) // 定义了一个Resource,其中包含Schema以及CRUD操作 func dataSourceCoffees() *schema.Resource { return &schema.Resource{ // 由于数据资源仅仅支持读操作,因此仅声明ReadContext ReadContext: dataSourceCoffeesRead, Schema: map[string]*schema.Schema{}, } } |
下面我们完善此数据资源的Schema,根据Terraform的最佳实践,Schema应该尽量和基础实施匹配:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// curl localhost:19090/coffees [ { "id": 1, "name": "Packer Spiced Latte", "teaser": "Packed with goodness to spice up your images", "description": "", "price": 350, "image": "/packer.png", "ingredients": [ { "ingredient_id": 1 }, { "ingredient_id": 2 }, { "ingredient_id": 4 } ] } ] |
根据服务器返回的咖啡数据结构,编写对应的Schema并且添加到上面的dataSourceCoffees方法中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
Schema: map[string]*schema.Schema{ "coffees": { // 注意这里是列表 Type: schema.TypeList, Computed: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { Type: schema.TypeInt, // 此值是"计算得到的",也就是在创建资源时(除非手工配置)会得到此值的结果 Computed: true, }, "name": { Type: schema.TypeString, Computed: true, }, "teaser": { Type: schema.TypeString, Computed: true, }, "description": { Type: schema.TypeString, Computed: true, }, "price": { Type: schema.TypeInt, Computed: true, }, "image": { Type: schema.TypeString, Computed: true, }, // 复杂类型,一个列表 "ingredients": { Type: schema.TypeList, Computed: true, // 列表元素的Schema Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "ingredient_id": { Type: schema.TypeInt, Computed: true, }, }, }, // 如果是list(string),可以这样声明Elem Elem: schema.Schema{ Type: schema.TypeString, }, }, }, }, }, }, } } |
定义好Schema后,我们需要实现读操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// 读结果存放在这里 这个m是meta,元参数, // 是配置Provider的返回值,下文有说明 func dataSourceCoffeesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { client := &http.Client{Timeout: 10 * time.Second} // 这是一个切片,用于收集警告或者错误信息 var diags diag.Diagnostics // 像云API发起请求 req, err := http.NewRequest("GET", fmt.Sprintf("%s/coffees", "http://localhost:19090"), nil) if err != nil { // 收集错误 return diag.FromErr(err) } r, err := client.Do(req) if err != nil { return diag.FromErr(err) } defer r.Body.Close() // 将响应反串行化到临时对象中 coffees := make([]map[string]interface{}, 0) err = json.NewDecoder(r.Body).Decode(&coffees) if err != nil { return diag.FromErr(err) } // schema.ResourceData用于查询和设置资源属性 // 此方法将响应设置到Terraform数据源,并且保证字段设置到Schema对应位置 if err := d.Set("coffees", coffees); err != nil { return diag.FromErr(err) } // 设置数据资源的ID d.SetId(strconv.FormatInt(time.Now().Unix(), 10)) return diags } |
数据资源的ID被设置为非空值,这提示Terraform,目标资源已经被创建。 作为一个列表,此数据资源没有真实的ID,因此我们这里设置为时间戳。
如果数据资源被从Terraform外部删除了,这里应该设置空ID:
1 2 3 4 |
if resourceDoesntExist { d.SetID("") return } |
这样Terraform会自动将state中的数据资源清除掉。
将上面的数据资源配置到Provider中:
1 2 3 4 5 6 7 8 |
func Provider() *schema.Provider { return &schema.Provider{ ResourcesMap: map[string]*schema.Resource{}, DataSourcesMap: map[string]*schema.Resource{ "hashicups_coffees": dataSourceCoffees(), }, } } |
现在我们开发一个模块,使用上面定义的咖啡数据源。首先需要编译好Provider: make install。
根模块配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
terraform { required_providers { hashicups = { // 当前构建的版本是0.2 version = "0.2" source = "hashicorp.com/edu/hashicups" } } } // 目前Provider不支持配置 provider "hashicups" {} // 调用coffee子模块 module "psl" { source = "./coffee" // 传递输入参数 coffee_name = "Packer Spiced Latte" } // 打印coffee模块的coffee输出值 output "psl" { value = module.psl.coffee } |
coffee子模块配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// 子模块需要声明自己的依赖 terraform { required_providers { hashicups = { version = "0.2" source = "hashicorp.com/edu/hashicups" } } } // 输入变量,咖啡品类 variable "coffee_name" { type = string default = "Vagrante espresso" } // 调用上面开发的数据源,拉取所有咖啡品类 data "hashicups_coffees" "all" {} # 输出所有咖啡 output "all_coffees" { value = data.hashicups_coffees.all.coffees } output "coffee" { value = { // 遍历所有咖啡 for coffee in data.hashicups_coffees.all.coffees : // 返回咖啡ID到咖啡资源的映射 coffee.id => coffee // 过滤,要求名称匹配输入参数 if coffee.name == var.coffee_name } } |
应用根模块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# terraform init && terraform apply --auto-approve psl = { "1" = { "description" = "" "id" = 1 "image" = "/packer.png" "ingredients" = tolist([ { "ingredient_id" = 1 }, { "ingredient_id" = 2 }, { "ingredient_id" = 4 }, ]) "name" = "Packer Spiced Latte" "price" = 350 "teaser" = "Packed with goodness to spice up your images" } } |
本节我们来演示如何为Provider增加参数,Provider的实现又是如何读取这些参数的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
func Provider() *schema.Provider { return &schema.Provider{ // ... // 定义Provider的配置参数 Schema: map[string]*schema.Schema{ "username": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_USERNAME", nil), }, "password": { Type: schema.TypeString, Optional: true, // 敏感数据,在输出时会处理 Sensitive: true, // 默认值函数 从环境变量读取默认值 DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_PASSWORD", nil), }, }, ConfigureContextFunc: providerConfigure, } } |
用户提供的username/password,如何被Provider的ReadContext函数访问呢?这需要配置Provider。配置过程是由 schema.Provider的 ConfigureContextFunc函数负责的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { // 读取Provider的配置参数 username := d.Get("username").(string) password := d.Get("password").(string) var diags diag.Diagnostics if (username != "") && (password != "") { c, err := hashicups.NewClient(nil, &username, &password) if err != nil { return nil, diag.FromErr(err) } return c, diags } c, err := hashicups.NewClient(nil, nil, nil) if err != nil { return nil, diag.FromErr(err) } return c, diags } |
可以看到配置Provider后,会返回一个interface{},这个对象会传递给CRUD操作的最后一个参数:
1 2 3 4 5 6 7 8 9 10 11 |
// See Resource documentation. type CreateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics // See Resource documentation. type ReadContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics // See Resource documentation. type UpdateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics // See Resource documentation. type DeleteContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics |
一般情况下,这个interface{}是配置好的云API客户端,或者是云API配置结构。
本节我们演示如何在读操作中使用Provider参数,更精确的说是基于这些参数配置Provider后的结果。
首先我们创建几个咖啡订单(用于后续查询):
1 |
curl -X POST -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders -d '[{"coffee": { "id":1 }, "quantity":4}, {"coffee": { "id":3 }, "quantity":3}]' |
看一下订单的数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// curl -X GET -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders/2 { "id": 2, "items": [ { "coffee": { "id": 1, "name": "Packer Spiced Latte", "teaser": "Packed with goodness to spice up your images", "description": "", "price": 350, "image": "/packer.png", "ingredients": null }, "quantity": 4 }, { "coffee": { "id": 3, "name": "Nomadicano", "teaser": "Drink one today and you will want to schedule another", "description": "", "price": 150, "image": "/nomad.png", "ingredients": null }, "quantity": 3 } ] } |
下面我们定义对应的数据源:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
func dataSourceOrder() *schema.Resource { return &schema.Resource{ // 读取操作,见下文 ReadContext: dataSourceOrderRead, Schema: map[string]*schema.Schema{ "id": { Type: schema.TypeInt, Required: true, }, "items": { Type: schema.TypeList, Computed: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "coffee_id": { Type: schema.TypeInt, Computed: true, }, "coffee_name": { Type: schema.TypeString, Computed: true, }, "coffee_teaser": { Type: schema.TypeString, Computed: true, }, "coffee_description": { Type: schema.TypeString, Computed: true, }, "coffee_price": { Type: schema.TypeInt, Computed: true, }, "coffee_image": { Type: schema.TypeString, Computed: true, }, "quantity": { Type: schema.TypeInt, Computed: true, }, }, }, }, }, } } |
注意这个数据源的Schema和API的结构没有做对应,进行了扁平化处理。
读取订单的操作如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
func dataSourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // 配置Provider后,得到的是一个客户端 c := m.(*hc.Client) var diags diag.Diagnostics orderID := strconv.Itoa(d.Get("id").(int)) // 基于此客户端进行订单查询 order, err := c.GetOrder(orderID) if err != nil { return diag.FromErr(err) } // 订单结构和我们数据源的结构不一致,这里做转换 orderItems := flattenOrderItemsData(&order.Items) if err := d.Set("items", orderItems); err != nil { return diag.FromErr(err) } d.SetId(orderID) return diags } |
将此资源注册到Provider:
1 2 3 4 5 6 7 8 9 10 |
func Provider() *schema.Provider { return &schema.Provider{ // ... DataSourcesMap: map[string]*schema.Resource{ // ... "hashicups_order": dataSourceOrder(), }, ConfigureContextFunc: providerConfigure, } } |
在配置文件中使用该数据源:
1 2 3 4 5 6 7 |
data "hashicups_order" "order" { id = 1 } output "order" { value = data.hashicups_order.order } |
本节演示如何基于日志来调试Provider,我们会添加定制的错误消息,并且显示详细的Terraform Provider日志。
开发Provider时需要实现很多函数,这些函数常常具有一个返回值 diag.Diagnostics,例如上面的CRUD操作,以及配置Provider的函数:
1 |
type ConfigureContextFunc func(context.Context, *ResourceData) (interface{}, diag.Diagnostics) |
警告/错误级别的调试信息,都要放到diag.Diagnostics中,执行Terraform CLI命令时,这些信息会自动打印。
当创建HashiCups客户端失败时,我们可以添加一条针对信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { // ... if (username != "") && (password != "") { c, err := hashicups.NewClient(nil, &username, &password) if err != nil { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "Unable to create HashiCups client", Detail: "Unable to auth user for authenticated HashiCups client", }) return nil, diags } return c, diags } c, err := hashicups.NewClient(nil, nil, nil) if err != nil { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "Unable to create HashiCups client", Detail: "Unable to auth user for authenticated HashiCups client", }) return nil, diags } return c, diags } |
执行命令时,错误信息会打印出来:
1 2 3 4 5 6 7 8 |
terraform init && terraform apply --auto-approve ## ... module.psl.data.hashicups_coffees.all: Refreshing state... # 摘要 Error: Unable to create HashiCups client # 详情 Unable to auth user for authenticated HashiCups client |
资源支持创建、修改、删除等写操作,上面编写的数据源则仅支持读。尽管资源、数据源可能指向同一类实体,但是Schema不能共用。
资源的文件名前缀通常使用 resource_,下面是订单资源的骨架代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
package hashicups import ( "context" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceOrder() *schema.Resource { return &schema.Resource{ CreateContext: resourceOrderCreate, ReadContext: resourceOrderRead, UpdateContext: resourceOrderUpdate, DeleteContext: resourceOrderDelete, Schema: map[string]*schema.Schema{}, } } func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // Warning or errors can be collected in a slice type var diags diag.Diagnostics return diags } func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // Warning or errors can be collected in a slice type var diags diag.Diagnostics return diags } func resourceOrderUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { return resourceOrderRead(ctx, d, m) } func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // Warning or errors can be collected in a slice type var diags diag.Diagnostics return diags } |
我们需要将其注册到Provider:
1 2 3 4 5 6 7 8 9 |
func Provider() *schema.Provider { return &schema.Provider{ // ... ResourcesMap: map[string]*schema.Resource{ "hashicups_order": resourceOrder(), }, // ... } } |
不同于上问的订单数据源,这里我们设计了和API结构更加匹配的Schema:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
Schema: map[string]*schema.Schema{ "items": { Type: schema.TypeList, Required: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "coffee": { Type: schema.TypeList, // coffee明明是一个,还非得声明为列表 MaxItems: 1, Required: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { Type: schema.TypeInt, Required: true, }, "name": { Type: schema.TypeString, Computed: true, }, "teaser": { Type: schema.TypeString, Computed: true, }, "description": { Type: schema.TypeString, Computed: true, }, "price": { Type: schema.TypeInt, Computed: true, }, "image": { Type: schema.TypeString, Computed: true, }, }, }, }, "quantity": { Type: schema.TypeInt, Required: true, }, }, }, }, } |
注意和订单数据源Schema的其它几个重要区别:
- 在资源Schema中,顶级的id属性不存在。这是因为资源中你无法提前知道id,也不能将id作为输入参数。id是在资源创建过程中自动生成的
- 在资源Schema中,items是必须字段,而不是计算出的字段。这是因为我们在配置中声明订单资源时必须提供订单项信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // 元参数的真实类型是HashCups客户端 c := m.(*hc.Client) var diags diag.Diagnostics // 从ResourceData中获得订单条目(的参数) items := d.Get("items").([]interface{}) ois := []hc.OrderItem{} // 遍历订单条目,构造为HashCups客户端所需的OrderItem for _, item := range items { i := item.(map[string]interface{}) co := i["coffee"].([]interface{})[0] // 只取第一个coffee,为何非要定义为列表 coffee := co.(map[string]interface{}) oi := hc.OrderItem{ Coffee: hc.Coffee{ ID: coffee["id"].(int), }, Quantity: i["quantity"].(int), } ois = append(ois, oi) } // 调用客户端创建订单 o, err := c.CreateOrder(ois) if err != nil { return diag.FromErr(err) } // 将生成的标识符设置为资源ID d.SetId(strconv.Itoa(o.ID)) return diags } |
实现了创建操作后,必须同时实现读操作,并在创建操作中调用读操作,这样才能在创建资源后,立即以最新资源填充state:
1 2 3 4 5 6 7 8 |
func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { // ... d.SetId(strconv.Itoa(o.ID)) resourceOrderRead(ctx, d, m) return diags } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { c := m.(*hc.Client) var diags diag.Diagnostics // 读操作时,资源的ID是已知的(从state中获取) orderID := d.Id() // 根据ID查询订单 order, err := c.GetOrder(orderID) if err != nil { return diag.FromErr(err) } // 结构转换 orderItems := flattenOrderItems(&order.Items) if err := d.Set("items", orderItems); err != nil { return diag.FromErr(err) } return diags } func flattenOrderItems(orderItems *[]hc.OrderItem) []interface{} { if orderItems != nil { ois := make([]interface{}, len(*orderItems), len(*orderItems)) for i, orderItem := range *orderItems { oi := make(map[string]interface{}) oi["coffee"] = flattenCoffee(orderItem.Coffee) oi["quantity"] = orderItem.Quantity ois[i] = oi } return ois } return make([]interface{}, 0) } func flattenCoffee(coffee hc.Coffee) []interface{} { c := make(map[string]interface{}) c["id"] = coffee.ID c["name"] = coffee.Name c["teaser"] = coffee.Teaser c["description"] = coffee.Description c["price"] = coffee.Price c["image"] = coffee.Image return []interface{}{c} } |
下面的配置,创建了一个订单,并且输出其内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
resource "hashicups_order" "edu" { // 订单的Schema中,items是List,要为其声明多个元素,多次添加items块 items { // 订单的Schema中,coffee也是一个List coffee { id = 3 } quantity = 2 } items { coffee { id = 2 } quantity = 2 } } output "edu_order" { value = hashicups_order.edu } |
1 2 3 4 5 6 7 8 9 10 11 12 |
func resourceOrder() *schema.Resource { return &schema.Resource{ // ... UpdateContext: resourceOrderUpdate, Schema: map[string]*schema.Schema{ "last_updated": &schema.Schema{ Type: schema.TypeString, Optional: true, // 可选字段,允许配置时不提供 Computed: true, // 在创建时自动计算出(由Provider给出) }, "items": &schema.Schema{ /... |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// 判断指定的键,对应的值是否改变了。通过对比比较配置文件前后的差异达成 if d.HasChange("items") { items := d.Get("items").([]interface{}) ois := []hc.OrderItem{} for _, item := range items { i := item.(map[string]interface{}) co := i["coffee"].([]interface{})[0] coffee := co.(map[string]interface{}) oi := hc.OrderItem{ Coffee: hc.Coffee{ ID: coffee["id"].(int), }, Quantity: i["quantity"].(int), } ois = append(ois, oi) } // 调用HashCups API更新灯胆 _, err := c.UpdateOrder(orderID, ois) if err != nil { return diag.FromErr(err) } // 更新 last_updated字段 d.Set("last_updated", string(time.Now().Format(time.RFC850))) } // 总是重新读取订单最新状态 return resourceOrderRead(ctx, d, m) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { c := m.(*hc.Client) var diags diag.Diagnostics orderID := d.Id() // 调用HashiCups API执行删除 err := c.DeleteOrder(orderID) if err != nil { return diag.FromErr(err) } // 注意:d.SetId("") 会在本方法无错误返回的情况下,自动调用 // 通常不需要手工调用 d.SetId("") return diags } |
Terraform处理该回调的返回值的逻辑:
- 如果没有返回错误,则Terraform认为资源被删除,其所有状态信息被从state中移除
- 如果返回了错误,则Terraform认为资源仍然存在,其所有状态信息会被保留
该回调:
- 永远不应该更新资源的任何状态
- 总是应当处理资源已经被删除的场景,这种情况下不应该返回错误
- 如果云API不支持删除操作,则该回调应该检查资源是否存在,如果不存在则设置ID为"",确保资源从state中删除
导入操作能够通过API拉取已经存在的订单,并且同步到Terraform state中,不进行创建订单的操作。 导入的资源和通过Terraform创建的资源已有,被Terraform管理。
1 2 3 4 5 6 7 8 |
func resourceOrder() *schema.Resource { return &schema.Resource{ // ... Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, } } |
StateContext被设置为Terraform库提供的函数schema.ImportStatePassthroughContext,该函数签名为:
1 2 3 4 |
// StateContextFunc用于导入资源到Terraform state。入参是仅仅设置了ID的资源,这个ID // 由用户提供,因此需要校验 // 返回值是ResourceData,代表需要存入state的资源状态。最简单的情况下,仅仅包含原封不动的入参 type StateContextFunc func(context.Context, *ResourceData, interface{}) ([]*ResourceData, error) |
函数的实现很简单,就是直接把入参返回:
1 2 3 |
func ImportStatePassthroughContext(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) { return []*ResourceData{d}, nil } |
1 |
terraform import hashicups_order.sample <order_id> |
上述命令将名为sample的hasicups_order和指定的订单ID关联起来。Terraform会调用Importer,并将ID传递给resourceOrderRead来读取完整的状态。
从导入操作我们可以看到,资源的唯一性ID很重要。某些情况下,我们可能需要从云API的多个字段去构造ID,例如 <region>:<resource_id>
Terraform Registry是Provider和Module的公共仓库。如果你开发了有复用价值的Provider,可以考虑上传到其中。
几乎所有Provider为用户提供配置参数,以实现云API的访问凭证、Region信息等的可定制化。下面的资源,允许你提供uuid、name两个参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
func resourceExampleResource() *schema.Resource { return &schema.Resource{ // 每个资源,从根上来说,是一个{键值}结构 // 需要为每个值指定Schema Schema: map[string]*schema.Schema{ "uuid": { // 指定值的类型 Type: schema.TypeString, // 这个字段会在资源创建时,自动生成 Computed: true, }, "name": { Type: schema.TypeString, // 这是必须字段,用户必须提供 Required: true, // 该字段改变后,资源会被删除、重新创建 ForceNew: true, // 该字段的校验逻辑 ValidateFunc: validatName, }, }, } } |
类型可以分为两大类:基本类型、聚合类型。
基本类型包括:
类型 | Schema示例 配置示例 |
状态表示 | ||||||
TypeBool (bool) |
|
|
||||||
TypeInt (int) |
|
|
||||||
TypeFloat (float64) |
|
|
||||||
TypeString (string) |
|
|
||||||
日期时间,也使用TypeString,配合校验函数:
|
|
聚合类型包括:
类型 | Schema示例 配置示例 |
状态表示 | ||||||
TypeMap |
(map[string]interface{})
|
|
||||||
TypeList |
([]interface{})
|
|
||||||
TypeSet |
(*schema.Set)
|
|
Schema的一些字段的设置,会对Terraform的plan/apply行为产生影响。
字段 | 影响 | ||
Optional | 是否在配置中是可选的 | ||
Required | 是否必须在配置中提供 | ||
Computed | 提示字段不能由用户提供,并且在terraform apply之前,其值是未知的 | ||
ForceNew | 对资源的该字段的修改,会导致删除并重新创建资源 | ||
Default | 如果用户没有配置,使用的默认值 | ||
DiffSuppressFunc |
用于计算该字段的(前后值的)差异,下面的例子,不区分大小写:
|
||
DefaultFunc | 用于动态提供默认值 | ||
StateFunc | 将字段转换为一个字符串,该字符串存储在state中 | ||
ValidateFunc | 校验该字段 |
Terrafrom通过比较用户提供的配置、资源的state,来确定是否需要更新。
可以为schema.Resource传递CustomizeDiff,该回调和Terraform生成的Diff(表示资源的变更)一起工作,它可以:
- 修改Diff
- 否决Diff,终止计划
1 |
type CustomizeDiffFunc func(context.Context, *ResourceDiff, interface{}) error |
云上的很多操作比较耗时,例如启动操作系统、跨越网络边缘复制状态。开发Provider时,应该注意考虑云API的延迟,Terraform支持为资源的各种操作设置超时:
1 2 3 4 5 6 7 8 |
func resourceExampleInstance() *schema.Resource { return &schema.Resource{ // ... Timeouts: &schema.ResourceTimeout{ Create: schema.DefaultTimeout(45 * time.Minute), }, } } |
Terraform提供了一个重试助手函数:
1 2 3 |
type RetryFunc func() *RetryError func RetryContext(ctx context.Context, timeout time.Duration, f RetryFunc) error |
RetryContext能够反复重试RetryFunc:
- timeout指定Terraform调用RetryFunc的最大用时。可以传递 schema.TimeoutCreate给 *schema.ResourceData.Timeout()获取用户配置的超时值
- RetryFunc可以返回:
- resource.NonRetryableError,这样直接导致重试终止
- resource.RetryableError,这样会重试
基于测试用例来组织,每个用例使用1-N个Terraform配置,创建一些资源,并并验证实际创建的基础设施对象符合预期。
Terraform的 resource包提供了 Test()方法,该方法是Terraform可接受测试框架的入口点,它接受两个参数:
*testing.T 来自Go语言的测试框架
TestCase开发者提供的用于设立可接受测试的结构
下面是一个例子,它测试一个名为Example的Provider,被测试的资源是Widget:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
package example var testAccProviders map[string]*schema.Provider var testAccProvider *schema.Provider func init() { testAccProvider = Provider() // 被测试Provider testAccProviders = map[string]*schema.Provider{ "example": testAccProvider, } } // 方法命名约定TestAccXxx func TestAccExampleWidget_basic(t *testing.T) { var widgetBefore, widgetAfter example.Widget rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) // 大部分可接受测试,都是仅仅调用Test方法并退出 // 任何时候(不管是PreCheck还是Steps...)出错,框架都会调用t.Error()方法,导致测试失败并终止 resource.Test(t, resource.TestCase{ // 是否单元测试。该参数允许在不考虑TF_ACC环境变量的情况下运行测试。应当仅仅用于本地资源的快速测试 // 默认情况下,如果没有设置环境变量TF_ACC,测试会立即失败 IsUnitTest: false, // 在任何Steps之前,允许的回调,通常用来检查测试需要的值(例如用于配置Provider的环境变量)存在 PreCheck: func() { testAccPreCheck(t) }, // map[string]*schema.Provider类型,表示将被测试的Providers。配置文件中引用的任何Provider都 // 需要在此配置,否则用例会报错 Providers: testAccProviders, // 注意:Providers已经废弃,请使用ProviderFactories代替 ProviderFactories: map[string]func() (*schema.Provider, error), // 类似ProviderFactories,但是用于基于terraform-plugin-go ProviderServer接口实现 // Protocol V5的Provider ProtoV5ProviderFactories map[string]func() (tfprotov5.ProviderServer, error) // 类似ProviderFactories,但是用于基于terraform-plugin-go ProviderServer接口实现 // Protocol V6的Provider ProtoV6ProviderFactories map[string]func() (tfprotov6.ProviderServer, error) // 所有Steps运行之后,并且Terraform已经针对state运行了destroy命令之后执行的回调 // 该方法以最后一次已知的staet作为入参,通常直接使用基础设施的SDK来确认目标对象已都不存在 // 如果应该被删除的对象仍然存在,此方法应该报错 CheckDestroy: testAccCheckExampleResourceDestroy, // 允许Provider有选择性的处理错误,例如基于特定错误,跳过某些测试 ErrorCheck ErrorCheckFunc, // 一个TestStep,通常对应单次apply。基础的测试包含1-2个Step,验证资源可以被创建,然后更新 Steps: []resource.TestStep{ { Config: testAccExampleResource(rName), Check: resource.ComposeTestCheckFunc( testAccCheckExampleResourceExists("example_widget.foo", &widgetBefore), ), }, { Config: testAccExampleResource_removedPolicy(rName), Check: resource.ComposeTestCheckFunc( testAccCheckExampleResourceExists("example_widget.foo", &widgetAfter), ), }, }, }) } |
每个TestStep需要Terraform Configuration作为输入,提供多种验证被测试资源行为的方法。
Terraform的测试框架支持两个独立的测试模式:
- Lifecycle模式,常用,提供1-N个配置文件并测试Provider在terraform apply时的行为
- Import模式,测试Provider在terraform import时的行为
测试模式被传递给TestStep的字段隐式的确定。
每个TestStep包含一个需要被Apply的配置(由Config字段给出)、0-N个校验(在Check字段中编排),多个TestStep按顺序,依次执行。
当需要执行多个校验时,需要使用下面的函数之一来编排: ComposeTestCheckFunc、 ComposeAggregateTestCheckFunc。示例:
1 2 3 4 5 6 7 8 9 10 |
Steps: []resource.TestStep{ { Check: resource.ComposeTestCheckFunc( // 检查资源是否存在于基础设施 testAccCheckExampleResourceExists("example_widget.foo", &widgetBefore), // 校验资源属性 resource.TestCheckResourceAttr("example_widget.foo", "size", "expected size"), ), }, }, |
ComposeAggregateTestCheckFunc的区别是,尽管也是顺序执行校验,但是某个校验失败,并不会立即停止,还会继续执行其它校验,收集所有错误。
Terratform将所有stderr的输出都通过gRPC协议传输到CLI,并打印到控制台。编写插件时,绝不要将日志打印到stdout,因为stdout作为Terraform内部到CLI的通信通道。
建议使用Go内置的 log.Println或 log.Printf进行日志输出。日志的每行必须以[日志级别]开始,支持的级别包括ERROR WARN INFO DEBUG TRACE。示例:
1 |
log.Println("[DEBUG] Something happened!") |
Terraform使用环境变量 TF_LOG来控制Provider、CLI的日志输出级别:
1 |
export TF_LOG=DEBUG |
可以使用环境变量 TF_LOG_CORE、 TF_LOG_PROVIDER为Terraform核心、Provider设置不同的日志级别。
如果需要输出到文件,使用环境变量 TF_LOG_PATH,默认输出到CLI的stderr。
使用下面的代码,在Provider中启用单步跟踪支持:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
func main() { var debugMode bool flag.BoolVar(&debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve") flag.Parse() opts := &plugin.ServeOpts{ProviderFunc: provider.New} if debugMode { // 调试模式 err := plugin.Debug(context.Background(), "registry.terraform.io/my-org/my-provider", opts) if err != nil { log.Fatal(err.Error()) } return } // 正常模式 plugin.Serve(opts) } |
关于调试模式,需要注意:
- 在此模式下,Terraform不会启动Provider进程,你需要手工启动它:
1dlv exec --headless ./terraform-provider-my-provider -- --debug - 你需要将IDE连接到到dlv进程,这时Provider会打印日志:
123Provider started, to attach Terraform set the TF_REATTACH_PROVIDERS env var:TF_REATTACH_PROVIDERS='{"registry.terraform.io/my-org/my-provider":{"Protocol":"grpc","Pid":3382870,"Test":true,"Addr":{"Network":"unix","String":"/tmp/plugin713096927"}}}' - 你需要export上述日志输出的环境变量,然后执行Terraform CLI
- 在terraform init时不会对Provider进行约束检查
- 当遍历了Terraform资源依赖图后,Provider进程不会被重启
在运行可接受测试的时候,直接在当前测试进程中运行Provider,因此可以进行单步跟踪而不需要特别设置。
Terraform Plugin Framework是新的(但还不稳定)Provider开发方式,关于它的优缺点,上文已经介绍过。本章主要以例子说明如何使用该框架。
要使用TPF,在go.mod增加依赖:
1 |
github.com/hashicorp/terraform-plugin-framework v0.4.2 |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
package main import ( "context" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "terraform-provider-hashicups/hashicups" ) func main() { tfsdk.Serve(context.Background(), hashicups.New, tfsdk.ServeOpts{ Name: "hashicups", }) } |
tfsdk.Serve函数的第二个参数,就是任何Provider需要实现的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
type Provider interface { // 返回配置Provider的Schema,如果Provider不需要配置返回空的Schema GetSchema(context.Context) (Schema, diag.Diagnostics) // 在Provider生命周期的最初期此方法被调用,Terraform会此方法发送用户在provider块中 // 提供的参数,并且存放在ConfigureProviderRequest中。注意,Terraform不保证此方法调用时, // 所有参数都是Known的。如果Provider在某些参数在Unknown的时候仍然可被配置,建议发出警告 // 否则发出错误 Configure(context.Context, ConfigureProviderRequest, *ConfigureProviderResponse) // 返回此Provider支持的资源类型列表 // 资源列表的键,是资源的名字,并且应当以Provider名为前缀 GetResources(context.Context) (map[string]ResourceType, diag.Diagnostics) // 返回此Provider支持的数据源类型列表 // 资源列表的键,是资源的名字,并且应当以Provider名为前缀 GetDataSources(context.Context) (map[string]DataSourceType, diag.Diagnostics) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
package hashicups import ( "context" "os" "github.com/hashicorp-demoapp/hashicups-client-go" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" ) var stderr = os.Stderr func New() tfsdk.Provider { return &provider{} } type provider struct { configured bool client *hashicups.Client } |
GetSchema方法,获取Provider的配置参数的Schema:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// GetSchema func (p *provider) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { return tfsdk.Schema{ Attributes: map[string]tfsdk.Attribute{ "host": { Type: types.StringType, Optional: true, Computed: true, }, "username": { Type: types.StringType, Optional: true, Computed: true, }, "password": { Type: types.StringType, Optional: true, Computed: true, Sensitive: true, }, }, }, nil } // Provider schema struct type providerData struct { Username types.String `tfsdk:"username"` Host types.String `tfsdk:"host"` Password types.String `tfsdk:"password"` } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// req 代表Terraform在配置Provider时,发送过来的请求 // resp 代表响应 func (p *provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, resp *tfsdk.ConfigureProviderResponse) { var config providerData // 从用户给出的Provider配置中取出数据,存放到providerData中 diags := req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } // 校验username password host是否提供 var username string // 值不能是未知的 if config.Username.Unknown { // Cannot connect to client with an unknown value resp.Diagnostics.AddWarning( "Unable to create client", "Cannot use unknown value as username", ) return } // 如果值没有设置(或者被明确的设置为null),则尝试从环境变量读取 if config.Username.Null { username = os.Getenv("HASHICUPS_USERNAME") } else { username = config.Username.Value } // 如果配置是空字符串,并且也没有配置环境变量,则报错 if username == "" { // Error vs warning - empty value must stop execution resp.Diagnostics.AddError( "Unable to find username", "Username cannot be an empty string", ) return } // password, host类似... c, err := hashicups.NewClient(&host, &username, &password) if err != nil { resp.Diagnostics.AddError( "Unable to create client", "Unable to create hashicups client:\n\n"+err.Error(), ) return } p.client = c p.configured = true } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
package hashicups import ( "github.com/hashicorp/terraform-plugin-framework/types" ) // Order - type Order struct { ID types.String `tfsdk:"id"` Items []OrderItem `tfsdk:"items"` LastUpdated types.String `tfsdk:"last_updated"` } // OrderItem - type OrderItem struct { Coffee Coffee `tfsdk:"coffee"` Quantity int `tfsdk:"quantity"` } // Coffee - // This Coffee struct is for Order.Items[].Coffee which does not have an // ingredients field in the schema defined in the provider code. Since the // resource schema must match the struct exactly (any extra field will return an // error), this struct has Ingredients commented out. type Coffee struct { ID int `tfsdk:"id"` Name types.String `tfsdk:"name"` Teaser types.String `tfsdk:"teaser"` Description types.String `tfsdk:"description"` Price types.Number `tfsdk:"price"` Image types.String `tfsdk:"image"` // Ingredients []Ingredient `tfsdk:"ingredients"` } |
注意:资源模型必须和资源的Schema(如下节)严格匹配,如果模型里面有某字段,而Schema没有,会导致出错。
TPF和SDKv2比起来,一个优势是实现了模型字段的自动绑定,不再需要一个个字段手工设置。但是这个模型不一定能和云API返回的资源结构自动的相互转换。
首先需要声明Provider支持哪些资源,即提供资源名称(对应resource块第2标签)到资源类型 tfsdk.ResourceType的映射:
1 2 3 4 5 |
func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) { return map[string]tfsdk.ResourceType{ "hashicups_order": resourceOrderType{}, }, nil } |
资源类型需要实现下面的接口:
1 2 3 4 5 6 7 |
type ResourceType interface { // 返回资源的Schema GetSchema(context.Context) (Schema, diag.Diagnostics) // 创建该资源类型的Resource对象 NewResource(context.Context, Provider) (Resource, diag.Diagnostics) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
type resourceOrderType struct{} func (r resourceOrderType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { return tfsdk.Schema{ Attributes: map[string]tfsdk.Attribute{ "id": { Type: types.StringType, Computed: true, }, "last_updated": { Type: types.StringType, Computed: true, }, "items": { Required: true, Attributes: tfsdk.ListNestedAttributes(map[string]tfsdk.Attribute{ "quantity": { Type: types.NumberType, Required: true, }, "coffee": { Required: true, Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{ "id": { Type: types.NumberType, Required: true, }, "name": { Type: types.StringType, Computed: true, }, "teaser": { Type: types.StringType, Computed: true, }, "description": { Type: types.StringType, Computed: true, }, "price": { Type: types.NumberType, Computed: true, }, "image": { Type: types.StringType, Computed: true, }, }), }, }, tfsdk.ListNestedAttributesOptions{}), }, }, }, nil } |
资源类型的NewResource方法返回的是Resource类型,它抽象了针对单个资源的CRUD操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
type Resource interface { // 当Provider需要创建一个新资源的时候调用此方法。配置和planned state可以从 // CreateResourceRequest读取。新的(实际完成创建后的)state则设置到 // CreateResourceResponse Create(context.Context, CreateResourceRequest, *CreateResourceResponse) // 当Provider需要读取资源values来更新state时调用此方法。从 // ReadResourceRequest读取planned state,新的state则设置到 // ReadResourceResponse. Read(context.Context, ReadResourceRequest, *ReadResourceResponse) // 当Provider需要更新资源状态时调用此方法。配置和planned state可以从 // UpdateResourceRequest读取。新的(更新后的)state则设置到 // UpdateResourceResponse. Update(context.Context, UpdateResourceRequest, *UpdateResourceResponse) // 当Provider需要删除资源时调用此方法。配置从DeleteResourceRequest读取 Delete(context.Context, DeleteResourceRequest, *DeleteResourceResponse) // 当前Provider需要导入一个资源时调用此方法。 // 如果不支持导入,建议返回 ResourceImportStateNotImplemented() // // If setting an attribute with the import identifier, it is recommended // to use the ResourceImportStatePassthroughID() call in this method. ImportState(context.Context, ImportResourceStateRequest, *ImportResourceStateResponse) } |
创建操作的关键步骤:
- 从请求中读取plan数据,读取为模型
- 将模型转换为云API请求,并发送求
- 将响应转换并同步到模型,写入state
需要注意:plan中的每个已知(known)值,必须和state中对应值的完全一样(逐字节相等),也就是说,用户指定的配置不能被改变,否则Terraform抛出错误。Provider只能修改计划中unknown的值,而且必须解析所有unknown的值,state中不会有任何unknown的值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
import ( "context" "math/big" "strconv" "time" "github.com/hashicorp-demoapp/hashicups-client-go" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" ) func (r resourceOrder) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { // 必须确保Provider已经被配置过 if !r.p.configured { resp.Diagnostics.AddError( "Provider not configured", "The provider hasn't been configured before apply, likely because it depends on an unknown value from another resource. This leads to weird stuff happening, so we'd prefer if you didn't do that. Thanks!", ) return } // 从执行计划获取订单模型 var plan Order // 注意这里会自动将配置绑定到模型,不需要手工处理 diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } // 遍历订单项,将其适配为HashiCups客户端需要的入参 var items []hashicups.OrderItem for _, item := range plan.Items { items = append(items, hashicups.OrderItem{ Coffee: hashicups.Coffee{ ID: item.Coffee.ID, }, Quantity: item.Quantity, }) } // 调用HashiCups客户端创建订单 order, err := r.p.client.CreateOrder(items) if err != nil { resp.Diagnostics.AddError( "Error creating order", "Could not create order, unexpected error: "+err.Error(), ) return } // 将HasiCups创建的完整订单对象,适配回模型OrderItem var ois []OrderItem for _, oi := range order.Items { ois = append(ois, OrderItem{ Coffee: Coffee{ ID: oi.Coffee.ID, Name: types.String{Value: oi.Coffee.Name}, Teaser: types.String{Value: oi.Coffee.Teaser}, Description: types.String{Value: oi.Coffee.Description}, Price: types.Number{Value: big.NewFloat(oi.Coffee.Price)}, Image: types.String{Value: oi.Coffee.Image}, }, Quantity: oi.Quantity, }) } // 完整订单模型 var result = Order{ ID: types.String{Value: strconv.Itoa(order.ID)}, Items: ois, LastUpdated: types.String{Value: string(time.Now().Format(time.RFC850))}, } // 设置状态 diags = resp.State.Set(ctx, result) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } } |
读取操作更新state,使其反映(通过云API得到的)云基础设施对象的最新的、真实状态。
实现读操作的时候,没有plan(用户提供的配置)可用。你需要从请求中读取当前的状态,从中取得执行调用云API所需的信息。
在更新时,Provider理论上可以修改state中的任何值,但是主要应当:
- 处理值漂移(drift),也就是外部系统或人员修改了Terraform所拥有(创建并在状态中管理)的资源。漂移的值总应该反映到state中
- 处理语义上没有改变的值,比如某个值是个JSON,它的字段顺序可能调整了,尽管新旧值并不是逐字节相等的,但是并不应当更新state
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
func (r resourceOrder) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) { // 获取当前状态,状态和计划一样,都是模型 var state Order diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } // 得到状态中的ID,则是访问HashiCups所需参数 orderID := state.ID.Value // 通过HashiCups客户端获取资源最新信息 order, err := r.p.client.GetOrder(orderID) if err != nil { resp.Diagnostics.AddError( "Error reading order", "Could not read orderID "+orderID+": "+err.Error(), ) return } // 转换为模型 state.Items = []OrderItem{} for _, item := range order.Items { state.Items = append(state.Items, OrderItem{ Coffee: Coffee{ ID: item.Coffee.ID, Name: types.String{Value: item.Coffee.Name}, Teaser: types.String{Value: item.Coffee.Teaser}, Description: types.String{Value: item.Coffee.Description}, Price: types.Number{Value: big.NewFloat(item.Coffee.Price)}, Image: types.String{Value: item.Coffee.Image}, }, Quantity: item.Quantity, }) } // 设置到状态 diags = resp.State.Set(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } } |
更新操作将用户对资源配置的修改,通过云API同步到基础设施对象,然后更新state。关键步骤:
- 从请求中读取plan数据,并绑定到模型
- 将模型转换为云API请求
- 将响应转换并同步到模型,写入state
需要注意:known的值在更新前后,必须在state中逐字节对应。state不能包含任何unknown的值。
删除操作读取state信息,发起云API请求删除对应基础设施对象,然后从state中清除资源记录。
清除资源记录时调用 State.RemoveResource方法。
ImportState方法可以创建一个初始的state,将资源纳管起来。此方法的实现必须确保后续读操作能够正常刷新状态。
通常需要从请求中读取 req.ID,并设置到响应中:
1 2 3 4 |
resp.State.SetAttribute(ctx, path, req.ID) // 或者,直接调用: tfsdk.ResourceImportStatePassthroughID() |
数据源的资源模型和资源没有区别,数据源仅仅支持Read方法。该方法无法使用plan或state,它只能从 tfsdk.ReadDataSourceRequest中读取调用云API所需的参数。
属性就是Provider、资源、数据源的Schema的字段,属性持有最终落地到state的值。每个属性都有对应的类型。当你从config、state或者plan访问属性时,实际上是访问属性的值。
任何类型的属性,都可以持有这两种值。
Null表示值不存在,通常是由于用户没有给optional属性赋值。required属性永远不会是Null。
Unknown表示属性的值尚不知道。Unknown值和Terraform的依赖管理有关。Terraform会构建资源之间的依赖DAG,当资源A引用了资源B的属性a,并且a此时的值是Unknown,则此时就Terraform就会转而去处理B,通过调用云API取得a的值,然后再处理A。资源创建/读取后,任何字段都不能是Unknown的。
TPF的 types包,提供了一系列内置属性类型。每个属性类型对应两个Go结构,其中一个用做Schema中的属性类型声明(tfsdk.Attribute.Type),另外一个在定义模型时,作为模型的字段类型。
Schema属性类型 值(模型字段)类型 |
说明 |
StringType String |
表示UTF-8编码的字符串 |
Int64Type Int64 |
64bit整数 |
Float64Type Float64 |
64bit浮点数 |
NumberType Number |
通用数字类型,对应Go语言的 *big.Float |
BoolType Bool |
布尔类型 |
ListType List |
列表类型 types.ListType的 ElemType属性,说明元素的类型 types.List的属性: ElemType,总是和ListType的ElemType一致 types.List的非Null、非Unknown元素,可以直接通过其 ElementsAs方法访问,不需要类型断言 |
SetType Set |
类似于列表,但是元素唯一、无序 |
MapType Map |
具有string类型键的映射 types.Map的属性: ElemType,总是和MapType的ElemType一致 types.Map的非Null、非Unknown元素,可以直接通过其 ElementsAs方法访问,不需要类型断言 |
ObjectType Object |
所谓对象,是指其它若干其它不限类型的属性的无序集合,每个属性都被赋予名字 你需要通过 AttrTypes为对象的所有属性声明名称和类型 types.Object的属性: AttrTypes,总是和ObjectType.AttrType一致 非Null、非Unknown的types.Object的值,可以使用 As方法转换为Go结构,不需要类型断言 |
很多情况下Provider需要访问用户提供的配置数据、Terraform的状态、生成的执行计划中的数据。这些数据通常存放在请求对象中:
1 2 |
func (m myResource) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) |
最简单的访问配置/计划/状态的方法是,将其转换为一个Go类型(模型):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
type resourceData struct { Name types.String `tfsdk:"name"` Age types.Number `tfsdk:"age"` Registered types.Bool `tfsdk:"registered"` Pets types.List `tfsdk:"pets"` Tags types.Map `tfsdk:"tags"` Address types.Object `tfsdk:"address"` } func (m myResource) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { var plan resourceData diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } // 可以通过plan.Name.Value访问计划中的值,你需要检查 // plan.Name.Null 判断值是否是null的 // plan.Name.Unknown 判断值是否是unknown的 } |
上面这些模型,字段类型都是 attr.Value的实现。其好处是,能够知晓值是不是Null、Unknown的,但是带来了不必要的复杂性:
- 这些类型都是Terraform私有的,不可能在云API的SDK中使用这些类型。因此将模型转换为SDK类型时就有了额外的负担,难以自动化转换
好在Get方法能够将值转换为Go类型,因此你可以这样声明模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
type resourceData struct { Name string `tfsdk:"name"` Age int64 `tfsdk:"age"` Registered bool `tfsdk:"registered"` Pets []string `tfsdk:"pets"` Tags map[string]string `tfsdk:"tags"` Address struct{ Street string `tfsdk:"street"` City string `tfsdk:"city"` State string `tfsdk:"state"` Zip int64 `tfsdk:"zip"` } `tfsdk:"address"` } |
警告:Null值/Unknown值可能无法转换,会导致报错。参考下文的转换规则。
另外一种访问配置/计划/状态的方法是,读取单个属性的值。这种情况下,不需要定义模型(除了ObjectType):
1 2 3 4 5 6 7 8 9 10 |
func (m myResource) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { // 属性路径 attr, diags := req.Config.GetAttribute(ctx, tftypes.NewAttributePath().WithAttributeName("age")) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } age := attr.(types.Number) } |
上面提到过,Terraform能够自动将属性值转换为Go类型,这里列出转换规则:
类型 | 说明 |
String | 只要值不为null/unknown,就可以转换为string |
Number |
只要值不为null/unknown,就可以转换为多种Go数字类型 当转换会导致溢出、丢失精度时,返回错误 |
Boolean | 只要值不为null/unknown,就可以转换为bool |
List |
只要值不为unknown,就可以转换为[]ElemType切片,如果值是unknown则返回错误 |
Map | 只要值不为unknown,就可以转换为map[string]ElemType,如果值是unknown则返回错误 |
Object |
只要值不为null/unknown,在满足以下约束条件时,可以转换为Go结构:
|
转换为指针 | 值为null的时候,不会导致报错,其余和转换为非指针类型一致 |
Get方法在进行转换时,会自动Go类型实现的特殊接口。
如果Go类型实现了 tftypes.ValueConverter接口,则转换工作代理给此接口进行。
如果Go类型实现了 Unknownable接口,则Terraform认为它能够处理unknown值。类似的,如果实现了 Nullable接口则认为能够处理null值。
配置、计划仅仅支持读操作,但是状态还支持写操作。
写状态的时候,调用响应的State.Set方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
type resourceData struct { Name types.Strings `tfsdk:"name"` } func (m myResource) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { var newState resourceData newState.Name.Value = "J. Doe" // 持久化到状态中 diags := resp.State.Set(ctx, &newState) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } } |
写入单个属性:
1 2 3 4 5 6 7 8 9 |
func (m myResource) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) { age := types.Number{Value: big.NewFloat(7)} diags := resp.State.SetAttribute(ctx, tftypes.NewAttributePath().WithAttributeName("age"), &age) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } } |
目前TPF框架依赖SDKv2的可接受测试框架,你需要编写和SDKv2可接受测试一样的PreCheck、TestStep...主要区别之处,是如何在TestCase中指定被测试的Provider。
在SDKv2中,通过设置TestCase的Provider属性,来指定map[string]*schema.Provider。在TPF中,则需要指定 ProtoV6ProviderFactories属性。该属性是 tfprotov6.ProviderServer的map。通过下面的方式创建tfprotov6.ProviderServer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
func TestAccTeleportFullVPCMigration(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ // 返回每个需要被测试的Provider "teleport": func() (tfprotov6.ProviderServer, error) { return tfsdk.NewProtocol6Server(New()), nil }, }, Steps: []resource.TestStep{ { Config: ` # 不需要使用terraform块声明从何处加载Provider,因为此Provider就在运行在测试进程内 provider "teleport" { endpoint = "http://127.0.0.1:6080" token = "" } resource "teleport_fullvpcmigration" "fvm" { region = "eu-moscow" vpc_id = "vpc-10" src_app_id = "10" src_uin = "10" src_sub_account_uin = "11" dest_app_id = "20" dest_uin = "20" dest_sub_account_uin = "21" operator = "alex" migrate_timeout = "15s" } `, Check: nil, }, }, }) } |
参考SDKv2基于日志的调试。
目前TPF不支持向SDKv2那样,以调试模式启动Provider。
尽管如此,在可接受测试中,你可以单步跟踪Provider代码。
在开发阶段,你可以在.terraformrc中配置dev_overrides:
1 2 3 4 5 |
provider_installation { dev_overrides { "hashicorp.com/edu/hashicups-pf" = "/Users/Alex/.local/bin" } } |
dev_overrides必须放在所有安装方法的最前面。 dev_overrides会让Terraform跳过适用版本检查、Checksum匹配检查。但是dev_overrides不能参与正常的Provider安装流程,它没有提供满足版本的元数据、不能产生锁文件。
因此,为了使用dev_overrides,你需要将编译好的插件存放在指定目录。然后跳过terraform init,直接运行apply/plan等命令。如果没有配置dev_overrides,跳过init会导致报错,提示Provider的本地缓存不存在或者录制的元数据不匹配。
特殊的块 terraform,可以针对当前配置设置Terraform自身的行为,例如指定应用当前配置所需Terraform的最小版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
terraform { # 配置后端 backend { } # 配置Terraform CLI的版本约束 required_version = "" # 模块需要的所有provider的配置 required_providers { aws = { version = ">= 2.7.0" source = "hashicorp/aws" } } # 启用实验特性 experiments = [example] } |
每个Terraform配置,都可以指定一个后端:后端决定了操作在何处执行,状态快照在何处存储:
- 所谓操作(operation)是指调用云API进行资源的CRUD
- Terraform利用状态(state)来跟踪它管理的资源。它依赖于state来知晓配置文件中的资源和云上基础设施对象的对应关系,典型情况是将云上对象的ID和配置资源关联起来
根据能力的不同,后端分为两类:
- 增强后端,同时支持存储状态、执行操作,只有两个增强后端: local、 remote
- 标准后端,仅仅存储状态,并且依赖于 local后端执行操作
简单场景下可以使用local后端,不需要任何配置。后端配置仅仅被Terraform CLI使用,Terraform Cloud/Enterprise总是使用他们自己的状态存储,并忽略配置文件中的backend块。
该后端在本地文件系统存储状态,并且使用系统API锁定状态数据。该后端在本地直接执行操作。不进行任何backend配置,默认使用的就是该后端。
示例配置:
1 2 3 4 5 6 7 8 |
terraform { backend "local" { # 状态存储路径 path = "relative/path/to/terraform.tfstate" # 如果使用非默认工作区 workspace_dir = "" } } |
用在terraform_remote_state数据资源中:
1 2 3 4 5 6 |
data "terraform_remote_state" "foo" { backend = "local" config = { path = "${path.module}/../../terraform.tfstate" } } |
使用此后端时,大部分Terraform CLI的、从后端读写state快照的命令,都支持以下选项:
-state=FILENAME 读取先前状态快照时,使用的状态文件
-state-out=FILENAME 写入新的状态快照时,使用的状态文件
-backup=FILENAME 写入新状态快照时,先前状态的备份文件。取值 - 禁用备份
配合Terraform Cloud使用,在云端存储状态和执行操作。
在云端执行操作时,terraform plan / apply等命令在Terraform Cloud的运行环境下执行,日志则打印到本地终端。远程的plan/apply使用关联的Terraform云工作区中的变量。
这是个标准后端,只能用于存储状态。示例配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
terraform { backend "etcdv3" { # Etcd服务器列表 endpoints = ["etcd-1:2379", "etcd-2:2379", "etcd-3:2379"] # 是否锁定状态访问 lock = true # 存储前缀 prefix = "terraform-state/" # 基于口令的身份验证 username = "$ETCDV3_USERNAME" password = "$ETCDV3_PASSWORD" # 基于证书的身份验证 cacert_path = "" cert_path = "" key_path = "" # 最大发送的请求,增大此值可以存放更大的状态,必须配合Etcd服务器配置 --max-request-bytes,默认2MB } } data "terraform_remote_state" "foo" { backend = "etcdv3" config = { endpoints = ["etcd-1:2379", "etcd-2:2379", "etcd-3:2379"] lock = true prefix = "terraform-state/" } } |
Terraform需要存储被管理资源的状态,从而实现三个目的:
- 将真实基础设施对象绑定到配置中定义的资源(最核心的用途)
- 跟踪元数据,例如资源依赖关系。通常情况下,Terraform直接使用配置文件来跟踪资源依赖,但是当你删除配置文件片段后,此依赖关系可能被破坏,这是它会利用状态总存储的最近的依赖关系
- 提升管理大规模基础设施时的性能。状态中存储了所有资源的属性值,这样可以避免每次访问属性时都需要请求provider
在执行任何操作之前,Terraform会执行refresh操作来将状态和基础设施同步。 当配置发生变动后,Terraform可能会:
- 如果配置文件中定义了新的资源:调用Provider创建对应基础设施对象,并且将对象的标识符和配置中的资源定义绑定,存储在状态中。Terraform期望基础设施对象和资源定义的实例(使用count/foreach时一个块定义了多个实例)是一一对应关系
- 如果配置文件中删除了资源定义:调用Provider删除对应基础设施对象,并且清除状态中对应数据
如上一章节所述,默认情况下后端local负责存储状态,它默认将状态存储到名为terraform.tfstate的文件中。Terraform还支持大量其它后端,将状态存储到Etcd、S3等各种存储服务中。
尽管状态就是JSON文本,也不要直接修改它。可以使用 terraform state命令进行基本的修改。
通过 terraform import导入外部创建对象时,或者通过 terraform state rm让Terraform忘记某个既有对象时,你必须保证基础设施对象和资源定义的一一对应关系。
该数据源能够从其它Terraform配置的最新状态快照拉取根模块的输出值。这个模块是内置的,不需要配置。
目标配置使用local后端,读取其输出值的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
data "terraform_remote_state" "vpc" { backend = "local" config = { # 目标配置的状态文件 path = "..." } } # Terraform >= 0.12 resource "aws_instance" "foo" { # 读取输出值 subnet_id = data.terraform_remote_state.vpc.outputs.subnet_id } # Terraform <= 0.11 resource "aws_instance" "foo" { # ... subnet_id = "${data.terraform_remote_state.vpc.subnet_id}" } |
注意:仅仅目标配置的根模块的输出值被暴露,其子模块的输出值是不可见的。你必须手工在根模块将子模块的输出值再次输出:
1 2 3 4 5 6 7 8 |
module "app" { source = "..." } output "app_value" { # This syntax is for Terraform 0.12 or later. value = module.app.example } |
如果后端支持,Terraform在执行任何可能修改状态的操作时,会锁定状态,防止并发修改损坏数据。
对于大部分命令,都可以使用命令行选项 -lock禁用锁定,但是不推荐这样做。
命令 force-unlock用于强制解锁,这个命令存在危险性,会导致并发修改。
存储在backend中的状态数据,属于一个工作区。最开始仅有一个名为default的工作区,因而对于一个Terraform配置来说,只有一个关联的state。
某些backend支持多个工作区,包括S3、Local、Kubernetes等。
命令 terraform workspace用于管理工作区。
工作区可以用于区分测试/生产环境,开发人员可以在测试环境中创建并行的、完整的基础设施,并且测试配置文件的变更。
当管理大规模系统时,应该重构被拆分出多个配置(而不是引入新工作区),这些配置甚至可能由不同团队管理。
在Local后端中存储的状态,敏感数据直接明文保存在文件中。
Leave a Reply