当前位置: 首页 > article >正文

基础设施即代码初探-开发Terraform Provider管理私有云MySQL实例

基础设施即代码(Infrastructure as Code, IaC)已经成为云时代DevOps实践中不可或缺的一环。通过代码来管理和配置基础设施,我们可以像开发软件一样,用工程化的方法来对待基础设施。在IaC领域,Terraform无疑是最流行的工具之一。

1

ada48eed886903dc363cc68610fc00eb.gif

Terraform和Provider简介

Terraform是一个用于安全高效地构建、更改和版本控制基础设施的工具。它通过一种声明式的语言(HCL)来描述基础设施的期望状态,然后根据这些描述自动化地创建和管理资源。

本质来说,Terraform是一个状态管理工具,对其管理的资源执行CRUD操作,被其托管的资源很多时候是基于云的,但是也可以用其托管其他资源,理论上能通过CRUD表达的任意资源都可以通过其托管。

在 Terraform 中,Provider 是 Terraform 的核心组件之一,用于抽象化与特定云服务或其他基础设施资源的交互。它是一种插件,充当了 Terraform 和外部系统之间的桥梁,允许 Terraform 管理、创建、修改和删除外部资源。每个Provider负责一类特定的资源,例如AWS Provider允许我们管理EC2实例、S3存储桶等AWS资源。Terraform通过丰富的Provider生态,支持管理几乎所有主流的云资源。

Provider的主要功能
1.资源管理

定义可以创建、修改和删除的资源类型。

2.数据源查询

定义只读的数据源,用于从外部系统获取信息。

3.状态同步

Provider 通过 API 调用获取资源的当前状态,与 Terraform 的状态文件保持一致。

虽然Terraform内置了丰富的Provider支持,但在某些场景下,标准Provider可能无法满足业务需求。这时,开发一个自定义的Provider成为了解决问题的关键手段。

那么,什么情况下我们需要创建一个自定义的Provider呢?

  • 支持自定义资源:如果你的基础设施中包含了一些自定义资源或服务(例如内部开发的私有云平台、专有 API 或者公司特定的工具),而这些资源并未被官方提供的 Terraform Providers 支持,那么开发一个自定义 Provider 就可以将这些资源纳入基础设施即代码(IaC)的管理中。

  • 服务开放:如果你的服务未来要面向外部客户开放,一个自定义 Provider 可以作为基础设施自动化的重要组成部分,方便客户通过 Terraform 集成你的服务。

  • 扩展现有Provider:如果现有的Provider 并未满足你的需求(例如,缺少某些资源类型的支持,或者对资源的操作不够灵活),通过自定义 Provider 可以对其进行扩展。


2

f349f4d90add6fab4065e0d6fca5ce16.gif

开发自定义Provider

在开始进入这个主题之前,我们先了解下Provider的整个工作流程:

459f3838a44f69c0465eda83833cabcc.png

可以看到,Provider就是连接Terraform和具体服务API的桥梁。如果我们想要实现一个管理私有云MySQL的Provider,其实调用的也是我们私有云自己的API,只是Terraform和Provider帮助了使用Terraform的用户摆脱了自己对接私有云API的繁琐步骤。

由此,我们也可以知道,如果我们要开发一个Provider,其实本质上就是完成对Terraform Provider的接口适配。

目前,HashiCorp提供了两个用于开发 Terraform Provider 的SDK,Terraform Plugin FrameworkTerraform Plugin SDK ,Terraform Plugin Framework是 HashiCorp 官方推荐的新一代开发框架,设计更现代化,并且基于 Go Context 和 gRPC,强调扩展性和模块化,支持更细粒度的控制,提供更好的类型安全支持。所以本文将采用Terraform Plugin Framework来进行Provider的开发,也推荐所有新的Provider都采用官方的新框架。

1. 环境要求

1.Go 1.21+

2.Terraform v1.8+

3.自己的私有云API服务(或者任意你想对接的资源API都可以)

2. Provider的资源定义

任何Terraform Provider的主要目的都是为Terraform提供资源,资源主要有两种—resource(也可以称为托管资源)以及data sources(也可以称为数据源)。托管资源,通过实现创建、读取、更新和删除(CRUD)方法,支持完整的生命周期管理。而数据源则相对简单,仅实现了CRUD中的读取(Read)部分。当然,也有一种比较特殊的资源定义,也就是Provider本身。让我们用aws的配置来举例:

  1. Provider 定义

  • provider 块用来配置与具体服务的交互方式。

  • 常见的配置项包括认证信息、API 地址、默认区域等。

 
 
provider "aws" {
  region = "us-east-1"
  access_key = "your_access_key"
  secret_key = "your_secret_key"
}
  1. Resource 定义(以aws s3举例)

resource 块用于定义由 Provider 管理的具体资源,这些资源可以进行全部的CRUD操作。

 
 
resource "aws_s3_bucket" "example_bucket" {
  bucket = "my-example-bucket"
  acl    = "private"


  tags = {
    Name        = "My bucket"
    Environment = "Dev"
  }
}

3.Data Sources定义(以aws s3举例)

data 块主要用来定义一些外部服务的现有资源,而不是再去创建新资源。

 
 
data "aws_s3_bucket" "existing_bucket" {
  bucket = "existing-bucket-name"
}

通过以上关于资源的定义,大概可以理出一个Provider的开发顺序,首先进行provider块部分相关的开发,然后进行resource/data块相关的开发。

2. provider结构设计

首先,我们看下官方的SDK中对于provider的接口定义:

 
 
type Provider interface {
  // Metadata should return the metadata for the provider, such as
  // a type name and version data.
  //
  // Implementing the MetadataResponse.TypeName will populate the
  // datasource.MetadataRequest.ProviderTypeName and
  // resource.MetadataRequest.ProviderTypeName fields automatically.
  Metadata(context.Context, MetadataRequest, *MetadataResponse)


  // Schema should return the schema for this provider.
  Schema(context.Context, SchemaRequest, *SchemaResponse)


  // Configure is called at the beginning of the provider lifecycle, when
  // Terraform sends to the provider the values the user specified in the
  // provider configuration block. These are supplied in the
  // ConfigureProviderRequest argument.
  // Values from provider configuration are often used to initialise an
  // API client, which should be stored on the struct implementing the
  // Provider interface.
  Configure(context.Context, ConfigureRequest, *ConfigureResponse)


  // DataSources returns a slice of functions to instantiate each DataSource
  // implementation.
  //
  // The data source type name is determined by the DataSource implementing
  // the Metadata method. All data sources must have unique names.
  DataSources(context.Context) []func() datasource.DataSource


  // Resources returns a slice of functions to instantiate each Resource
  // implementation.
  //
  // The resource type name is determined by the Resource implementing
  // the Metadata method. All resources must have unique names.
  Resources(context.Context) []func() resource.Resource
}

可以看到,如果我们需要实现这个接口,需要实现Metadata,Schema,Configure,DataSources,Resources这几个方法。

Metadata 方法用于提供当前 Provider 的元数据信息,例如类型名称(TypeName)和版本等。这些信息可以用于识别 Provider,或者在需要与 Terraform 核心交互时使用。

Schema 方法用于定义 Provider 的配置结构。例如,用户在 Terraform 中配置 Provider 的时候,可能需要指定 API 的凭证或目标地址。这些配置信息通过此方法定义。

Configure 方法用于初始化 Provider 的运行环境。通常会解析用户配置的参数(例如凭证或其他必要的初始化信息),并生成一个客户端实例或其他相关的资源。

DataSources 方法返回 Provider 支持的所有数据源类型。每个数据源用于从外部系统(如 API)中读取数据并将其提供给 Terraform。

Resources 方法返回 Provider 支持的所有托管资源类型。每个资源代表 Terraform 可以管理的一个实体,例如云服务中的虚拟机、数据库实例等。

第一步,先想下我们的Schema怎么实现。我们已经知道,Schema方法用于定义 Provider 的配置结构,因为本文的例子是要开发一个管理私有云MySQL的Provider,所以我们对接的API服务就是智汇云的OpenAPI。通过智汇云的OpenAPI文档,我们已知,如果要与其进行交互,需要Endpoint,AccessKeyId,AccessKeySecret这些信息,所以我们的Schema其实就是这些信息的一个结构化。

这个接口的定义我们放在provider.go这个文件中实现,以下是这个文件的部分代码。

 
 
// Ensure the implementation satisfies the expected interfaces.
var (
  _ provider.Provider = &ZyunDbProvider{}
)


// New is a helper function to simplify provider server and testing implementation.
func New(version string) func() provider.Provider {
  return func() provider.Provider {
    return &ZyunDbProvider{
      version: version,
    }
  }
}


// ZyunDbProvider defines the provider implementation.
type ZyunDbProvider struct {
  // version is set to the provider version on release, "dev" when the
  // provider is built and ran locally, and "test" when running acceptance
  // testing.
  version string
}


// Metadata returns the provider type name.
func (p *ZyunDbProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
  resp.TypeName = "zyundb"
  resp.Version = p.version
}


// Schema defines the provider-level schema for configuration data.
func (p *ZyunDbProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
  resp.Schema = schema.Schema{
    Attributes: map[string]schema.Attribute{
      "endpoint": schema.StringAttribute{
        Description: "The endpoint of the ZyunDB API",
        Required:    true,
      },
      "access_key_id": schema.StringAttribute{
        Description: "The access key id of the ZyunDB API",
        Required:    true,
      },
      "access_key_secret": schema.StringAttribute{
        Description: "The access key secret of the ZyunDB API",
        Required:    true,
        Sensitive:   true,
      },
    },
  }
}


// Configure prepares a ZyunDB API client for data sources and resources.
func (p *ZyunDbProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {


  // Retrieve provider data from configuration
  var config zyundbProviderModel
  diags := req.Config.Get(ctx, &config)
  resp.Diagnostics.Append(diags...)
  if resp.Diagnostics.HasError() {
    return
  }


  // If practitioner provided a configuration value for any of the
  // attributes, it must be a known value.


  if config.Endpoint.IsUnknown() {
    resp.Diagnostics.AddAttributeError(
      path.Root("endpoint"),
      "Unknown ZyunDB API Host",
      "The provider cannot create the ZyunDB API client as there is an unknown configuration value for the ZyunDB API host. "+
        "Either target apply the source of the value first, set the value statically in the configuration, or use the ZYUNDB_HOST environment variable.",
    )
  }
        //...........................


  if resp.Diagnostics.HasError() {
    return
  }


  // Default values to environment variables, but override
  // with Terraform configuration value if set.


  endpoint := os.Getenv("ZYUNDB_ENDPOINT")




  if !config.Endpoint.IsNull() {
    endpoint = config.Endpoint.ValueString()
  }


  // If any of the expected configurations are missing, return
  // errors with provider-specific guidance.


  if endpoint == "" {
    resp.Diagnostics.AddAttributeError(
      path.Root("endpoint"),
      "Missing ZyunDB API Endpoint",
      "The provider cannot create the ZyunDB API client as there is a missing or empty value for the ZyunDB API endpoint. "+
        "Set the endpoint value in the configuration or use the ZYUNDB_ENDPOINT environment variable. "+
        "If either is already set, ensure the value is not empty.",
    )
  }


  //................................




  if resp.Diagnostics.HasError() {
    return
  }


  // Create a new ZyunDB client using the configuration values
  client := client.NewZyunOpenApiClient(endpoint, accessKeyId, accessKeySecret, "v1", "https")
  if client == nil {
    resp.Diagnostics.AddError(
      "Unable to Create ZyunDB API Client",
      "An unexpected error occurred when creating the ZyunDB API client. "+
        "If the error is not clear, please contact the provider developers.",
    )
    return
  }


  // Make the ZyunDB client available during DataSource and Resource
  // type Configure methods.
  resp.DataSourceData = client
  resp.ResourceData = client


  tflog.Info(ctx, "Configured ZyunDB client end")
}


// Resources defines the resources implemented in the provider.
func (p *ZyunDbProvider) Resources(ctx context.Context) []func() resource.Resource {
  return []func() resource.Resource{
    NewMysqlInstanceResource,
  }
}


// DataSources defines the data sources implemented in the provider.
func (p *ZyunDbProvider) DataSources(_ context.Context) []func() datasource.DataSource {
  return []func() datasource.DataSource{
    NewMysqlInstanceDataSource,
  }
}

其中tflog是Terraform提供的一个日志库,帮忙我们打印日志。

Schema方法中对于每个字段都定义了Required: true是因为这些字段都是我们调用智汇云OpenAPI不可或缺的,而Sensitive: true则代表了这个字段是敏感字段,包含敏感信息,例如密码、密钥或其他不应暴露在日志或 Terraform 状态文件中的数据。

Configure方法,我们如果从配置文件中没有找到对应配置,就会从环境变量中进行寻找。通过找到的这些凭证信息,我们new了一个ZyunClient,这里因为我们是一个demo,所以整个client方法是自己在Provider的项目中实现的。一般如果是生产项目,更推荐将API部分的细节抽象成一个自己的SDK。

而Resources和DataSources方法,返回了后续我们需要定义的两种资源。

如上,我们的provider部分的结构就设计完成了,让我们继续下一步。

3. resource结构设计

首先,我们看下官方的SDK中对于resource的接口定义:

 
 
type Resource interface {
  // Metadata should return the full name of the resource, such as
  // examplecloud_thing.
  Metadata(context.Context, MetadataRequest, *MetadataResponse)


  // Schema should return the schema for this resource.
  Schema(context.Context, SchemaRequest, *SchemaResponse)


  // Create is called when the provider must create a new resource. Config
  // and planned state values should be read from the
  // CreateRequest and new state values set on the CreateResponse.
  Create(context.Context, CreateRequest, *CreateResponse)


  // Read is called when the provider must read resource values in order
  // to update state. Planned state values should be read from the
  // ReadRequest and new state values set on the ReadResponse.
  Read(context.Context, ReadRequest, *ReadResponse)


  // Update is called to update the state of the resource. Config, planned
  // state, and prior state values should be read from the
  // UpdateRequest and new state values set on the UpdateResponse.
  Update(context.Context, UpdateRequest, *UpdateResponse)


  // Delete is called when the provider must delete the resource. Config
  // values may be read from the DeleteRequest.
  //
  // If execution completes without error, the framework will automatically
  // call DeleteResponse.State.RemoveResource(), so it can be omitted
  // from provider logic.
  Delete(context.Context, DeleteRequest, *DeleteResponse)
}

可以看到,如果我们需要实现这个接口,需要实现Metadata,Schema,Create,Read,Update,Delete这几个方法。

Metadata 方法用于提供当前资源的元数据信息,标识资源的唯一名称(资源类型名)。

Schema定义了资源的所有属性及其类型。

Create,Read,Update,Delete就不用说了,对应了资源的增删改查。

看了这些,好像有个问题啊,我上面定义的provider的一些配置,能让我连接到API的那些配置,怎么取到呢?这就涉及到另一个接口—ResourceWithConfigure接口了,让我们看下这个接口的定义:

 
 
type ResourceWithConfigure interface {
  Resource


  // Configure enables provider-level data or clients to be set in the
  // provider-defined Resource type. It is separately executed for each
  // ReadResource RPC.
  Configure(context.Context, ConfigureRequest, *ConfigureResponse)
}

实现这个接口就可以允许资源在初始化时接受来自 Provider 的全局配置(如认证信息、客户端实例、区域设置等)了。

好了,让我们开始开发这个resource的实现吧。

第一步,我们依然是需要思考下我们的Schema怎么实现。在API模式下,一个资源的各种操作对应的参数不一定是一致的,比如我们创建了一个自动分配端口的MySQL实例,创建的接口没传过port,但是获取数据的时候port就有了;我们创建的时候没有传过一个MySQL实例应该有几个从节点,但是扩容实例的时候却需要指明在哪个机房扩容几个节点。而对于IaC来说,因为代码就意味着你的资源,你的代码改了,那么你的资源就会更新,这种情况下我们就需要将所有的这些API的入参出参进行考虑,将整个Schema作为你的资源的整体。

我们先看下创建一个MySQL的入参:

6d382a22e4efc357911cdeb85362e3c9.png

再看下配置变更的入参,可以发现extendInfos完全是个额外的参数,根本与创建实例和下面的实例详情没有关系,但是我们却需要实现整体实例的节点扩容:

8267bebba0be27035dc3033d35432871.png

以及MySQL实例详情的返回值,根据如下返回值,我们其实可以想到,将master和slave的返回信息进行一定的处理就可以作为参数传给配置变更的API了:

 
 
{
  "status" : 200,
  "developer-message" : "",
  "more-info" : "",
  "errno-code" : 0,
  "user-message" : "",
  "data" : {
    "id" : "xxx",
    "port" : "xxx",
    "status" : "1",
    "ctime" : "2024-09-23 19:42:39",
    "utime" : "2024-09-23 19:52:13",
    "pkg_id" : "xxx",
    "db_type" : "master-slave",
    "is_audit" : "1",
    "name" : "test_name",
    "instance_type" : "EXCLUSIVE",
    "network_id" : "xxx",
    "subnet_id" : "xxx",
    "idc" : [ "xxidc" ],
    "rs_num" : [ {
      "cnt" : "2",
      "idc" : "xxidc"
    } ],
    "master" : [ {
      "ip" : "1.1.1.1",
      "idc" : "xxidc",
      "type" : "master",
      "idc_name" : "北京A区"
    } ],
    "slave" : [ {
      "ip" : "2.2.2.2",
      "idc" : "xxidc",
      "type" : "slave",
      "idc_name" : "北京A区"
    } ],
    "vip_data" : [ {
      "port" : "xxx",
      "idc" : "xxidc",
      "vip" : "3.3.3.3",
      "rw_status" : "6",
      "idc_name" : "北京A区"
    } ],
  }
}

根据以上信息,我们暂为Schema定义为如下结构:

 
 
type mysqlInstanceResourceModel struct {
  ID           types.String   `tfsdk:"id"`
  ProjectID    types.String   `tfsdk:"project_id"`
  Port         types.String   `tfsdk:"port"`
  Name         types.String   `tfsdk:"name"`
  PkgID        types.String   `tfsdk:"pkg_id"`
  InstanceType types.String   `tfsdk:"instance_type"`
  Mode         types.String   `tfsdk:"mode"`
  MasterIDC    types.String   `tfsdk:"master_idc"`
  RedundantIDC types.String   `tfsdk:"redundant_idc"`
  NetworkID    types.String   `tfsdk:"network_id"`
  SubnetID     types.String   `tfsdk:"subnet_id"`
  IsAuditLog   types.String   `tfsdk:"is_audit_log"`
  Status       types.String   `tfsdk:"status"`
  CTime        types.String   `tfsdk:"ctime"`
  VipData      types.List     `tfsdk:"vip_data"`
  MasterNum    types.List     `tfsdk:"master_num"`
  SlaveNum     types.List     `tfsdk:"slave_num"`
  Timeouts     timeouts.Value `tfsdk:"timeouts"`
}

其中大部分参数我们都是根据创建实例方法来的,而VipData以及MasterNum和SlaveNum则主要方便后续给用户展示连接方式以及方便用户进行配置变更。

我们将这个resource的定义文件命名为mysql_instance_resource.go,部分代码如下:

 
 
// Ensure the implementation satisfies the expected interfaces.
var (
  _ resource.Resource                = &mysqlInstanceResource{}
  _ resource.ResourceWithConfigure   = &mysqlInstanceResource{}
)




// NewMysqlInstanceResource is a helper function to simplify the provider implementation.
func NewMysqlInstanceResource() resource.Resource {
  return &mysqlInstanceResource{}
}


// mysqlInstanceResource is the resource implementation.
type mysqlInstanceResource struct {
  client *client.ZyunOpenAPI
}


// mysqlInstanceResourceModel maps the resource schema data.
type mysqlInstanceResourceModel struct {
// .........
}


// Metadata returns the resource type name.
func (r *mysqlInstanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
  resp.TypeName = req.ProviderTypeName + "_mysql_instance"
}


// Schema defines the schema for the resource.
func (r *mysqlInstanceResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
  resp.Schema = schema.Schema{
    Attributes: map[string]schema.Attribute{
      "timeouts": timeouts.Attributes(ctx, timeouts.Opts{
        Create: true,
      }),
      "id": schema.StringAttribute{
        Description: "The id of the mysql instance",
        Computed:    true,
        PlanModifiers: []planmodifier.String{
          stringplanmodifier.UseStateForUnknown(),
        },
      },
      "port": schema.StringAttribute{
        Description: "The port of the mysql instance",
        Computed:    true,
        PlanModifiers: []planmodifier.String{
          stringplanmodifier.UseStateForUnknown(),
                                        stringplanmodifier.RequiresReplace(),
        },
      },
      "network_id": schema.StringAttribute{
        Description: "The network id of the mysql instance",
        Required:    true,
      },
      "vip_data": schema.ListNestedAttribute{
        Description: "The vip data of the mysql instance",
        Computed:    true,
        NestedObject: schema.NestedAttributeObject{
          Attributes: map[string]schema.Attribute{
            "vip": schema.StringAttribute{
              Description: "The vip of the vip",
              Computed:    true,
            },
                                                //.....................
          },
        },
      },
                        //........................
    },
  }
}


// Configure adds the provider configured client to the resource.
func (r *mysqlInstanceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
  // Add a nil check when handling ProviderData because Terraform
  // sets that data after it calls the ConfigureProvider RPC.
  if req.ProviderData == nil {
    return
  }


  client, ok := req.ProviderData.(*client.ZyunOpenAPI)


  if !ok {
    resp.Diagnostics.AddError(
      "Unexpected Data Source Configure Type",
      fmt.Sprintf("Expected *client.ZyunOpenAPI, got: %T. Please report this issue to the provider developers.", req.ProviderData),
    )


    return
  }


  r.client = client
}


// Create creates the resource and sets the initial Terraform state.
func (r *mysqlInstanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
  var plan mysqlInstanceResourceModel
  diags := req.Plan.Get(ctx, &plan)
  resp.Diagnostics.Append(diags...)
  if resp.Diagnostics.HasError() {
    return
  }


  // 获取超时上下文
  createTimeout, diags := plan.Timeouts.Create(ctx, 20*time.Minute)
  resp.Diagnostics.Append(diags...)
  if resp.Diagnostics.HasError() {
    return
  }
  ctx, cancel := context.WithTimeout(ctx, createTimeout)
  defer cancel()


  // Generate API request body from plan
  // Create new mysql instance


  result, err := r.client.CreateMySQLInstance(ctx, &client.CreateMySQLInstanceParams{
    ProjectID:    plan.ProjectID.ValueString(),
                //................
  })
  if err != nil {
    resp.Diagnostics.AddError(
      "Error creating mysql instance",
      "Could not create mysql instance, unexpected error: "+err.Error(),
    )
    return
  }


  instanceID := result.Detail.InsID
  plan.ID = types.StringValue(instanceID)


  // Poll to check the instance status
  ticker := time.NewTicker(10 * time.Second)
  defer ticker.Stop()


CheckLoop:
  for {
    select {
    case <-ctx.Done():
      resp.Diagnostics.AddError(
        "Timeout waiting for MySQL instance creation",
        fmt.Sprintf("Instance %s creation did not complete within the timeout period", instanceID),
      )
      return
    case <-ticker.C:
      instance, err := r.client.GetMySQLInstance(ctx, plan.ProjectID.ValueString(), instanceID)
      if err != nil {
        continue
      }


      // Status 1 means ready
      if instance.Status == "1" {
        plan.xx = xx
                                //..........................
        break CheckLoop
      } else if instance.Status == "2" { // 2 means failed
        resp.Diagnostics.AddError(
          "Error creating mysql instance",
          "MySQL instance creation failed",
        )
        return
      }
    }
  }


  diags = resp.State.Set(ctx, plan)
  resp.Diagnostics.Append(diags...)
}


// Read refreshes the Terraform state with the latest data.
func (r *mysqlInstanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
  // Get current state
        //..............


  // Get refreshed order value from HashiCups
  instance, err := r.client.GetMySQLInstance(ctx, state.ProjectID.ValueString(), state.ID.ValueString())
  if err != nil {
    resp.Diagnostics.AddError(
      "Error Reading mysql instance",
      "Could not read mysql instance ID "+state.ID.ValueString()+": "+err.Error(),
    )
    return
  }
        //................................


  // Set refreshed state
  diags = resp.State.Set(ctx, &state)
  resp.Diagnostics.Append(diags...)
}


// Update updates the resource and sets the updated Terraform state on success.
func (r *mysqlInstanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
        // 处理更新值到params................


  err := r.client.UpdateMySQLInstance(ctx, params)
  if err != nil {
                //............
    return
  }


  resp.Diagnostics.AddWarning(
    "Asynchronous Operation",
    "The MySQL instance update is an asynchronous operation. Use 'terraform refresh' to get the latest state after the update completes.",
  )


  diags = resp.State.Set(ctx, plan)
  resp.Diagnostics.Append(diags...)
  if resp.Diagnostics.HasError() {
    return
  }
}


// Delete deletes the resource and removes the Terraform state on success.
func (r *mysqlInstanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
  // Retrieve values from state
  // ..........


  // Delete existing mysql instance
  err := r.client.DeleteMySQLInstance(ctx, state.ProjectID.ValueString(), state.ID.ValueString())
  if err != nil {
    resp.Diagnostics.AddError(
      "Error Deleting mysql instance",
      "Could not delete mysql instance, unexpected error: "+err.Error(),
    )
    return
  }
}

以上代码中有几个需要注意的部分:

1.在schema的定义中,我们使用了Computed: true,和Optional: true这两个上文还未出现过的属性,其中Computed:true是表示此属性的值由提供者(Provider)计算并填充,而不是用户直接提供,也就是类似于自动生成的实例ID之类的。而Optional:true表示此属性是可选的,用户可以选择是否在 Terraform 配置文件中设置该值。

2.stringplanmodifier.UseStateForUnknown()以及stringplanmodifier.RequiresReplace()。

其中UseStateForUnknown表示当 Terraform 无法确定一个属性的新值(即该值是未知的,unknown),此修饰器会指示 Terraform 在计划阶段使用当前状态(state)中的值作为暂时的计划值。适用于在资源生命周期中,新值可能暂时不可用,但现有值可以作为替代。例如,某些属性的值需要依赖外部计算结果(如远程 API 的响应),但这结果在计划阶段尚未可知。

RequiresReplace表示如果该属性的值在配置中发生更改,则需要销毁并重新创建整个资源(触发替换操作)。这个使用场景还是比较常见的,因为很多我们创建时候支持的属性,可能在更新时候我们并不支持更新,那么设置了这个修饰器,Terraform就会自动为我们处理删除并重新创建的流程。

3.Create中的轮询处理

大部分情况下,我们的资源可能并不是瞬时创建完成,并且接口本身是个异步接口。这个时候可以进行轮询处理并指定超时时间,以便最终资源的属性可以回填。此处我们是通过ticker结合Terraform的超时上下文部分来处理的(注意schema的定义要有timeouts属性,而最终的超时时间可以让使用者在资源的使用配置中定义):

// 获取超时上下文
  createTimeout, diags := plan.Timeouts.Create(ctx, 20*time.Minute)
  resp.Diagnostics.Append(diags...)
  if resp.Diagnostics.HasError() {
    return
  }
  ctx, cancel := context.WithTimeout(ctx, createTimeout)
  defer cancel()

4.Update的异步但不处理数据回填

此处我们的Update方法其实也是个异步方法,但是我们没有添加轮询来进行数据的回填,相对的,我们通过增加了一个Warning来告知用户自己去刷新数据:

 
 
resp.Diagnostics.AddWarning(
    "Asynchronous Operation",
    "The MySQL instance update is an asynchronous operation. Use 'terraform refresh' to get the latest state after the update completes.",
  )

以上就是resource部分的实现,可以看出,整体实现方式还是十分简便的,通过Get从Context中获取到配置文件的当前数据,再通过Set将从接口中拉取到的数据回填。

5. data结构设计

我们看下最后一种资源的结构设计,首先还是先看下官方的接口定义:

 
 
type DataSource interface {
  // Metadata should return the full name of the data source, such as
  // examplecloud_thing.
  Metadata(context.Context, MetadataRequest, *MetadataResponse)


  // Schema should return the schema for this data source.
  Schema(context.Context, SchemaRequest, *SchemaResponse)


  // Read is called when the provider must read data source values in
  // order to update state. Config values should be read from the
  // ReadRequest and new state values set on the ReadResponse.
  Read(context.Context, ReadRequest, *ReadResponse)
}

可以看到,整体接口跟之前都差不多,只不过只有最简单的Read方法需要实现了,其余生命周期是没有的。同样,我们也需要实现DataSourceWithConfigure好能够提取之前provider的配置。

 
 
type DataSourceWithConfigure interface {
  DataSource


  // Configure enables provider-level data or clients to be set in the
  // provider-defined DataSource type. It is separately executed for each
  // ReadDataSource RPC.
  Configure(context.Context, ConfigureRequest, *ConfigureResponse)
}

这里的代码就不赘述了,唯一需要注意的部分是在resource的设计中,我们可以看到,比如VipData,我们定义的类型是types.List,所以我们需要在具体的CRUD中写更多的代码去处理type.List到其实际结构的处理。而其实,对于data类型的资源,我们就可以直接使用原始的go切片了。这是为什么呢?原因是resource类型的资源,我们在Create的流程中,想要去使用VipData这个数据,这个时候Terraform需要对其进行解析,如果是go的原始切片,就失去了Terraform对对应类型的一些隐藏处理,同时在执行terraform命令的时候就会报错。而data资源就没有这个顾虑了。

6. 入口文件

最后看一下入口文件的代码吧

 
 
var (
  // these will be set by the goreleaser configuration
  // to appropriate values for the compiled binary.
  version string = "dev"


  // goreleaser can pass other information to the main package, such as the specific commit
  // https://goreleaser.com/cookbooks/using-main.version/
)


func main() {
  var debug bool


  flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve")
  flag.Parse()


  opts := providerserver.ServeOpts{
    // TODO: Update this string with the published name of your provider.
    // Also update the tfplugindocs generate command to either remove the
    // -provider-name flag or set its value to the updated provider name.
    Address: "local/namespace/zyundb",
    Debug:   debug,
  }


  err := providerserver.Serve(context.Background(), provider.New(version), opts)


  if err != nil {
    log.Fatal(err.Error())
  }
}

这里主要需要注意的是Address,如果是已经发布的provider,可以换成对应的域名以及对应的命名空间,因为我们这里是本地环境,所以暂时用local代替。

7.实现单元测试

当然,也别忘了书写单元测试,这里用一个比较简单的单元测试作为示例,具体可以看对应测试包的实现:

 
 
func TestAccMysqlInstanceDataSource(t *testing.T) {
  resource.Test(t, resource.TestCase{
    ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
    Steps: []resource.TestStep{
      // Read testing
      {
        Config: providerConfig + `data "zyundb_mysql_instance" "all" {
    project_id = "xxxx"
}`,
        Check: resource.ComposeAggregateTestCheckFunc(
          // Verify number of coffees returned
          resource.TestCheckResourceAttrSet("data.zyundb_mysql_instance.all", "mysql_instance.#"),
          // Verify the first coffee to ensure all attributes are set
          resource.TestCheckResourceAttrSet("data.zyundb_mysql_instance.all", "mysql_instance.0.id"),
          resource.TestCheckResourceAttrSet("data.zyundb_mysql_instance.all", "mysql_instance.0.name"),
          resource.TestCheckResourceAttrSet("data.zyundb_mysql_instance.all", "mysql_instance.0.port"),
        ),
      },
    },
  })
}

然后执行一下吧:

TF_LOG=ERROR TF_ACC=1 go test -count=1 -run='TestAccMysqlInstanceDataSource' -v

输出:

 
 
=== RUN   TestAccMysqlInstanceDataSource
--- PASS: TestAccMysqlInstanceDataSource (2.66s)
PASS
ok    terraform-provider-zyundb/internal/provider  3.574s

3

7e252757a5bbd795db2fe0615ab877b9.gif

本地使用自己的Provider

既然我们已经实现了自己的Provider,那就来使用一下吧。首先我们先了解下Terraform的工作流:

97db0b2a600d827fb765fbb24a85bea2.png

其实整体来说就是先执行terraform init初始化环境,然后执行terraform plan看下terraform接下来会做什么改变,最后执行terraform apply来应用这个变更。

因为我们使用的是本地的Provider,所以我们首先需要先编辑Terraform CLI配置文件来让其能够发现我们本地的Provider,一般这个文件在~/.terraformrc,打开并修改它:

 
 
provider_installation {


  dev_overrides {
      "local/namespace/zyundb" = "/Users/xxx/terraform-providers"
  }


  # For all other providers, install them directly from their origin provider
  # registries as normal. If you omit this, Terraform will _only_ use
  # the dev_overrides block, and so no other providers will be available.
  direct {}
}

这里要注意local/namespace/zyundb部分要跟我们之前在入口文件里定义的保持一致,而/Users/xxx/terraform-providers就是我们最后用来放二进制文件的地方,这个目录是可以自己随便定义的,只要这个目录下有你最后的Provider的二进制文件。

编译
GOOS=darwin GOARCH=amd64 go build -o terraform-provider-zyundb_v1.0.0

然后将编译好的二进制文件放到上面的目录下。

配置并测试

创建一个main.tf,并编辑如下:

 
 
# Copyright (c) HashiCorp, Inc.


terraform {
  required_providers {
    zyundb = {
      source = "local/namespace/zyundb"
      version = "1.0.0"
    }
  }
}


provider "zyundb" {
  endpoint          = "你的endpoint"
  access_key_id     = "你的access key"
  access_key_secret = "你的access key secret"
}


data "zyundb_mysql_instance" "all" {
    project_id = "你的资源组ID"
}
output "mysql_instance" {
  value = data.zyundb_mysql_instance.all
}


resource "zyundb_mysql_instance" "example" {
  name          = "example"
  project_id    = "你的资源组ID"
  pkg_id        = "套餐ID"
  instance_type = "NORMAL"
  mode          = "master-slave"
  master_idc    = "xxidc"
  redundant_idc = ""
  network_id    = "xxx"
  subnet_id     = "xxx"
  is_audit_log  = true


  timeouts = {
    create = "60m"
  }
}

然后,执行terraform init吗?不,如果是使用本地的terraform provider,请不要执行这一步,这是为什么呢?

因为terraform init 是用来初始化 Terraform 配置的,它通常会下载所需的远程提供者并初始化状态。可是,当你使用本地开发的提供者时,terraform init 并不会像往常那样从远程注册表下载提供者,因为本地提供者已经通过 dev_overrides 配置指定。因此,Terraform 不需要再运行 terraform init 来获取远程提供者

如果你依然执行了 terraform init,它可能会尝试下载远程提供者,并且在你本地提供者存在的情况下,可能会引发一些错误或冲突。

比如:

0028b858160a7c214b8cc64e36b1226f.png

所以,执行terraform plan吧

e79943591553e6c9c2f7a298c7a48530.pngf3d2705966aaf2b1fe1865e19acfa5cf.png

这个时候它就会列出会发生的变更。如果没有发现问题,那么执行terraform apply应用就可以了。

可以看到在apply的过程中我们的轮询日志也是会打印出来的:

604fc19ac8f5865f64820f7fa304e4d0.png

当然,plan或者apply都有可能会失败,这个时候可以在命令前增加TF_LOG=debug/trace等就可以查看详细的报错信息了。

4

fe4048d2e44d7d5ce7a78d59900b2581.gif

生成文档

如果是一个需要发布的Provider,文档还是很有必要的,而Terraform为我们提供了很方便的方式去生成文档。还记得我们代码中加的那些Description吗?那些就是生成文档的必须。

在已经添加了Description的前提下,我们在项目下再新建一个目录examples,在下面写下各种示例的tf文件,然后新建一个tools目录,新建tools.go,如下:

 
 
//go:build generate


package tools


import (
  _ "github.com/hashicorp/copywrite"
  _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs"
)


// Generate copyright headers
//go:generate go run github.com/hashicorp/copywrite headers -d .. --config ../.copywrite.hcl


// Format Terraform code for use in documentation.
// If you do not have Terraform installed, you can remove the formatting command, but it is suggested
// to ensure the documentation is formatted properly.
//go:generate terraform fmt -recursive ../examples/


// Generate documentation.
//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-dir .. -provider-name zyundb

注意provider-name要是对应上的。

然后cd tools; go generate ./…

可以看到输出如下

7da76b0f08daa37b59b28aded3063d41.png

然后,文档就生成了。

最后,看下整体的目录结构吧:

 
 
.
├── README.md
├── docs //这部分目录下都是自动生成的文档
│   ├── data-sources
│   │   └── mysql_instance.md
│   ├── index.md
│   └── resources
│       └── mysql_instance.md
├── examples //这个目录下是我们写的示例tf
│   ├── README.md
│   ├── data-sources
│   │   └── zyundb
│   │       └── data-source.tf
│   ├── main.tf
│   ├── provider
│   │   └── provider.tf
│   ├── resources
│   │   └── zyundb
│   │       ├── import.sh
│   │       └── resource.tf
│   └── terraform.tfstate //执行terraform apply后生成的状态文件
├── go.mod
├── go.sum
├── internal
│   ├── client //我们自己的处理API请求的代码,可以用SDK代替
│   │   ├── client.go
│   │   └── mysql.go
│   └── provider //实现Provider的部分
│       ├── mysql_instance_data_source.go
│       ├── mysql_instance_data_source_test.go
│       ├── mysql_instance_resource.go
│       ├── mysql_instance_resource_test.go
│       ├── provider.go
│       └── provider_test.go
├── main.go
└── tools
    ├── go.mod
    ├── go.sum
    └── tools.go

推荐阅读:

如何使用whisper+ollama+ffmpeg为视频添加中文字幕

360容器云独享集群新功能介绍

更多技术和产品文章,请关注👆

如果您对哪个产品感兴趣,欢迎留言给我们,我们会定向邀文~

360智汇云是以"汇聚数据价值,助力智能未来"为目标的企业应用开放服务平台,融合360丰富的产品、技术力量,为客户提供平台服务。
目前,智汇云提供数据库、中间件、存储、大数据、人工智能、计算、网络、视联物联与通信等多种产品服务以及一站式解决方案,助力客户降本增效,累计服务业务1000+。
智汇云致力于为各行各业的业务及应用提供强有力的产品、技术服务,帮助企业和业务实现更大的商业价值。
官网:https://zyun.360.cn 或搜索“360智汇云”
客服电话:4000052360
欢迎使用我们的产品!😊

http://www.kler.cn/a/447228.html

相关文章:

  • 线性代数期末总复习的点点滴滴(1)
  • 为何VisualRules更适合技术人员使用
  • WeakAuras NES Script(lua)
  • 【Java基础面试题024】Java中包装类型和基本类型的区别是什么?
  • Vue与React:前端框架的巅峰对决
  • 第二十四天 循环神经网络(RNN)基本原理与实现
  • 探索计算机存储层次体系:从寄存器到磁带
  • 6_HTML5 SVG (2) --[HTML5 API 学习之旅]
  • 周末紫蓬山行车见闻
  • UE5 移植Editor或Developer模块到Runtime
  • 强光防卫电筒TX-8610
  • Matlab个性化绘图第5期—三维分层柱状图
  • 播放端标注叠加方案
  • 高并发服务器实现总结目录
  • VUE3+django接口自动化部署平台部署说明文档(使用说明,需要私信)
  • Cadence学习笔记 9 器件封装匹配
  • OpenHarmony 3.2 网卡获取ip地址缓慢分析
  • [创业之路-202]:任正非管理华为的思想与毛泽东管理党、军队、国家的思想的相似性与差异性
  • 高效SQL优化技巧:解决常见性能问题的实战方案
  • Spring 6 实现 Bean 异步初始化,减少项目启动时间
  • JAVA包装类变量赋值是会新创建对象实例
  • 【LeetCode】227、基本计算器 II
  • C#中方法参数传值和传引用的情况
  • AI大模型与传统模型的六大区别
  • feign调用远程服务返回值的一种处理办法
  • PostgreSQL中使用PL/pgSQL开发函数