简介

Upjet 是一个代码生成框架,支持开发者构建用于生成 Crossplane 控制器的代码生成管道。Upiet主要由三部分组成:

  1. 构建代码生成器管道的框架。
  2. 所有自动生成 CRD 使用的通用 Reconciler 实现
  3. 为所有自动生成 CRD 提取文档的工具。

相关名词:

  • MR: Managed Resource, Crossplane 管理的托管资源
  • CR: Custom Resource,自定义资源,Kubernetes API的扩展
  • CRD: Custom Resource Definition: 自定义资源定义。

资源配置

Upjet 使用 Terraform Resource Schema 中的信息尽可能多的生成 Provider 中的相关信息,包括符合XRM 模式的 Resource SchemaController 逻辑、延迟加载、敏感数据处理等。然后仍然有一些信息,需要通过查阅 Terraform 文档后手动输入配置:

  • 外部名称 | External Name
  • 跨资源引用 | Cross Resource Referencing
  • 敏感字段及自定义连接详情 | AdditionalSensitive Fields and Custom Connection Details
  • 延迟初始化行为 | Late lnitialization Behavior
  • 覆盖 | Terraform Resource Schema
  • 初始化器 | Initializers

外部名称 External Name

简介

Crossplane 使⽤ Managed Resource 中的 Annotation 来识别 Crossplane 管理的外部资源, Annotation key 为crossplane.io/external-name,值为 External Name,即所管理资源的名称。External name 的内容和格式由 Cloud Provider 决定,如 TOS bucket 使⽤资源名称 bucketName,⽀持⽤⼾⾃⾏设置;VPC 使⽤⾃动⽣成的 ID:VpcId,由云⼚商⽣成,不⽀持⾃定义。该内容是因具体资源⽽异的,因此需要在 upjet 中配置每个资源的 External Name 规则,以便正常⽣成 Provider。

为什么需要配置 External Name

  1. 一个 Managed Resource,本质上还是一个云资源,Crossplane 除了在内部要区分该资源,还需要知道该资源与外部资源的引用,因此需要 Name 和 External Name 的区分。Name 是集群内的资源名称,作为 K8s 资源的 metadata 中的 name 属性存储,跨资源引用时使用metadata.name; ExternalName 做为该资源的外部名称,在云厂商唯一标识一个资源,存储在 K8s 资源的crossplane.io/external-name annotation中,Upjet 在将 spec 转 tfstate 时,需要通过 externalname 解析出 tf 的标识符,并且设置到 tfstate,最终通过 tf 执行资源管理的操作。该配置就是为了支持 CRD spec 和tf配置、state 标识符字段互相转换,即 externalname至 tf 标识符的相互转换规则
  2. 当资源标识符为自动生成ID 时 (如 vpc-123) ,ExternalName和ID 等价;当标识符为 Name 时(如 bucketName) ,即使tf 中的ID和 Name 相同,但在 Crossplane 中,如果使用ldentifierFromProvider,能工作,但 CRD 会存在额外的 Name 字段。

配置规则

External Name 的规则配置,可以简单参考 Terraform 文档中 Import 小节,如下 Imported using the id,external name 配置 upjet 内置的 IdentifierFromProvider,如果是 Imported using the name,则配置 upjet 内置的 NameAsIdentifier,如果是其他字段,则使用 ParameterAsIdentifier("xxx")

Terraform import resource

ExternalName 配置在 upjet 中是一个结构体,定义如下:

// ExternalName contains all information that is necessary for naming operations,
// such as removal of those fields from spec schema and calling Configure function
// to fill attributes with information given in external name.
type ExternalName struct {
	// SetIdentifierArgumentFn sets the name of the resource in Terraform argument
	// map. In many cases, there is a field called "name" in the HCL schema, however,
	// there are cases like RDS DB Cluster where the name field in HCL is called
	// "cluster_identifier". This function is the place that you can take external
	// name and assign it to that specific key for that resource type.
	SetIdentifierArgumentFn SetIdentifierArgumentsFn

	// GetExternalNameFn returns the external name extracted from TF State. In most cases,
	// "id" field contains all the information you need. You'll need to extract
	// the format that is decided for external name annotation to use.
	// For example the following is an Azure resource ID:
	// /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/mygroup1
	// The function should return "mygroup1" so that it can be used to set external
	// name if it was not set already.
	GetExternalNameFn GetExternalNameFn

	// GetIDFn returns the string that will be used as "id" key in TF state. In
	// many cases, external name format is the same as "id" but when it is not
	// we may need information from other places to construct it. For example,
	// the following is an Azure resource ID:
	// /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/mygroup1
	// The function here should use information from supplied arguments to
	// construct this ID, i.e. "mygroup1" from external name, subscription ID
	// from terraformProviderConfig, and others from parameters map if needed.
	GetIDFn GetIDFn

	// OmittedFields are the ones you'd like to be removed from the schema since
	// they are specified via external name. For example, if you set
	// "cluster_identifier" in SetIdentifierArgumentFn, then you need to omit
	// that field.
	// You can omit only the top level fields.
	// No field is omitted by default.
	OmittedFields []string

	// DisableNameInitializer allows you to specify whether the name initializer
	// that sets external name to metadata.name if none specified should be disabled.
	// It needs to be disabled for resources whose external identifier is randomly
	// assigned by the provider, like AWS VPC where it gets vpc-21kn123 identifier
	// and not let you name it.
	DisableNameInitializer bool

	// IdentifierFields are the fields that are used to construct external
	// resource identifier. We need to know these fields no matter what the
	// management policy is including the Observe Only, different from other
	// (required) fields.
	IdentifierFields []string
}
  • ExternalName
    • SetIdentifierArgumentFn: 根据 external name,计算 tfstate id,并且设置到 tf 对应参数中。如 NameAsIdentifier 中的 OmitteFields 字段会忽略 CRD 中的 name 字段,在 spec 转 tf 时就没有 name 字段,需要通过该函数把 name 字段加进去。
    • GetExternalNameFn:tf 转 CRD spec 时,从 tfstate 获取 external name。
    • GetIDFn:根据 tfstate 与 external name,计算 tfstate 中的 id,用于在执行 tf 操作前写入 state 至 terraform.tfstate。
    • OmitteFields:忽略字段,不会加入到 CRD 定义里。
    • DisableNameInitializer:是否使用资源的 name 初始化 external name(即 Managed Resource 的crossplane.io/external-name注释),对于 IdentifierFromProvider,由于 ID 是云服务自动生成的,不能在初始化时将 Name 设置到crossplane.io/external-name中,因此该字段需要设置为 true;对于使用 Name 或其他字段作为标识符的资源,默认设置 false,Controller Reconcile 逻辑会在初始化时将 k8s 中创建的 Managed Resource 的 Name 字段值设置到上述注释中。
    • IdentifierFields:仅TemplatedStringAsIdentifier会用到,会计算模板中通过{{ .parameters.Xxx }}设置的字段列表,这些字段用于组成 external name,upjet 会在生成 kubebuilder 字段 Required 校验注释时,去掉这些字段。这个是 upjet 内部的逻辑,接入时不需要太过关注,以下是一条校验规则示例:
// +kubebuilder:validation:XValidation:rule="self.managementPolicy == 'ObserveOnly' || has(self.forProvider.bucketName)",message="bucketName is a required parameter"

External name config

Upjet 内置的 ExternalName 配置:

var (
	// NameAsIdentifier uses "name" field in the arguments as the identifier of
	// the resource.
	NameAsIdentifier = ExternalName{
		SetIdentifierArgumentFn: func(base map[string]any, name string) {
			base["name"] = name
		},
		GetExternalNameFn: IDAsExternalName,
		GetIDFn:           ExternalNameAsID,
		OmittedFields: []string{
			"name",
			"name_prefix",
		},
	}

	// IdentifierFromProvider is used in resources whose identifier is assigned by
	// the remote client, such as AWS VPC where it gets an identifier like
	// vpc-2213das instead of letting user choose a name.
	IdentifierFromProvider = ExternalName{
		SetIdentifierArgumentFn: NopSetIdentifierArgument,
		GetExternalNameFn:       IDAsExternalName,
		GetIDFn:                 ExternalNameAsID,
		DisableNameInitializer:  true,
	}

	parameterPattern = regexp.MustCompile(`{{\s*\.parameters\.([^\s}]+)\s*}}`)
)

// ParameterAsIdentifier uses the given field name in the arguments as the
// identifier of the resource.
func ParameterAsIdentifier(param string) ExternalName {
	e := NameAsIdentifier
	e.SetIdentifierArgumentFn = func(base map[string]any, name string) {
		base[param] = name
	}
	e.OmittedFields = []string{
		param,
		param + "_prefix",
	}
	e.IdentifierFields = []string{param}
	return e
}

// TemplatedStringAsIdentifier accepts a template as the shape of the Terraform
// ID and lets you provide a field path for the argument you're using as external
// name. The available variables you can use in the template are as follows:
// parameters: A tree of parameters that you'd normally see in a Terraform HCL
//
//	file. You can use TF registry documentation of given resource to
//	see what's available.
//
// setup.configuration: The Terraform configuration object of the provider. You can
//
//	take a look at the TF registry provider configuration object
//	to see what's available. Not to be confused with ProviderConfig
//	custom resource of the Crossplane provider.
//
// setup.client_metadata: The Terraform client metadata available for the provider,
//
//	such as the AWS account ID for the AWS provider.
//
// external_name: The value of external name annotation of the custom resource.
//
//	It is required to use this as part of the template.
//
// The following template functions are available:
// ToLower: Converts the contents of the pipeline to lower-case
// ToUpper: Converts the contents of the pipeline to upper-case
// Please note that it's currently *not* possible to use
// the template functions on the .external_name template variable.
// Example usages:
// TemplatedStringAsIdentifier("index_name", "/subscriptions/{{ .setup.configuration.subscription }}/{{ .external_name }}")
// TemplatedStringAsIdentifier("index_name", "/resource/{{ .external_name }}/static")
// TemplatedStringAsIdentifier("index_name", "{{ .parameters.cluster_id }}:{{ .parameters.node_id }}:{{ .external_name }}")
// TemplatedStringAsIdentifier("", "arn:aws:network-firewall:{{ .setup.configuration.region }}:{{ .setup.client_metadata.account_id }}:{{ .parameters.type | ToLower }}-rulegroup/{{ .external_name }}")
func TemplatedStringAsIdentifier(nameFieldPath, tmpl string) ExternalName {
	t, err := template.New("getid").Funcs(template.FuncMap{
		"ToLower": strings.ToLower,
		"ToUpper": strings.ToUpper,
	}).Parse(tmpl)
	if err != nil {
		panic(errors.Wrap(err, "cannot parse template"))
	}

	// Note(turkenh): If a parameter is used in the external name template,
	// it is an identifier field.
	var identifierFields []string
	for _, node := range t.Root.Nodes {
		if node.Type() == parse.NodeAction {
			match := parameterPattern.FindStringSubmatch(node.String())
			if len(match) == 2 {
				identifierFields = append(identifierFields, match[1])
			}
		}
	}
	return ExternalName{
		SetIdentifierArgumentFn: func(base map[string]any, externalName string) {
			if nameFieldPath == "" {
				return
			}
			// TODO(muvaf): Return error in this function? Not returning error
			// is a valid option since the schemas are static so we'd get the
			// panic right when you create a resource. It's not generation-time
			// error though.
			if err := fieldpath.Pave(base).SetString(nameFieldPath, externalName); err != nil {
				panic(errors.Wrapf(err, "cannot set %s to fieldpath %s", externalName, nameFieldPath))
			}
		},
		OmittedFields: []string{
			nameFieldPath,
			nameFieldPath + "_prefix",
		},
		GetIDFn: func(ctx context.Context, externalName string, parameters map[string]any, setup map[string]any) (string, error) {
			o := map[string]any{
				"external_name": externalName,
				"parameters":    parameters,
				"setup":         setup,
			}
			b := bytes.Buffer{}
			if err := t.Execute(&b, o); err != nil {
				return "", errors.Wrap(err, "cannot execute template")
			}
			return b.String(), nil
		},
		GetExternalNameFn: func(tfstate map[string]any) (string, error) {
			id, ok := tfstate["id"]
			if !ok {
				return "", errors.New(errIDNotFoundInTFState)
			}
			return GetExternalNameFromTemplated(tmpl, id.(string))
		},
		IdentifierFields: identifierFields,
	}
}
  • NameAsIdentifier:使用 name 字段生成 external name,并且 CRD spec 中移除 name 字段。
  • IdentifierFromProvider:使用 id 生成 external name。
  • ParameterAsIdentifier:指定字段生成 external name,NameAsIdentifier == ParameterAsIdentifier(“name”)
  • TemplatedStringAsIdentifier:自定义模板,当 tf id 和 external name 不一致时自定义转换规则,external name 从 tf id 解析;tf id 从 external name 以及其他字段计算得出。

跨资源引用|References

资源依赖

在许多场景下,一个 MR(Managed Resource) 可能会依赖其他资源,比如一台 ECS 实例需要指定一个 VPC。VPC 资源中存在 VpcId 字段,但在 ECS 资源编排定义中,大多数情况下是通过 VPC 的 Name 来引用一个具体的 VPC,而不是 VpcId 字段值。在 Crossplane 中,有 3 个字段用来引用另一个资源,以 subnet 为例:

spec:
  forProvider:
    vpcId: vpc-xxx
    vpcIdRef:
      name: demoVpc
    vpcIdSelector:
      matchLabels:
        testing.upbound.io/example-name: example

支持指定 3 种字段种的一个:

  • vpcId:实际资源值
  • vpcIdRef:资源 Name 引用
  • vpcIdSelector:标签选择器

需要注意的是,如果存在引用,托管资源 MR 在引用对象准备就绪之前不会创建外部资源。在本例中,在引用的 VPC status.condition值为 Ready 之前,不会创建 Subnet 资源。

配置规则

在 Upjet 中,存在一个 Reference 字段用于配置资源依赖:

// Reference represents the Crossplane options used to generate
// reference resolvers for fields
type Reference struct {
	// Type is the type name of the CRD if it is in the same package or
	// <package-path>.<type-name> if it is in a different package.
	Type string
	// Extractor is the function to be used to extract value from the
	// referenced type. Defaults to getting external name.
	// Optional
	Extractor string
	// RefFieldName is the field name for the Reference field. Defaults to
	// <field-name>Ref or <field-name>Refs.
	// Optional
	RefFieldName string
	// SelectorFieldName is the field name for the Selector field. Defaults to
	// <field-name>Selector.
	// Optional
	SelectorFieldName string
}
  • Type:依赖资源在同一个包,则配置 CR 名,否则配置 <package-path>.<type-name>
  • TerraformName:依赖资源的 Terraform resource 名,如 subnet 依赖 volcengine_vpc,当 Type 为空,该字段不为空时,upjet 会查到该资源在 upjet 中的定义,然后生成 Type 配置。
  • Extractor:从引用里获取值的方法配置,默认获取 tf 资源 id,可以配置获取资源的其他字段值。
  • RefFieldName:Ref 名,默认<field-name>Ref,如 VpcIdRef。
  • SelectorFieldName:Selector 名,默认<field-name>Selector,如 VpcIdSelector。

对于我们想要生成的资源,我们需要在 Terraform 文档中检查其参数列表,并找出哪个字段需要引用哪个资源。

p.AddResourceConfigurator("volcengine_subnet", func(r *config.Resource) {
    // We need to override the default group that upjet generated for
    // upjet根据volcengine_subnet获取group名,最终生成在CRD spec.group 字段
    // vpc.volcengine.upbound.io
    // volcengine_subnet 会生成 volcengine group,这里需要手动设置group为vpc
    // aws 有一个统一的GroupMap,会在生成代码的Provider初始化时设置,最后覆盖所有Resource
    // https://github.com/upbound/provider-aws/blob/7e57ea50d7c5ead808967aebbb02079fc3532b1f/config/groups.go#L56
    r.ShortGroup = "vpc"
    r.References["vpc_id"] = config.Reference{
        //三种配置均可
        //Type: "VPC"
        //Type: "github.com/volcengine/provider-volcengine/apis/vpc/v1alpha1.VPC",
        TerraformName: "volcengine_vpc",
    }
    // 当资源创建、删除超过1min时,需要设置为true,比如Kubernetes集群或数据库。
    // true时,tf操作会启协程操作,防止资源Controller阻塞
    r.UseAsync = false
})

敏感字段及自定义连接详情

Connection Details

Crossplane 托管资源 MR 中的一个概念:MR 需要的例如 URLs、username、endpoint、password 这样的用于外部连接的信息,通常会写入到 Kubernetes Secret 中。可以指定 MR 的 spec.writeConnectionSecretToRef,将数据写入到关联的 Secret 中,所有资源都有该字段,但大多数资源没有 secret 信息的都不会用到。

Upjet 配置

Upjet 会识别 Terraform 中的 Sensitive 字段,并且生成一个 SecretRef,资源的值存储在 K8s Secret 中,通过指定 Secret 所在 Namespace,以及 Secret Name 和 password Key,来获取最终的值。

例如 volcengine_cr_registry 资源,password字段标记了Sensitive,在 CRD 中会生成 passwordSecretRef 字段,通过以下方式引用 Secret:

apiVersion: cr.volcengine.upbound.io/v1alpha1
  kind: Registry
metadata:
  annotations:
    meta.upbound.io/example-id: cr/v1alpha1/registry
  labels:
    testing.upbound.io/example-name: foo
  name: foo
spec:
  forProvider:
    deleteImmediately: false
    passwordSecretRef:
      key: example-key
      name: example-secret
      namespace: upbound-system

---

还有一些情况,想根据 tf 中的一些字段,生成新的字段并且加入到 Secret 中,例如 aws_iam_access_key,存在字段 id、secret,在 Secret 中以attribute.idattribute.secret存在,如果想在 Secret 中新增aws_access_key_idaws_secret_access_key两个字段,可以通过 AddtionalConnectionDetailsFn配置:

func Configure(p *config.Provider) {
    p.AddResourceConfigurator("aws_iam_access_key", func(r *config.Resource) {
        r.Sensitive.AdditionalConnectionDetailsFn = func(attr map[string]any) (map[string][]byte, error) {
            conn := map[string][]byte{}
            if a, ok := attr["id"].(string); ok {
                conn["aws_access_key_id"] = []byte(a)
            }
            if a, ok := attr["secret"].(string); ok {
                conn["aws_secret_access_key"] = []byte(a)
            }
            return conn, nil
        }
    })
}

Secret 中会加入这两个字段:

apiVersion: v1
data:
  attribute.id: QUtJQVk0QUZUVFNFNDI2TlhKS0I=
  attribute.secret: ABCxyzRedacted==
  attribute.ses_smtp_password_v4: QQ00REDACTED==
  aws_access_key_id: QUtJQVk0QUZUVFNFNDI2TlhKS0I=
  aws_secret_access_key: ABCxyzRedacted==
kind: Secret

延迟初始化行为

LateInitialize 将 tf Refresh 获取的 tf 对象中的非空字段 copy 到 CR 中的空字段(即 tf 中的 Optional + Computed 字段),如果 tf 中存在冲突字段,并且 Refresh 后冲突字段会填充一个值,这里 LateInititalize 时会将该值转换填入 CR spec 中,后续再执行 tf 操作时可能会有字段冲突发生。

仅当 Terraform 资源配置中存在参数冲突的场景下需要该配置。大多数时候需要测试时才能知道资源字段是否冲突,因此可以跳过该配置,仅当冲突时再补充。

observe failed: cannot run refresh: refresh failed: Invalid combination of arguments:
  "address_prefix": only one of `address_prefix,address_prefixes` can be specified, but `address_prefix,address_prefixes` were specified.: File name: main.tf.json

如果希望延迟初始化不处理 address_prefix 字段,可以通过LateInitializer配置:

func Configure(p *config.Provider) {
    p.AddResourceConfigurator("azurerm_subnet", func(r *config.Resource) {
        r.LateInitializer = config.LateInitializer{
            IgnoredFields: []string{"address_prefix"},
        }
    })
}

配置 IgnoredFields 后,在执行 tf Refresh 后,即使 tf 字段不为空,但 spec 字段为空,也不会将该字段从 tf 转换到 spec 中。

覆盖 Terraform Resource Schema

Upjet 使用 Terraform 资源 schema 生成 Crossplane 资源 schema(CR spec/status),截止目前 Upjet 利用了以下 schema:

  • 使用TypeElem识别字段类型
  • 使用字段Sensitive标识确认是否在 CRD 中引入 Secret 引用
  • 使用Description添加到 CRD 中的字段描述
  • 使用OptionalComputed确认字段属于 spec 还是 status:
    • Not Optional & Not Computed => Spec(required)
    • Optional & Not Computed => Spec(optional)
    • Optional & Computed => Spec(optional, to be late-initalized)
    • Not Optional & Computed => Status

一般情况下不需要做任何修改,除了一些罕见的情况:

  • TF 字段标记为Sensitive,但实际并不是,反之亦然。
  • 在 CRD schema 中没意义的字段,通常转换为 CR status。如 aws 资源的 tags_all 字段
  • 将 Terraform Provider Config 中的字段移动至 Crossplane 资源 schema,如 AWS region 字段,属于 Terraform Provider Config,但 Crossplane 想要将该字段加到 CRD spec 中。

可以通过以下方式覆盖或新增 schema:

p.AddResourceConfigurator("aws_autoscaling_group", func(r *config.Resource) {
    // Managed by Attachment resource.
    if s, ok := r.TerraformResource.Schema["load_balancers"]; ok {
        s.Optional = false
        s.Computed = true
    }
    if s, ok := r.TerraformResource.Schema["target_group_arns"]; ok {
        s.Optional = false
        s.Computed = true
    }
    r.TerraformResource.Schema["region"] = &schema.Schema{
        Type:        schema.TypeString,
        Required:    true,
        Description: comment.String(),
    }
})

初始化器

用于为资源提供初始化器,在生成 Controller 时,会读取资源的InitializerFns字段,配置到 Controller 中,在 Controller 的 Reconcile 中会顺序调用 Initializer 链,传入 CR,更新 CR 相关字段,完成初始化操作。

// 初始化 Controller
// ...
{{- if .Initializers }}
for _, i := range o.Provider.Resources["{{ .ResourceType }}"].InitializerFns {
    initializers = append(initializers,i(mgr.GetClient()))
}
{{- end}}

opts := []managed.ReconcilerOption{
    // ...
    managed.WithInitializers(initializers),
    // ...
}
// ...
r := managed.NewReconciler(mgr, xpresource.ManagedKind(v1alpha1.Subnet_GroupVersionKind), opts...)
// ...

AWS 许多资源都有一个 tags 字段,Crossplane 也有标签设置,但不是必要的,指定一个字段,在 spec.forProvider.<field-name>下设置如下标签,方便做 Provider 级别的过滤、搜索,或者通过 kubectl 获取相关资源。

A tag set for a VPC in AWS:
  "crossplane-kind": "vpc.network.aws.crossplane.io"
  "crossplane-name": "myappnamespace-mynetwork-5sc8a"
  "crossplane-provider": "aws-provider"

upjet 内置了TagInitializer,读取 CR 的 Kind、Name、Provider 字段,最后设置到 spec.forProvider 下指定的字段。

// TagInitializer returns a tagger to use default tag initializer.
var TagInitializer NewInitializerFn = func(client client.Client) managed.Initializer {
	return NewTagger(client, "tags")
}

// Tagger implements the Initialize function to set external tags
type Tagger struct {
	kube      client.Client
	fieldName string
}

// NewTagger returns a Tagger object.
func NewTagger(kube client.Client, fieldName string) *Tagger {
	return &Tagger{kube: kube, fieldName: fieldName}
}

// Initialize is a custom initializer for setting external tags
func (t *Tagger) Initialize(ctx context.Context, mg xpresource.Managed) error {
	if mg.GetManagementPolicy() == xpv1.ManagementObserveOnly {
		// We don't want to add tags to the spec.forProvider if the resource is
		// in ObserveOnly mode.
		return nil
	}
	paved, err := fieldpath.PaveObject(mg)
	if err != nil {
		return err
	}
	pavedByte, err := setExternalTagsWithPaved(xpresource.GetExternalTags(mg), paved, t.fieldName)
	if err != nil {
		return err
	}
	if err := json.Unmarshal(pavedByte, mg); err != nil {
		return err
	}
	if err := t.kube.Update(ctx, mg); err != nil {
		return err
	}
	return nil
}

相关文档