client-goでCustom ResourceのオブジェクトをGETする

::: note info
これは [ZOZO Advent Calendar 2023](https://qiita.com/advent-calendar/2023/zozo) カレンダー Vol.5 の 2日目の記事です。
:::

本記事ではclient-goを利用してKubernetesのCustom ResourceのオブジェクトをGETする方法について紹介します。

[client-go](https://github.com/kubernetes/client-go)でDeploymentやPodといったKubernetesのデフォルトリソースを取得する場合、次のようにclientsetの初期化を行うと思います。

```main.go
package main

import (
	"context"
	"flag"
	"fmt"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/tools/clientcmd"
)

var kubeconfig string

func main() {
	flag.StringVar(&kubeconfig, "kubeconfig", "", "kubeconfig path")
	flag.Parse()
    // kubeconfig fileの読み込み
	config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)

	// clientsetの初期化
	clientset, _ := kubernetes.NewForConfig(config)
 }
```
このように初期化されたclientsetは次のインターフェースを満たしており、各KubernetesリソースごとにClientを返すメソッドを持っています。
```client-go/kubernetes/clientset.go
type Interface interface {
	Discovery() discovery.DiscoveryInterface
 ...省略
	AppsV1() appsv1.AppsV1Interface
	AppsV1beta1() appsv1beta1.AppsV1beta1Interface
	AppsV1beta2() appsv1beta2.AppsV1beta2Interface
...省略
	CoreV1() corev1.CoreV1Interface
...省略
}
```
そのため次のように対応するオブジェクトの取得が可能です。
```main.go
	// Podオブジェクトの取得
	pod, _ := clientset.CoreV1().Pods("hoge").Get(context.TODO(), "fuga", metav1.GetOptions{})
	fmt.Println(pod.ObjectMeta.Name)
```

では、Custom Resourceのオブジェクトを取得するにはどのように実装できるのかというのが今回のテーマになります。

KubernetesではPodやDeloymentといったデフォルトリソース意外にたくさんのCustom Resourceが存在しています。一方で、これらを取得するための独自Clientは上記のコードで利用したclientsetには含まれていません。

client-goでCustom Resourceのオブジェクトを取得する場合、dynamic Clientを利用することができます。  
dynamic Clientを利用すると、上記のclientsetでサポートしていないKubernetesリソースについても扱うことができます。

実験として、Sample Custom Resouceをデプロイし、dynamic Clientを使って取得してみます。

次の2つのKubernetesマニフェストをapplyしてCustom Resouceの作成を行なってください。

```sample-crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: samples.stable.example.com
spec:
  group: stable.example.com
  versions:
  - name: v1alpha
    # REST APIを介して提供されるのかのフラグ
    served: true
    storage: true
  scope: Namespaced
  names:
    kind: Sample
    plural: samples
    singular: sample
    shortNames: ["sp"]
  versions:
  - name: v1alpha
    served: true
    storage: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              image:
                type: string
              message:
                type: string
              replicas:
                type: integer
            required:
            - message
    additionalPrinterColumns:
    - name: message
      type: string
      description: message content which want to show
      jsonPath: .spec.message
    - name: AGE
      type: date
      jsonPath: .metadata.creationTimestamp
    subresources:
      scale:
        specReplicasPath: .spec.replicas
        statusReplicasPath: .status.replicas
        labelSelectorPath: .status.labelSelector
```

```example-cr.yaml
apiVersion: "stable.example.com/v1alpha"
kind: Sample
metadata:
  name: my-cr-sample-object
spec:
  image: my-cr-image
  message: "Hello Custom Resource"
  replicas: 1

```

kubectlコマンドを叩いてCustom Resouce DefinitionとCustom ResouceであるSampleリソースのオブジェクトが取得できることを確認してください。

```bash
k get crd                                                                               

NAME                         CREATED AT
samples.stable.example.com   2023-11-30T13:40:56Z

k get sample                                                                                                                   
NAME                  MESSAGE                 AGE
my-cr-sample-object   Hello Custom Resource   3s
```

次のコードによりdynamic Clientを利用して上記のSampleリソースのオブジェクトを取得することができます。

```main.go
package main

import (
	"context"
	"flag"
	"fmt"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/client-go/tools/clientcmd"
	"k8s.io/client-go/dynamic"
)

var kubeconfig string

type Sample struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec SampleSpec `json:"spec"`
}

type SampleSpec struct {
	Image string `json:"image"`
	Message string `json:"message"`
	Replicas int `json:"replicas"`
}

func main() {
	flag.StringVar(&kubeconfig, "kubeconfig", "", "kubeconfig path")
	flag.Parse()
	config, _ := clientcmd.BuildConfigFromFlags("", kubeconfig)

	client, _ := dynamic.NewForConfig(config)
	gvr := schema.GroupVersionResource{
		Group: "stable.example.com",
		Version: "v1alpha",
		Resource: "samples",
	}
	unstructuredObj, _ := client.Resource(gvr).Namespace("default").Get(context.TODO(), "my-cr-sample-object", metav1.GetOptions{})
	sample := &Sample{}
    _ = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, sample)
	fmt.Println(sample.ObjectMeta.Name)
}
```

Podリソースのオブジェクトを取得した際との大きな差分は主に2つあります。

1つはclientからチェーンされる1つ目のメソッドが異なる点です。

```go
// clientsetの場合
pod, _ := clientset.CoreV1().Pods("hoge").Get(context.TODO(), "fuga", metav1.GetOptions{})

// dynamic Clientの場合
obj, _ := client.Resource(gvr).Namespace("default").Get(context.TODO(), "my-cr-sample-object", metav1.GetOptions{})
```

Podの場合は元々client-go側でPod Kindに対応する`Group-Version-Resouce`を知っていますが、Sample Kindの場合は情報を持っていません。そのため、Resourceメソッドに直接`Group-Version-Resouce`を渡しています。

2つ目は最終的に呼ばれているGetメソッドの戻り値の型が異なる点です。
podsオブジェクトに実装されているGetメソッドは次のようになっており、Intoメソッドに`v1.Pod`型のポインタを渡して取得したレスポンスを`v1.Pod`型にデコードしています。

```client-go/kubernetes/typed/core/v1/pod.go
// Get takes name of the pod, and returns the corresponding pod object, and an error if there is any.
func (c *pods) Get(ctx context.Context, name string, options metav1.GetOptions) (result *v1.Pod, err error) {
	result = &v1.Pod{}
	err = c.client.Get().
		Namespace(c.ns).
		Resource("pods").
		Name(name).
		VersionedParams(&options, scheme.ParameterCodec).
		Do(ctx).
		Into(result)
	return
}
```

```client-go/rest/request.go
func (r Result) Into(obj runtime.Object) error {
	if r.err != nil {
		// Check whether the result has a Status object in the body and prefer that.
		return r.Error()
	}
	if r.decoder == nil {
		return fmt.Errorf("serializer for %s doesn't exist", r.contentType)
	}
	if len(r.body) == 0 {
		return fmt.Errorf("0-length response with status code: %d and content type: %s",
			r.statusCode, r.contentType)
	}
    
	out, _, err := r.decoder.Decode(r.body, nil, obj)
	if err != nil || out == obj {
		return err
	}
	// if a different object is returned, see if it is Status and avoid double decoding
	// the object.
	switch t := out.(type) {
	case *metav1.Status:
		// any status besides StatusSuccess is considered an error.
		if t.Status != metav1.StatusSuccess {
			return errors.FromObject(t)
		}
	}
	return nil
}
```
一方でdynamicResourceClientオブジェクトに実装されているGetメソッドは次のようになっています。  
取得されたバイト列は`runtime.Decode`関数に渡されていますが、この関数の内部では`unstructuredJSONScheme`の`decode`メソッドによりバイト列が`Unstructured`型に変換されています。

```client-go/dynamic/simple.go
func (c *dynamicResourceClient) Get(ctx context.Context, name string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
	if len(name) == 0 {
		return nil, fmt.Errorf("name is required")
	}
	if err := validateNamespaceWithOptionalName(c.namespace, name); err != nil {
		return nil, err
	}
	result := c.client.client.Get().AbsPath(append(c.makeURLSegments(name), subresources...)...).SpecificallyVersionedParams(&opts, dynamicParameterCodec, versionV1).Do(ctx)
	if err := result.Error(); err != nil {
		return nil, err
	}
	retBytes, err := result.Raw()
	if err != nil {
		return nil, err
	}
	uncastObj, err := runtime.Decode(unstructured.UnstructuredJSONScheme, retBytes)
	if err != nil {
		return nil, err
	}
	return uncastObj.(*unstructured.Unstructured), nil
}
```
```apimachinery/pkg/apis/meta/v1/unstructured/helpers.go
func (s unstructuredJSONScheme) decode(data []byte) (runtime.Object, error) {
	type detector struct {
		Items gojson.RawMessage `json:"items"`
	}
	var det detector
	if err := json.Unmarshal(data, &det); err != nil {
		return nil, err
	}

	if det.Items != nil {
		list := &UnstructuredList{}
		err := s.decodeToList(data, list)
		return list, err
	}

	// No Items field, so it wasn't a list.
	unstruct := &Unstructured{}
	err := s.decodeToUnstructured(data, unstruct)
	return unstruct, err
}
```

しかし、ここで返却される`Unstructured` 構造体は次のように定義されており`ObjectMeta`等のプロパティを持ちません。
```apimachinery/pkg/apis/meta/v1/unstructured/unstructured.go
type Unstructured struct {
	// Object is a JSON compatible map with string, float, int, bool, []interface{}, or
	// map[string]interface{}
	// children.
	Object map[string]interface{}
}
```
こちらのUnstructured型のオブジェクトを具体的なリソースの型に変換しているのが`runtime.unstructuredConverter`の`FromUnstructured`メソッドです。
```apimachinery/pkg/runtime/converter.go
// FromUnstructured converts an object from map[string]interface{} representation into a concrete type.
// It uses encoding/json/Unmarshaler if object implements it or reflection if not.
func (c *unstructuredConverter) FromUnstructured(u map[string]interface{}, obj interface{}) error {
	return c.FromUnstructuredWithValidation(u, obj, false)
}
```
関数のドキュメントにも記載がある通り、こちらの関数により`Unstructured`オブジェクトの`Object`プロパティの値を指定された具体的な型に変換しています。

このように最終的にはmain.goに定義したSample型のオブジェクトとして値を取得することができ、main.goを実行すると次の結果を得ることができました。

```bash
go run main.go --kubeconfig ~/.kube/config                                                                                          
my-cr-sample-object
```

# 参考
- [Kubernetes Programming with Go](https://www.amazon.co.jp/Kubernetes-Programming-Go-Clients-Operators/dp/1484290259)
- [実践入門 Kubernetesカスタムコントローラーへの道](https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5%E5%85%A5%E9%96%80-Kubernetes%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%82%B3%E3%83%B3%E3%83%88%E3%83%AD%E3%83%BC%E3%83%A9%E3%83%BC%E3%81%B8%E3%81%AE%E9%81%93-%E6%8A%80%E8%A1%93%E3%81%AE%E6%B3%89%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA%EF%BC%88NextPublishing%EF%BC%89-%E7%A3%AF-%E8%B3%A2%E5%A4%A7/dp/4844378546)