terraform篇03:terraform provider开发避坑指南

原创 吴就业 195 0 2023-07-22

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/14bed69ee25948e3b6940800d45290ee

作者:吴就业
链接:https://wujiuye.com/article/14bed69ee25948e3b6940800d45290ee
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

云原生企业级实战笔记专栏

Go sdk本地开发调试sdk依赖问题

假设你的私有云sdk也是开发中状态,我们知道,在go中,依赖一个sdk并没有像java的maven那样区分快照版本和正式版本,go只能通过打tag发布版本,只有正式版本。而我们不想将未完整测试通过的sdk发布,但本地开发provider又要依赖,怎么解决。

在go.mod中使用replace声明sdk引用本地项目即可,例如:

replace github.wujiuye.com/mycloud_api => /Users/wujiuye/goprojects/src/mycloud_api

关于复杂嵌套结构体的schema声明

不可避免的会遇到一些基础设施资源的创建要求传递的参数是嵌套结构体,这里举个例子介绍如何将API的复杂结构体映射到schema的声明。

首先是model的声明

type CdnDomainModel struct {
    ....
    Sources          []CdnSourceView `tfsdk:"sources"`           // 源站地址
    ....
}

type SourceModel struct {
    Type     types.String `tfsdk:"type"`     // ipaddr:IP源站; domain:域名源站。
    Content  types.String `tfsdk:"content"`  // 回源地址
    Priority types.Int64  `tfsdk:"priority"` // 源站地址对应的优先级
    Port     types.Int64  `tfsdk:"port"` 
    Weight   types.Int64  `tfsdk:"weight"` // 回源权重
}

映射到schema的声明

func (s *Schema) GetSchema() schema.Schema {
    return schema.Schema{
       MarkdownDescription: "CDN domain instance resource",
       Attributes: map[string]schema.Attribute{
          //.......
          "sources": schema.ListNestedAttribute{
             Required:            true,
             MarkdownDescription: "回源地址",
             NestedObject: schema.NestedAttributeObject{
                Attributes: map[string]schema.Attribute{
                   "type": schema.StringAttribute{
                      Required:            true,
                      MarkdownDescription: "回源地址类型",
                      PlanModifiers: []planmodifier.String{
                         stringplanmodifier.RequiresReplace(),
                      },
                      Validators: []validator.String{
                         stringvalidator.OneOf(string(cdn.TYPE_IPADDR), string(cdn.TYPE_DOMAIN)),
                      },
                   },
                   "content": schema.StringAttribute{
                      Required:            true,
                      MarkdownDescription: "回源地址ip或域名",
                      PlanModifiers: []planmodifier.String{
                         stringplanmodifier.RequiresReplace(),
                      },
                   },
                   "port": schema.Int64Attribute{
                      Required:            true,
                      MarkdownDescription: "回源端口",
                      PlanModifiers: []planmodifier.Int64{
                         int64planmodifier.RequiresReplace(),
                      },
                   },
                   "priority": schema.Int64Attribute{
                      Required:            true,
                      MarkdownDescription: "源站地址对应的优先级",
                      PlanModifiers: []planmodifier.Int64{
                         int64planmodifier.RequiresReplace(),
                      },
                      Validators: []validator.Int64{
                         int64validator.OneOf(20, 30),
                      },
                   },
                   "weight": schema.Int64Attribute{
                      Required:            true,
                      MarkdownDescription: "回源权重",
                      Validators: []validator.Int64{
                         int64validator.AtLeast(0),
                         int64validator.AtMost(100),
                      },
                      PlanModifiers: []planmodifier.Int64{
                         int64planmodifier.RequiresReplace(),
                      },
                   },
                },
             },
             PlanModifiers: []planmodifier.List{
                listplanmodifier.RequiresReplace(),
             },
             Validators: []validator.List{
                listvalidator.SizeAtLeast(1),
             },
          },
          //......
       },
    }
}

最后是使用

// [{"type":"ipaddr","content":"192.168.2.2","port":8080,"priority":20,"weight":10}]
variable "sources" {
  type = list(object({
    type : string
    content : string
    port : number
    priority : number
    weight : number
  }))
  description = "回源地址"
}

resource "mycloud_cdn_domain" "cdn_domain" {
  ......
  sources           = var.sources
  ......
}

状态死循环监听,以及terraform命令终止时如何终止死循环

资源的创建实际会很耗时,调用创建接口响应成功资源可能还未创建出来,此时资源处于创建中,并在API响应给出资源的当前状态。

通过terraform去申请基础设施资源需要等待资源确实创建出来且可用才是成功,因此需要不断的去查询资源的状态。

无论是轮询一段时间超时失败,还是一直死循环轮询资源状态,如果客户端执行命令直接强行中断,轮询应该能够停止。

以下是死循环轮询,支持客户端强制终止的实现方案。基于上一节的vpc案例改造。

func (r *VpcResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    var data *VpcResourceModel

    // Read Terraform plan data into the model
    resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)

    if resp.Diagnostics.HasError() {
       return
    }

    vpc := &VpcDto{
       Name:       data.Name.ValueString(),
       IpCapacity: data.IpCapacity.ValueInt64(),
    }
    vpcBytes, _ := json.Marshal(vpc)

    // 发送post请求
    httpReq, _ := http.NewRequest("POST",
       fmt.Sprintf("%s/vpc/create", r.client.Endpoint),
       bytes.NewReader(vpcBytes))
    // 添加授权信息
    httpReq.Header.Add("Authorization", r.client.Token)
    httpResp, err := http.DefaultClient.Do(httpReq)
    if err != nil {
       resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create vpc, error: %s", err))
       return
    }
    if httpResp.StatusCode != 200 {
       resp.Diagnostics.AddError("Server Error", "status "+httpResp.Status)
       return
    }

    // 读响应body
    respBodyBytes, _ := io.ReadAll(httpResp.Body)
    dto := &VpcDto{}
    err = json.Unmarshal(respBodyBytes, dto)
    if err != nil {
       resp.Diagnostics.AddError("Server Error", fmt.Sprintf("unmarshal err:%s", err))
       return
    }
    data.Id = types.StringValue(dto.Id)
    data.Name = types.StringValue(dto.Name)
    data.IpCapacity = types.Int64Value(dto.IpCapacity)

    // 阻塞等待资源被创建
    dto, err = Retry[*VpcDto](ctx, func() (model *VpcDto, err error) {
       redReq, _ := http.NewRequest("GET",
          fmt.Sprintf("%s/vpc/%s", r.client.Endpoint, data.Id.ValueString()),
          bytes.NewReader([]byte{}))
       // 添加授权信息
       redReq.Header.Add("Authorization", r.client.Token)
       if readResp, serr := http.DefaultClient.Do(redReq); serr != nil {
          return nil, serr
       } else if readResp.StatusCode != 200 {
          return nil, errors.New("status " + readResp.Status)
       } else {
          // 读响应body
          readRespBodyBytes, _ := io.ReadAll(readResp.Body)
          readVpc := &VpcDto{}
          serr = json.Unmarshal(readRespBodyBytes, dto)
          return readVpc, serr
       }
    }, func(model *VpcDto) (bool, error) {
       // 状态为创建中则等待
       if dto.Status == "CREATING" {
          return true, nil
       }
       return false, nil
    }, 5*time.Second)

    if err != nil {
       resp.Diagnostics.AddError("Server Error", fmt.Sprintf("err:%s", err))
    }

    // Write logs using the tflog package
    // Documentation: https://terraform.io/plugin/log
    tflog.Trace(ctx, "created a resource")

    // Save data into Terraform state
    resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}

// 重试支持

func isNetError(err error) bool {
	if err == nil {
		return false
	}
	if err == net.ErrClosed {
		return true
	}
	if strings.Contains(err.Error(), "dial tcp") {
		return true
	}
	if strings.Contains(err.Error(), "i/o timeout") {
		return true
	}
	return false
}

func defaultNetErrorRetry(err error) bool {
	return isNetError(err)
}

func Retry[T any](ctx context.Context,
	refresh func() (model T, err error), // 刷新数据
	needRetry func(model T) (bool, error), // 判断是否需要重试
	interval time.Duration) (model T, err error) { // 间隔时间
	if p := recover(); p != nil {
		err = errors.New("retry panic. stop")
		return
	}
	for {
		model, err = refresh()
		if retry, nrerr := needRetry(model); !retry {
			if nrerr != nil {
				err = nrerr
			}
			return
		} else if err != nil {
			if !defaultNetErrorRetry(err) {
				return
			}
		}
		select {
		case <-ctx.Done():
			err = errors.New("break retry on context done")
			return
		default:
			time.Sleep(interval)
		}
	}
}

资源创建接口的默认可选字段不填遇到的坑

资源创建接口声明的可选带默认值字段,schema不能声明为可选,因为当我们使用的时候,如果不填这个可选参数,terraform认为输入值为null,但是实际调接口的时候,由于接口使用默认值,导致响应结果不是null,这时trraform认为输入与输出不符,会认为这是个bug。

例如:

schema.Schema{
    MarkdownDescription: "CDN record resource",
    Attributes: map[string]schema.Attribute{
        "https_enabled": schema.BoolAttribute{
            Optional:            true,
            MarkdownDescription: "https开关",
            PlanModifiers: []planmodifier.Bool{
               boolplanmodifier.RequiresReplace(),
            },
    },
}

如果执行terraform apply没有设置https_enabled这个变量,那么terraform认为这个变量是null值,如果创建资源的接口返回默认值false,就会抛出如下异常:

│ Error: Provider produced inconsistent result after apply
│ 
│ When applying changes to xxx_cdn_domain.cdn_domain, provider "provider["xxx"]" produced an
│ unexpected new value: .https_enabled: was null, but now cty.False.
│ 
│ This is a bug in the provider, which should be reported in the provider's own issue tracker.

提示信息很明显指出:提供者在申请后产生了(与入参)不一致的结果,https_enabled的当前值为null,但新值为false。

我们可以这样改,对于这类带默认值的可选字段,schema不要声明为Optional,而是声明为Required,然后在编辑.tf代码的时候,为这类输入变量指定默认值,例如:

variable "https_enabled" {
  type    = bool
  default = false
}

HCL代码输入变量的复杂校验

对于枚举值很多的情况,用“!=”要写很长一串,例如:

variable "xx" {
  type        = string
  validation {
    condition     = var.xx == "xx" || var.xx == "yy" || var.xx == "zz" || var.xx == "qq"
    error_message = "Required."
  }
}

terraform提供了contains校验函数来满足这种场景,上述例子可以修改为:

variable "xx" {
  type        = string
  validation {
    condition     = contains(["xx","yy","zz","qq"],var.xx)
    error_message = "Required."
  }
}

另外,terraform还提供了*anytrue*校验函数用于替换多个or组合条件 ,也可用于满足枚举场景,例如:

variable "xx" {
  type        = string
  validation {
    condition     = anytrue([
        var.xx == "xx"
        var.xx == "yy"
        var.xx == "zz"
        var.xx == "qq"
    ])
    error_message = "Required."
  }
}

如果参数是list类型,list的元素也是一个枚举值,可以这样实现:

variable "xx" {
  type = list(string)
  validation {
    condition     = alltrue([for x in var.xx :contains(["xx", "yy", "zz", "qq"], x)])
    error_message = "Invalid list of xx."
  }
}

由于condition要求表达式必须返回一个bool值,只要满足这个条件,就能用terraform提供的各种函数完成复杂参数值校验。

除了alltrue、anytrue、contains函数外,另一个常用的校验函数是can,can接收一个表达式,如果表达式执行没有出错,那么can返回true,否则返回false。

例如使用can实现正则表达式校验:

variable "xx" {
  type        = string
  description = "域名"
  validation {
    condition     = can(regex("[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+.?",var.xx))
    error_message = "Invalid record value."
  }
}

列表对象的复杂校验案例:

假设输入变量是一个list类型,并且list的每个元素都是一个object类型,要求校验列表至少有一个元素,且每个元素都需要对每个字段值做校验。

object的字段、类型以及校验要求如下:

variable "sources" {
  type = list(object({
    type : string
    content : string
    port : number
    priority : number
    weight : number
  }))
  description = "回源地址"
  validation {
    condition = length(var.sources)>=0 && alltrue([
    for s in var.sources : alltrue(
      [
        contains(["ipaddr", "domain"], s.type),
        can(regex("[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+.?", s.content)) && can(regex("(\.((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})){3}", s.content)),
        s.port>=0,
        s.priority == 20 || s.priority==30,
        s.weight>=0&&s.weight<=100
      ])
    ])
    error_message = "Sources required."
  }
}
#云原生

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

KubeVela篇03:了解KubeVela安装一个应用的过程

以一个简单的first-vela-app应用在kubevela上部署为例,介绍应用安装流程。

KubeVela篇02:初识KubeVela,进一步理解OAM模型

KubeVela是面向混合云环境的应用交付控制面,不与任何云产商绑定。KubeVela通过提供插件扩展机制,打通了应用交付涉及的基础设施即代码-terraform等能力。编写一个符合OAM模型的application.yaml就能实现将应用部署起来,包括申请基础设施。实现了声明式部署,且一次编写,到处部署。

KubeVela篇01:部署即代码-编写yaml在KubeVela上交付第一个应用

“部署即代码”即用代码描述一个应用的部署计划。KubeVela就是实现这一目标的平台,让我们可以编写一个符合OAM模型的yaml来描述应用的部署。

terraform篇02:为私有云开发一个terraform provider

很多企业内部为了不与云厂商绑定,避免上云容易下云难的尴尬,以及企业内部可能也会做私有云,或者封装一个混合云平台,因此不能直接用云厂商提供的provider。

terraform篇01:基础设施即代码,初识terraform,用代码减少沟通成本

通常申请基础设施,我们需要向运维描述我们需要什么基础设施、什么规格,运维根据我们的描述去检查是否已经申请过这样的资源,有就会直接给我们使用基础设施的信息,没有再帮我们申请,然后告诉我们使用基础设施的信息,例如mysql的jdbc和用户名、密码。如果将描述代码化,基础设施的申请自动化,就能实现“基础设施即代码”。而terraform就是实现“将描述代码化”的工具软件。

Operator实战4:如何获取已经被删除的pod的日记

在Job场景,如果Job达到backoffLimit还是失败,而且backoffLimit值很小,很快就重试完,没能及时的获取到容器的日记。而达到backoffLimit后,job的controller会把pod删掉,这种情况就没办法获取pod的日记了,导致无法得知job执行失败的真正原因,只能看到job给的错误:"Job has reached the specified backoff limit"。