fangpsh's blog

写一个Terraform Provider

上个月想给腾讯云写一个terraform provider,花了一周的时间入门Golang,参考现有的一些项目完成了CLBCRUD。本来想做成和阿里云的provider 一样完善,但是腾讯云的API 错误百出,有些产品的API 返回参数不一致。算了,等以后遇到具体需求再继续做。把学习写provider 的过程简单记录一下。

入门Golang,读了《Go 语言编程》 的1~4章, 重点看了下map 和interface 就够用了。goroutine、channel 啥的先不用看,写provider 用不到。

官网的教程Writing Custom Providers 比较简单,我是先看这个例子,了解大致的结构。再读alibaba/terraform-provider 源码,照猫画虎。

这篇教程写的也不错Writing a Terraform provider,看完之后很受用,写邮件给作者表示希望能翻译,没理我:( 。

代码目录:

├── README.md
├── api
│   ├── clb
│   ├── common
│   └── cvm
├── main.go
├── qcloud
│   ├── config.go
│   ├── data_source_qcloud_zones.go
│   ├── provider.go
│   ├── resource_qcloud_clb.go
│   ├── resource_qcloud_clb_listener.go
│   ├── resource_qcloud_eip.go
│   └── validators.go
....

main.go

main.go 每个provider 都差不多,包含一个main 函数,build 的时候生成一个二进制文件,照抄即可。

package main

import (
    "github.com/hashicorp/terraform/plugin"
    "terraform-provider-qcloud/qcloud"
)

func main(){
    plugin.Serve(&plugin.ServeOpts{
    ProviderFunc: qcloud.Provider,
    })
}

api/

provider 是通过云产品的api 调用相关的接口对资源进行CRUD,不过腾讯云没有提供golang 的sdk,需要自己封装一下。我参考了阿里云的denverdino/aliyungo,阿里云的provider 也是使用的这个SDK。签名函数直接用了腾讯云的QcloudApi/qcloud_sign_golang

.
├── clb
│   ├── client.go
│   ├── listener.go
│   └── load_balancer.go
├── common
│   ├── client.go
│   └── sign.go
└── cvm

按照不同产品分成多个client,是因为不同产品的API 在参数上有一些区别,例如版本号,接口地址等。我放弃继续实现的主要原因就是腾讯云的API 文档错误百出,各个产品API 风格不统一,非常累。

provider.go

这个文件包含你实现的Provider 所提供的所有内容,具体可以看源码。Provider() 函数返回一个 terraform.ResourceProvider 对象,包含以下内容:

  • Schema 是需要提供给你provider 的参数,例如访问API 的key 之类的。
  • ResourceMap 是你provider 将提供的resources,例如腾讯云的CLB,CVM,CDN 之类的,每个resource 你需要实现特定的CRUD 接口。
  • DataSourcesMap 是你provider 将提供的数据源,例如腾讯云CVM 都提供了哪些镜像,可以通过实现一个镜像的datasource 拿到,在操作resources 的时候使用,使用方法可以参考Terraform中DataSource的深度分析
  • ConfigureFunc 指向一个你实现的函数,用来做一些准备工作,例如初始化上文中出现各种Client,后续API 调用时使用 。

terraform-provider-qcloud/qcloud/provider.go

config.go

ConfigureFunc 指向的函数providerConfigure 中创建了一个 Config 对象,这个对象就来自config.go ,这里面会包含各类client 的init 代码。

terraform-provider-qcloud/qcloud/config.go

validators.go

在实现Resource 或者DataSource 的时候,需要传一些参数给API,服务端对这类参数一般都有规定,除了基本的类型区别,比如Int,String 之类的,还会有必须大于几,小于几,字符串必须以什么结尾开头等等。

例如阿里云provider 的这个片段

func resourceAliyunSlb() *schema.Resource {
    return &schema.Resource{
        Create: resourceAliyunSlbCreate,
        Read:   resourceAliyunSlbRead,
        Update: resourceAliyunSlbUpdate,
        Delete: resourceAliyunSlbDelete,
        Importer: &schema.ResourceImporter{
            State: schema.ImportStatePassthrough,
        },

        Schema: map[string]*schema.Schema{
            "name": &schema.Schema{
            Type:         schema.TypeString,
            Optional:     true,
            ValidateFunc: validateSlbName,
            Computed:     true,
            },

ValidateFunc 调用的 validateSlbName

// SLB
func validateSlbName(v interface{}, k string) (ws []string, errors []error) {
    if value := v.(string); value != "" {
        if len(value) < 1 || len(value) > 80 {
            errors = append(errors, fmt.Errorf(
                    "%q must be a valid load balancer name characters between 1 and 80",
                    k))
                return
            }
    }


    return
}

看看阿里云官网文档的要求

LoadBalancerName

String

负载均衡实例的显示名称。
取值:用户自定义字符串。长度限制为1-80个字符,允许包含字母、数字、‘-’、‘/’、‘.’、‘_’这些字符。

默认值:无。

不指定该参数时,默认由系统分配一个实例名称。

在实现其他Provider 时,对着API 文档敲即可。

resource_*.go

provider 的resource 可以认为是云服务的各项产品了,例如腾讯云的CLB,CDN,CVM 等。按照要求实现增删改查的函数即可。

func resourceQcloudClb() *schema.Resource {
    return &schema.Resource{
        Create: resourceQcloudClbCreate,
        Read:   resourceQcloudClbRead,
        Update: resourceQcloudClbUpdate,
        Delete: resourceQcloudClbDelete,
....

Create

Create 是创建时调用的函数,创建成功后需要调用一下func (*ResourceData) SetId:

SetId sets the ID of the resource. If the value is blank, then the resource is destroyed.

d.SetId(clb.UnLoadBalancerIds[clb.DealIds[0]][0])

这个ID 必须唯一。

Read

获取对应ID 资源的最新状态。如果有一些资源的属性在服务端可以修改的,拉取到最新的,得更新本地的状态,例如CLB 的名称。

Set sets the value for the given key.

If the key is invalid or the value is not a correct type, an error will be returned.

d.Set("load_balancer_type", clb.LoadBalancerSet[0].LoadBalancerType)
d.Set("forward", clb.LoadBalancerSet[0].LoadBalancerName)
d.Set("load_balancer_name", clb.LoadBalancerSet[0].LoadBalancerName)
d.Set("domain_prefix", clb.LoadBalancerSet[0].Domain)

Update

这个函数相对比较复杂,更新tf 文件中资源属性的时候,会调用这个函数,apply 到服务端。一般用 d.HasChange 来检测对应的属性是否改变:

d.Partial(true)

...

if d.HasChange("load_balancer_name") {
    args.LoadBalancerName = d.Get("load_balancer_name").(string)
    argList = append(argList, "load_balancer_name")
    _, err := conn.ModifyLoadBalancerAttributes(args, argList)
    if err != nil {
        return err
    }

    d.SetPartial("load_balancer_name")
}

...

d.Partial(false)
return resourceQcloudClbRead(d, meta)

func (*ResourceData) HasChange

HasChange returns whether or not the given key has been changed.


func (*ResourceData) Partial

Partial turns partial state mode on/off.

When partial state mode is enabled, then only key prefixes specified by SetPartial will be in the final state. This allows providers to return partial states for partially applied resources (when errors occur).


func (*ResourceData) SetPartial

SetPartial adds the key to the final state output while in partial state mode. The key must be a root key in the schema (i.e. it cannot be "list.0").

If partial state mode is disabled, then this has no effect. Additionally, whenever partial state mode is toggled, the partial data is cleared.

Update 一般能用HasChange 应付,但是如果遇到出现子资源这种需求时,就比较麻烦。例如阿里云的一个SLB 后面会挂很多listener(监听器) :

# Create a new load balancer for classic
resource "alicloud_slb" "classic" {
name                 = "test-slb-tf"
internet             = true
internet_charge_type = "paybybandwidth"
bandwidth            = 5

listener = [
    {
        "instance_port" = "2111"
        "lb_port"       = "21"
        "lb_protocol"   = "tcp"
        "bandwidth"     = "5"
    },
    {
        "instance_port" = "8000"
        "lb_port"       = "80"
        "lb_protocol"   = "http"
        "bandwidth"     = "5"
    },
    {
        "instance_port" = "1611"
        "lb_port"       = "161"
        "lb_protocol"   = "udp"
        "bandwidth"     = "5"
    },
    ]
}

这种在Update 时就比较尴尬,因为listener 就是一个list,没有唯一标识,修改一下端口啥的,总不能把所有listener 删除再添加一遍。

阿里云的做法是通过func (*ResourceData) GetChange 难道新、旧的值,互相对比,把改变的listener 删除掉,修改后的listener 新增,HasChange("listener")

不过我在实现腾讯云的CLB Listener 时候,采用的做法是用一个load_balancer_id 指向CLB 的资源ID,这样建立起对应关系:

resource "qcloud_clb_listener" "tcp1234" {
    depends_on = ["qcloud_clb.example"]
    load_balancer_id = "${qcloud_clb.example.id}"
    listener_name = "abcxxxxxsodo"
    load_balancer_port = 1234
    instance_port = 4567
    protocol = 2
}

不过我在给listener 加backend server 的时候,感觉还是得用阿里那种做法。

参考资料:helper/schema feature: nestable resources #2275

Delete

把本地tf 文件对应的资源删除时,会调用这个函数,删除服务端的资源。

data_source_*.go

这个没实现过,不过看起来和resource 差不多,只是大量的属性是Computed,供resource 使用。可以参考阿里云的datasource 实现。


最后,感觉腾讯云和AWS 之间,还差十个阿里云,手动再见。