Menu

  • Home
  • Work
    • Cloud
      • Virtualization
      • IaaS
      • PaaS
    • Java
    • Go
    • C
    • C++
    • JavaScript
    • PHP
    • Python
    • Architecture
    • Others
      • Assembly
      • Ruby
      • Perl
      • Lua
      • Rust
      • XML
      • Network
      • IoT
      • GIS
      • Algorithm
      • AI
      • Math
      • RE
      • Graphic
    • OS
      • Linux
      • Windows
      • Mac OS X
    • BigData
    • Database
      • MySQL
      • Oracle
    • Mobile
      • Android
      • IOS
    • Web
      • HTML
      • CSS
  • Life
    • Cooking
    • Travel
    • Gardening
  • Gallery
  • Video
  • Music
  • Essay
  • Home
  • Work
    • Cloud
      • Virtualization
      • IaaS
      • PaaS
    • Java
    • Go
    • C
    • C++
    • JavaScript
    • PHP
    • Python
    • Architecture
    • Others
      • Assembly
      • Ruby
      • Perl
      • Lua
      • Rust
      • XML
      • Network
      • IoT
      • GIS
      • Algorithm
      • AI
      • Math
      • RE
      • Graphic
    • OS
      • Linux
      • Windows
      • Mac OS X
    • BigData
    • Database
      • MySQL
      • Oracle
    • Mobile
      • Android
      • IOS
    • Web
      • HTML
      • CSS
  • Life
    • Cooking
    • Travel
    • Gardening
  • Gallery
  • Video
  • Music
  • Essay

Terraform快速参考

20
Oct
2021

Terraform快速参考

By Alex
/ in IaaS
0 Comments
简介

Terraform用于实现基础设施即代码(infrastructure as code)—— 通过代码(配置文件)来描述基础设施的拓扑结构,并确保云上资源和此结构完全对应。Terraform有三个版本,我们主要关注Terraform CLI。

Terraform CLI主要包含以下组件:

  1. 命令行前端
  2. Terraform Language(以下简称TL,衍生自HashiCorp配置语言HCL)编写的、描述基础设施拓扑结构的配置文件。配置文件的组织方式是模块。本文使用术语“配置”(Configuration)来表示一整套描述基础设施的Terraform配置文件
  3. 针对各种云服务商的驱动(Provider),实现云资源的创建、更新和删除

云上资源不单单包括基础的IaaS资源,还可以是DNS条目、SaaS资源。事实上,通过开发Provider,你可以用Terraform管理任何资源。

Terraform会检查配置文件,并生成执行计划。计划描述了那些资源需要被创建、修改或删除,以及这些资源之间的依赖关系。Terraform会尽可能并行的对资源进行变更。当你更新了配置文件后,Terraform会生成增量的执行计划。

命令行
安装命令行

直接到https://www.terraform.io/downloads.html下载,存放到$PATH下即可。

基本特性
切换工作目录

使用选项  -chdir=DIR

Shell自动完成

使用 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)"
  }
}
init

配置工作目录,为使用其它命令做好准备。

Terraform命令需要在一个编写了Terraform配置文件的目录(配置根目录)下执行,它会在此目录下存储设置、缓存插件/模块,以及(默认使用Local后端时)存储状态数据。此目录必须进行初始化。

初始化后,会生成以下额外目录/文件:

.terraform目录,用于缓存provider和模块
如果使用Local后端,保存状态的terraform.tfstate文件。如果使用多工作区,则是terraform.tfstate.d目录。

对配置的某些变更,需要重新运行初始化,包括provider需求的变更、模块源/版本约束的变更、后端配置的变更。需要重新初始化时,其它命令可能会无法执行并提示你进行初始化。

命令 terraform get可以仅仅下载依赖的模块,而不执行其它init子任务。

运行 terraform init -upgrade会强制拉取最新的、匹配约束的版本并更新依赖锁文件。

validate

校验配置是否合法。

plan

显示执行计划,即当前配置将请求(结合state)哪些变更。Terraform的核心功能时创建、修改、删除基础设施对象,使基础设施的状态和当前配置匹配。当我们说运行Terraform时,主要是指plan/apply/destroy这几个命令。

terraform plan命令评估当前配置,确定其声明的所有资源的期望状态。然后比较此期望状态和真实基础设施的当前状态。它使用state来确定哪些真实基础设施对象和声明资源的对应关系,并且使用provider的API查询每个资源的当前状态。当确定到达期望状态需要执行哪些变更后,Terraform将其打印到控制台,它并不会执行任何实际的变更操作。

保存计划

terraform plan命令得到的计划可以保存起来,并被后续的terraform apply使用:

Shell
1
terraform plan -out=FILE 
计划模式

plan命令支持两种备选的工作模式:

  1. 销毁模式:创建一个计划,其目标是销毁所有当前存在于配置中的远程对象,留下一个空白的state。对应选项 -destroy
  2. 仅刷新模式:创建一个计划,其目标仅仅是更新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

应用执行计划,创建、更新设施对象。

apply会做plan的任何事情,并在其基础上,直接执行变更操作。默认情况下,apply即席的执行一次plan,你也可以直接使用已保存的plan

命令格式: terraform apply [options] [plan file]

自动确认

选项 -auto-approve可以自动确认并执行所需操作,不需要人工确认。

使用已有计划

如果指定plan file参数,则读取先前保存的计划并执行。

计划模式

支持plan命令中关于计划模式的选项。

其它选项

-input=false、-parallelism=n等选项含义和plan命令相同。特有选项:

选项 说明
-lock-timeout=DURATION 对状态加锁的最大时间
destroy

删除先前创建的基础设施对象。

当前配置(+工作区)管理的所有资源都会被删除,destroy会使用状态数据确定哪些资源需要删除。

console

在交互式命令中估算Terraform表达式。

fmt

格式化配置文件

force-unlock

强制解除当前工作区的状态锁。当其它terraform进程锁定状态后,没有正常解锁时使用。如果其它进程仍然在运作,可能导致状态不一致。

get

安装或升级远程Terraform模块。格式: terraform get [options] PATH。

选项:

-update 检查已经下载的模块的新版本,如果存在匹配约束的新版本则更新
-no-color 禁用彩色输出

graph

生成操作中包含步骤的图形化表示。

import

导入现有的基础设施对象,让其关联到配置中的资源定义。

login

获取并保存远程服务(例如模块私服)的登录凭证。

logout

删除远程服务(例如模块私服)的登录凭证。

output

显示根模块的输出值。格式: terraform output [options] [NAME]

providers

显示此模块依赖的providers

refresh

更新状态,使其和远程基础设施匹配。

show

显示当前状态或保存的执行计划。

taint

将资源实例标记为“非功能完备(fully functional)”的。

所谓非功能完备,通常意味着资源创建过程出现问题,存在部分失败。此外taint子命令也可以强制将资源标记为非功能完备。

因为上述两种途径,进入tainted状态的资源,不会立即影响基础设施对象。但是在下一次的plan中,会销毁并重新创建对应基础设施对象。

命令格式: terraform [global options] taint [options] <address>

untaint

解除资源的tainted状态。

workspace

管理和切换工作区。

TL语言
块

配置文件由若干块(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中反斜杠不用于转义,可以使用:

$${ 字符串插值标记${
%%{ 模板指令标记%{

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

要将对象转换为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>引用指定类型、名称的资源。 其值可能是:

  1. 如果资源没有count/for_each参数,那么值是一个object,可以访问资源的属性
  2. 如果资源使用count参数,那么值是list
  3. 如果资源使用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表达式

使用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块。

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表达式

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)定义,复杂类型分为两类:

  1. 集合类型:组合相似(类型)的值
  2. 结构类型:组合可能不同的值
集合类型

集合类型包括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文档某些时候也不去区分,这是由于以下类型转换行为:

  1. 可能的情况下,Terraform会自动在相似复杂类型之间进行转换:
    1. object和map是相似的,只要map包含object的schema所要求的键集合,即可自动转换。多余的键在转换过程中被丢弃,这意味着map - object - mapl两重转换可能丢失信息
    2. tuple和list是相似的,但是转换仅仅在list元素数量恰好满足tuple的schema时发生
    3. set和tuple/list是相似的:
      1. 当list/tuple转换为set,重复的值会被丢弃,元素顺序消失
      2. 当set转换为list/tuple,元素的顺序是任意的,一个例外是set(string),将会按照元素字典序生成list/tuple
  2. 可能的情况下,Terraform会自动转换复杂类型的元素的类型,如果元素是复杂类型,则递归的处理

每当提供的值,和要求的值类型不一致时,自动转换都会发生。

module/provider的作者应该注意不同类型的差别,特别是在限制输入方面能力的不同。

动态类型any

特殊关键字any用做尚未决定的类型的占位符,其本身并非一个类型。当解释类型约束的时候,Terraform会尝试寻找单个的实际类型,替换any关键字,并满足约束。

例如,对于list(any)这一类型约束,对于给定的值["a", "b", "c"]其实际类型是tuple([string, string, string]),当将该值赋值给list(any)变量时,Terraform分析过程如下:

  1. tuple和list是相似类型,因此应用上问的tuple-list转换规则
  2. tuple的元素类型是string,满足any约束,因此将其替换,结果类型是list(string)
可选object属性

从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

资源(resource)

资源是TL语言中最重要的元素,由resource块定义。正如其名字所示,资源声明了某种云上基础设施的规格,这些基础设施可以是虚拟机、虚拟网络、DNS记录,等等。

数据源是一种特殊的资源,由data块定义。

行为

当你第一次为某个资源编写配置时,它值存在于配置文件中,尚未代表云上的某个真实基础设施对象。通过应用Terraform配置,触发创建/更新/销毁等操作,实现云上环境和配置文件的匹配。

生命周期

当一个新的资源被创建后,对应真实基础设施对象的标识符被保存到Terraform的State中。这个标识符作为后续更新/删除的依据。对于State中已经存在关联的标识符的那些Resource块,Terraform会比较真实基础设施对象和Resource参数的区别,并在必要的时候更新对象。

概括起来说,当Terraform配置被应用时:

  1. 创建存在于配置文件中,但是在State中没有关联真实基础设施对象的资源
  2. 销毁存在于State中,但是不存在于配置文件的资源
  3. 更新参数发生变化的资源
  4. 删除、重新创建参数发生变化,但是不能原地(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会自动并行处理没有依赖关系的资源。

Local-Only资源

这类特殊的资源不会对应某个云上基础设施对象,而是仅仅存在于Terraform本地State中。Local-Only资源用于一些中间计算过程,包括生成随机ID、创建私钥等。

Local-Only资源的行为和普通资源一致,只是其结果数据仅仅存在于State中,删除时也仅仅是从State中移除对应数据。

语法
1
2
3
resource "resource_type" "local_name" {
  # arguments...
}

两个标签分别代表资源的类型和本地名称。

资源类型提示正在描述的是那种云上基础设施,资源类型决定可用的参数集。本地名称用于在模块的其它地方饮用该资源,此外没有意义。资源类型+本地名称是资源的唯一标识,必须在模块范围内唯一。

Provider

每一种资源都由一个Provider来实现。Provider是Terraform的插件,它提供若干资源类型。通常一个云服务商提供提供一个Provider。初始化工作目录时Terraform能够自动从Terraform仓库下载大部分所需的Provider。

模块需要知道,利用哪些Provider才能管理所有的资源。此外Provider还需要经过配置才能工作,例如设置访问云API的凭证。这些配置由根模块负责。

元参数

元参数可以用于任何资源类型。

depends_on

该元参数用于处理隐含的资源/模块依赖,这些依赖无法通过分析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,
  ]
} 
count

默认情况下,一个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]
for_each

如果资源的规格几乎完全一致,可以用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的值的元素,有如下限制:

  1. 键必须是确定的值,如果应用配置前值无法确定会报错。例如,你不能引用CVM的ID,因为这个ID必须在配置应用之后才可知
  2. 如果键是函数调用,则此函数不能是impure的(非幂等),impure函数包括uuid/bcrypt/timestamp等
  3. 敏感(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

这个参数用于指定使用的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被配置好。 

lifecycle

该参数可以对资源的生命周期进行控制。示例:

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参数

某些资源类型提供了特殊的 timeouts内嵌块,用于指定多长时间后认定操作失败:

1
2
3
4
5
6
7
8
resource /* ... */ {
  # ...
  timeouts {
    create = "60m"
    update = "30s"
    delete = "2h"
  }
}
资源特定参数

资源的绝大部分参数由资源类型决定。需要翻阅Provider的文档了解哪些参数可用。

对于大部分公共的、托管在Terraform仓库的Provider来说,其文档可以直接在仓库网站上获得。

Provisioner

对于一些无法使用Terraform声明式模型来表达的某些行为,可以使用Provisioner作为最后(总是不推荐的)手段。

使用Provisioner会引入复杂性和不确定性:

  1. Terraform无法将Provisioner执行的动作,作为计划的一部分。因为Provisioner理论上可以做任何事情
  2. Provisioner通常需要使用更多细节信息,例如直接访问服务器的网络、使用Terraform的登录凭证
self对象

Provisioner块不能用名字访问其所在的上下文资源,你必须使用 self对象。这个对象就指代对应的资源,你可以访问它的参数和属性。

when参数

该参数指定何时(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时删除、重新创建。

on_failure参数

定制Provisioner失败时的行为:

  1. continue 忽略错误
  2. fail 默认行为,导致配置应用立即失败,如果正在创建资源,则taint该资源
连接设置

大部分Provisioner要求通过SSH或WinRM来访问远程资源。你可以在 connection块中声明如何连接。connection块可以内嵌在以下位置:

  1. resource,对资源的所有Provisioner生效
  2. 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}"
  }
}

关于如何通过证书进行身份验证,如何通过堡垒机连接,参考官方文档。 

null_resource

如果你希望运行一个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)}",
    ]
  }
}
通用Provisioners
Provisioner 说明
file

从运行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
28
resource "aws_instance" "web" {
  # ...
 
  # 文件到文件
  provisioner "file" {
    source      = "conf/myapp.conf"
    destination = "/etc/myapp.conf"
  }
 
  # 字符串到文件
  provisioner "file" {
    content     = "ami used: ${self.ami}"
    destination = "/tmp/file.log"
  }
 
  # 目录到目录,拷贝后生成/etc/configs.d目录
  provisioner "file" {
    source      = "conf/configs.d"
    destination = "/etc"
  }
 
  # Windows下的路径语法
  provisioner "file" {
    # apps/app1/下的所有文件拷贝到D:/IIS/webapp1下
    source      = "apps/app1/"
    destination = "D:/IIS/webapp1"
  }
}

关于整个目录的拷贝,需要注意:

  1. 如果连接type是ssh,则目标目录必须已经存在。你可能需要使用remote-exec预先创建好目录
  2. 原路径以/结尾,则拷贝目录下的所有文件,而非目录本身
local-exec

在资源创建之后,调用一个本地(运行Terraform的机器)可执行程序

注意:即使是在资源创建之后,但是不保证sshd这样的服务已经可用了。因此不要尝试在local-exec中调用ssh命令登录到资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
resource "aws_instance" "web" {
  # ...
 
  provisioner "local-exec" {
    command = "echo ${self.private_ip} >> private_ips.txt"
    # 可选的工作目录
    working_dir = "/root"
    # 可选的解释器
    interpreter = [ "/bin/bash", "-c" ]
    # 可选的环境变量
    environment = {
      FOO = "bar"
    }
  }
}
remote-exec

在资源创建之后,登录到新创建的资源,执行命令

1
2
3
4
5
6
7
8
9
10
11
12
13
resource "aws_instance" "web" {
  provisioner "remote-exec" {
    # 命令列表,逐个执行
    inline = [
      "puppet apply",
      "consul join ${aws_instance.web.private_ip}",
    ]
    # 单个脚本路径
    script = ""
    # 多个脚本路径
    scripts = []
  }
} 
数据资源(data)

数据资源,由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读取完成之前,所有对它结果(导出名称)的引用都是不可用的。

Local-Only数据源

大部分数据源都对应了某种云上基础设施,需要通过云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

支持的容器类型:

  1. 列表: list(<TYPE>)
  2. 集合: set(<TYPE>)
  3. 映射: map(<TYPE>)
  4. 对象: object({<ATTR NAME> = <TYPE>, ... })
  5. 元组: 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
}
给根模块变量赋值

要给根模块中定义的变量赋值,有以下几种方式:

  1. 使用 -var命令行选项,可以多次使用,每次赋值一个变量,示例:
    Shell
    1
    2
    3
    terraform 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"}' 
  2. 作为环境变量传入,示例:
    1
    2
    # 环境变量需要TF_VAR_前缀
    export TF_VAR_image_id=ami-abc123
  3. 使用 .tfvars文件,此文件可以自动载入或者通过命令行选项显式载入,示例:
    testing.tfvars
    1
    2
    3
    4
    5
    image_id = "ami-abc123"
    availability_zone_names = [
      "us-east-1a",
      "us-west-1c",
    ]

    Shell
    1
    terraform apply -var-file="testing.tfvars"

    注意以下文件可以自动识别并载入:

    1. 名为 terraform.tfvars或 terraform.tfvars.json的文件
    2. 以 .auto.tfvars或 .auto.tfvars.json结尾的文件
变量值优先级

如果通过多种方式给变量赋值,则优先级高的生效。优先级顺序从低到高:

  1. 环境变量文件
  2. terraform.tfvars
  3. terraform.tfvars.json
  4. *.auto.tfvars和*.auto.tfvars.json文件,多个文件,按字典序,后面的优先级高
  5. -var或-var-file命令行选项,多个选项,后面的优先级高
输出值

输出值是模块的“返回值”,具有以下用途:

  1. 子模块使用输出值将它创建的资源的属性的子集暴露给父模块
  2. 根模块可以利用输出值,将一些信息在terraform apply之后打印到控制台上
  3. 当使用远程状态(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>

模块(modules)
简介

一套完整的配置文件(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会尝试寻找已经存在的,定义在常规文件中的对应块,并将块的内容进行合并。内容合并规则如下:

  1. 覆盖文件中的顶级块、普通配置文件中的顶级块,对应关系通过块头(块类型+标签)识别,相同块头的块被合并
  2. 顶级块中的参数被替换
  3. 顶级块中的内嵌块被替换,不会递归的进行合并
  4. 多个覆盖文件覆盖了同一个块的定义时,按覆盖文件名的字典序依次合并

此外,对于resource / data块,有如下特殊规则:

  1. 内嵌的lifecycle块不是简单的直接替换。假设覆盖文件仅仅设置了lifecycle的create_before_destroy属性,原始配置中任何ignore_changes参数保持原样
  2. 对于内嵌的provisioner块,原始配置中的(不管有几个)provisioner块直接被忽略
  3. 原始配置中的内嵌connection块被完全覆盖
  4. 元参数(meta-argument)depends_on不能出现在覆盖文件中

对于variable(变量)块,有如下特殊规则:

  1. 如果原始块定义了default参数(默认值)并且覆盖块修改了变量的type,则Terraform尝试将default转换为新的type,如果转换无法自动完成则报错
  2. 如果覆盖块修改了default,那么其值必须匹配原始块中的type

不建议过多的使用覆盖文件,这会降低配置的可读性。

对于output块,有如下特殊规则:

  1. 元参数depends_on不能出现在覆盖文件中

对于local块,有如下特殊规则:

  1. 每个local块定义(或修改)了若干具有名字的值,覆盖时使用value-by-value的方式,至于值在何处定义不影响

对于terraform块,有如下特殊规则:

  1. required_providers的值,按element-by-element的方式进行覆盖。这样,覆盖块可以仅仅调整单个provider的配置,而不影响其他providers
  2. 覆盖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配置传递给子模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
provider "aws" {
  alias  = "usw2"
  region = "us-west-2"
}
 
module "example" {
  source    = "./example"
  providers = {
    # 配置名称由provider块的第1标签(+可选的alias参数)构成
    # 键是子模块中的Provider配置名称
    # 值是父模块中的Provider配置名称
    aws = aws.usw2
  }
}

如果子模块没有定义任何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 Registry

对于希望公开分享的模块,可以存放在这个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配置中添加访问令牌。

GitHub

如果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"
}
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参数。

HTTP

如果source指定为一个普通的URL,那么Terraform会:

  1. 附加GET参数 terraform-get=1,请求那个URL
  2. 如果得到2xx应答,那么尝试从以下位置读取模块实际地址:
    1. 响应头 X-Terraform-Get
    2. HTML元素:
      XHTML
      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模块

大部分模块都会描述需要被创建和管理的基础设施对象,偶尔的情况下,模块仅仅去抓取需要的信息。

其中一种情况是,一套系统被划分为多个子系统,这些子系统都需要获取某种信息,这些信息可以用由一个data-only模块抓取。

发布模块

出于复用目的的模块可以发布到Terraform Registry, 这样模块可以很容易被所有人使用。如果仅仅在组织内部共享,可以发布到私有仓库。

通过Git、S3、HTTP等方式发布模块也是可以的,模块支持多种源。

提供者(providers) 

所谓提供者,就是Terraform的插件/驱动,负责和特定云厂商的API或者其它任何API打交道。

每个Provider都可以提供若干受管资源、数据资源,供Terraform使用。 反过来说,任何资源都是由Provider提供,没有Provider,Terraform什么都做不了。

Provider和Terraform完全独立的分发,公共的Provider托管在Terraform仓库(Registry)。

安装Provider

在每次Terraform运行过程中,需要的Provider会自动被安装。

Terraform CLI会在初始化工作目录的时候安装Provider,它能够自动从Terraform Registry下载Provider,或者从本地镜像/缓存加载。要指定缓存位置,在CLI配置文件中设置 plugin_cache_dir。或者设置环境变量 TF_PLUGIN_CACHE_DIR

为了保证,针对一套配置,总是安装相同版本的Provider,可以使用CLI创建一个依赖锁文件,并将此文件和配置一起纳入版本管理。

引入Provider

每个模块都必须声明它需要哪些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>,各字段说明如下: 

  1. 可选的主机名,默认registry.terraform.io,即Terraform Registry的主机名
  2. 命名空间,通常是发布Provider的组织
  3. 类型,通常是Provider管理的平台/系统的简短名称
版本约束

>= 1.0表示要求1.0或更高版本。 ~> 1.0.4表示仅仅允许1.0.x版本。

内置Provider

目前仅有一个内置于Terraform的Provider,名为terraform_remote_state。你不需要再配置文件中引入它,尽管如此它还是有自己的源地址terraform.io/builtin/terraform。

私有Provider

某些组织可能会开发自己的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。子模块会自动从根模块继承Provider配置。示例:

1
2
3
4
5
#        本地名称,引入Provider时指定
provider "google" {
  project = "acme-app"
  region  = "us-central1"
}

具体哪些配置参数可用,取决于Provider。配置时可以使用表达式,但是只能引用那些应用配置之前即可知的值 —— 可以安全的引用输入变量,但是不能使用那些由资源导出的属性。

alias

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
} 
version

这个元参数已经弃用,是旧的管理Provider版本的方式。 

依赖锁文件

Terraform配置文件可以引用两类外部依赖:

  1. Providers,如上个章节所述,用于和外部系统交互的插件
  2. Modules,可复用的配置文件集合

这两类依赖都可以独立发布,并进行版本管理。引用这些依赖时,Terraform需要知道使用什么版本。

配置文件中的版本约束,指定了潜在的兼容性版本范围。但是到底选择(并锁定使用)依赖的哪个版本,由名为.terraform.lock.hcl的依赖锁文件决定。注意,当前依赖锁文件仅仅管理Provider的版本,对于Module,仍然总是拉取匹配版本约束的最新版本。

每当运行 terraform init命令时,依赖锁文件会自动创建/更新。此文件应该纳入版本管理。依赖锁文件使用和TF类似的语法。

运行terraform init时,如果:

  1. 依赖没有记录在依赖锁文件中,则尝试拉取匹配版本约束的最新版本。并将获取到的版本信息记录到依赖锁文件
  2. 依赖已经记录,则使用记录的版本

运行 terraform init -upgrade会强制拉取最新的、匹配约束的版本并更新依赖锁文件。

开发插件
核心和插件

Terraform逻辑上划分为两个部分:核心和插件。Terraform核心通过RPC来调用插件。Terraform支持多种发现和加载插件的方式。Terraform插件有两类

  1. Provider:通常用于对接到某特定云服务商,在其上创建基础设施对象
  2. Provisioner:对接到某种provisioner,例如Bash

核心的职责包括:

  1. 基础设施即代码:读取和解释配置文件和模块
  2. 资源状态管理
  3. 构造资源依赖图
  4. 执行计划
  5. 通过RPC和插件交互

插件和核心一样,基于Go语言编写。Terraform使用的所有Provider和Provisioner都是插件,它们在独立进程中运行。

Provider插件的职责是:

  1. 初始化任何必要的库,用于进行API调用
  2. 与基础设施提供者进行交互
  3. 定义映射到特定服务的资源

Provisioner插件的职责是:

  1. 在特定资源创建后、销毁前执行命令或脚本
插件的发现

当 terraform init运行后,Terraform会读取工作目录中的配置文件,确定需要哪些插件。并在多个位置搜索以及安装的插件,下载缺失的插件,确定使用插件的什么版本,并且更新依赖锁文件,锁定插件版本。

关于插件发现,有以下规则:

  1. 如果已经安装了满足版本约束的插件,Terraform会使用其中最新的。即使Terraform Registry有更新的满足版本约束的插件,默认也不会主动下载。使用 terraform init -upgrade可以强制下载最新版本
  2. 如果没有安装满足版本约束的插件,且插件托管在Registry,则下载并存放到 .terraform/providers/目录下
Provider设计原则
专注于单一API或SDK 

Provider应该基于单一API集合,或者SDK,例如仅仅针对腾讯云API,实现对腾讯云基础实施对象CRUD的封装。

资源应当表示单一API对象

Terraform插件定义的资源,应该对应单一的云资源,作为该云资源的声明式表示。资源通常应该提供创建/读取/删除,以及可选的更新方法。

对多个云资源的组合,或者其它高级特性,应该通过模块来实现。

资源及其属性的Schema应当尽可能和底层API匹配

名称、数据类型、结构,应当尽可能匹配,除非这样做会影响Provider用户的体验。

资源应该可导入

Terraform资源应该支持 terraform import操作。

注意状态和版本

一旦Provider发布,后续就面临向后兼容性问题。

两个SDK

开发Provider时,有两个SDK可供选择:

  1. SDKv2:当前大部分现有的Provider基于此SDK开发,提供稳定的开发体验
  2. Terraform Plugin Framework:新的SDK,还在积极的开发中。目标是提升开发体验,对齐Terraform新的架构

如何选择:

  1. 如果维护既有Provider,沿用它当前使用的SDK。如果开发全新的Provider,可以考虑使用Framework
  2. 如果使用的Terraform版本小于v1.0.3,则只能基于SDKv2开发。Framework基于Terraform Protocol Version 6构建,旧版本的Terraform不能下载基于Version 6的Provider
  3. Framework的接口可能发生改变,考虑成本
  4. 是否需要Framework提供的新特性:
    1. 支持获知一个值是否在配置、状态或计划中设置
    2. 支持获知一个值是否null、unknown,或者是空白值
    3. 支持object这样的结构化类型
    4. 支持嵌套属性
    5. 支持以any类型为元素的map
    6. 支持获知何时一个optional或计算出的字段从配置中移除了
  5. 是否需要Framework尚未实现的,SDKv2已经支持的特性:
    1. 超时支持
    2. 定义资源状态upgraders
手工构建和发布Provider
构建Provider

构建Provider的时候,使用go build命令,按照构建二进制文件的常规方式即可。你也可以使用GoReleaser来自动化构建多平台的、包含checksum的、自动签名的Provider。

准备一个发布

每个发布包含以下文件:

  1. 一个或多个zip文件,其命名格式为 terraform-provider-{NAME}_{VERSION}_{OS}_{ARCH}.zip
    1. zip中包含Provider的二进制文件,命名为 terraform-provider-{NAME}_v{VERSION}
  2. 一个摘要文件 terraform-provider-{NAME}_{VERSION}_SHA256SUMS,包含每个zip的sha256:
    Shell
    1
    shasum -a 256 *.zip > terraform-provider-{NAME}_{VERSION}_SHA256SUMS
  3. 一个GPG二进制文件 terraform-provider-{NAME}_{VERSION}_SHA256SUMS.sig,使用密钥对上述摘要文件进行前面:
    Shell
    1
    gpg --detach-sign terraform-provider-{NAME}_{VERSION}_SHA256SUMS
  4. 发布必须是finalized的(不是一个私有的草稿)
基于SDKv2开发Provider

本章开发和使用一个Provider,它和虚构的咖啡店应用Hashicups进行交互。此咖啡店应用支持查看和订购咖啡,它开放公共API端点:

  1. 列出咖啡品种
  2. 列出特定咖啡的成分

以及需要身份认证的API端点:

  1. CRUD咖啡订单

HashiCups Provider基于一个Golang客户端库,利用REST API访问以上API端点,管理咖啡订单。

使用Provider

首先我们从用户角度来感受一下,如何使用HashiCups Provider管理咖啡订单。本章后续会分析和重构该Provider的源码。

下载空白项目

执行下面的命令下载使用HashiCups Provider的Terraform配置的空白项目。此项目没有Terraform配置,但是提供了在本地运行HashiCup咖啡店应用的脚本。

Shell
1
2
git clone https://github.com/hashicorp/learn-terraform-hashicups-provider
cd learn-terraform-hashicups-provider

执行下面的命令,在本地启动HashiCup咖啡店应用:

Shell
1
cd docker_compose && docker-compose up

API监听端口是19090。检查并确认服务器正常工作:

Shell
1
curl localhost:19090/health 
安装Provider

从0.13+开始,必须在Terraform配置中声明所有依赖的Provider及其源(从哪里下载)。源的格式为[hostname]/[namespace]/[name],对于Terraform Registry中的hashicorp命名空间,hostname和namespace都是可选的。Terraform Registry对应的hostname为registry.terraform.io

这里用到的Provider没有托管在Registry,需要手工下载:

Shell
1
curl -LO https://github.com/hashicorp/terraform-provider-hashicups/releases/download/v0.3.1/terraform-provider-hashicups_0.3.1_linux_amd64.zip

或者从源码编译:

Shell
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

创建用户
Shell
1
curl -X POST localhost:19090/signup -d '{"username":"education", "password":"test123"}'

登录以获得Token:

Shell
1
2
curl -X POST localhost:19090/signin -d '{"username":"education", "password":"test123"}'
{"UserID":1,"Username":"education","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw"}

将Token设置到环境变量:

Shell
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源码:

Shell
1
git clone --branch boilerplate https://github.com/hashicorp/terraform-provider-hashicups

注意SDKv2的依赖:

go.mod
1
github.com/hashicorp/terraform-plugin-sdk/v2 v2.8.0 
骨架代码

分支boilerplate包含一些样板文件。 程序的入口点如下:

main.go
Go
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函数:

hashicups/provider.go
Go
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_开头: 

hashicups/data_source_coffee.go
Go
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应该尽量和基础实施匹配:

JSON
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方法中:

Go
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后,我们需要实现读操作: 

hashicups/data_source_coffee.go
Go
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:

Go
1
2
3
4
if resourceDoesntExist {
  d.SetID("")
  return
}

这样Terraform会自动将state中的数据资源清除掉。

将上面的数据资源配置到Provider中:

Go
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。 

根模块配置:

examples/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
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子模块配置:

examples/coffee/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
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
  }
}

应用根模块:

Shell
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的实现又是如何读取这些参数的。

Provider的Schema 
hashicups/provider.go
Go
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,
    }
}
配置Provider 

用户提供的username/password,如何被Provider的ReadContext函数访问呢?这需要配置Provider。配置过程是由 schema.Provider的 ConfigureContextFunc函数负责的:

hashicups/provider.go
Go
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操作的最后一个参数:

Go
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后的结果。

首先我们创建几个咖啡订单(用于后续查询):

Shell
1
curl -X POST -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders -d '[{"coffee": { "id":1 }, "quantity":4}, {"coffee": { "id":3 }, "quantity":3}]'

看一下订单的数据结构:

JSON
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
    }
  ]
}

下面我们定义对应的数据源:

hashicups/data_source_order.go
Go
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的结构没有做对应,进行了扁平化处理。

读取订单的操作如下:

hashicups/data_source_order.go
Go
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:

Go
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,
    }
}

在配置文件中使用该数据源:

Go
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的函数:

Go
1
type ConfigureContextFunc func(context.Context, *ResourceData) (interface{}, diag.Diagnostics)

警告/错误级别的调试信息,都要放到diag.Diagnostics中,执行Terraform CLI命令时,这些信息会自动打印。

当创建HashiCups客户端失败时,我们可以添加一条针对信息:

Go
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
}

执行命令时,错误信息会打印出来:

Shell
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_,下面是订单资源的骨架代码:

hashicups/resource_order.go
Go
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(),
        },
        // ...
    }
}
订单Schema 

不同于上问的订单数据源,这里我们设计了和API结构更加匹配的Schema:

Go
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的其它几个重要区别:

  1. 在资源Schema中,顶级的id属性不存在。这是因为资源中你无法提前知道id,也不能将id作为输入参数。id是在资源创建过程中自动生成的
  2. 在资源Schema中,items是必须字段,而不是计算出的字段。这是因为我们在配置中声明订单资源时必须提供订单项信息
创建操作 
Go
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),