Closed6

任意のリソースに対してmanagedFieldsに基づいたフィールドの抽出をしたい

zoetrozoetro

まずはExtractXXXX関数の確認。

こんな感じのYAMLを用意。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample
  labels:
    app: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:latest
        ports:
        - containerPort: 80

Server-Side Applyで適用。

kubectl --server-side apply -f deploy.yaml

以下のようにExtractDeploymentを利用すると、kubectlで適用したフィールドだけを抽出することができる。

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	appsv1 "k8s.io/api/apps/v1"
	"k8s.io/apimachinery/pkg/runtime"
	appsv1apply "k8s.io/client-go/applyconfigurations/apps/v1"
	"k8s.io/client-go/discovery"
	"k8s.io/client-go/discovery/cached/memory"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	"k8s.io/client-go/restmapper"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

func main() {
	cli, err := getClient()
	if err != nil {
		log.Fatal(err)
	}

	dep := &appsv1.Deployment{}
	err = cli.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: "sample"}, dep)
	if err != nil {
		log.Fatal(err)
	}

	depac, err := appsv1apply.ExtractDeployment(dep, "kubectl")
	if err != nil {
		log.Fatal(err)
	}

	j, err := json.MarshalIndent(depac, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(j))
}

func getClient() (client.Client, error) {
	config := ctrl.GetConfigOrDie()

	dc, err := discovery.NewDiscoveryClientForConfig(config)
	if err != nil {
		return nil, err
	}
	mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(dc))
	scheme := runtime.NewScheme()
	err = clientgoscheme.AddToScheme(scheme)
	if err != nil {
		return nil, err
	}

	cli, err := client.New(config, client.Options{Scheme: scheme, Mapper: mapper})
	if err != nil {
		return nil, err
	}
	return cli, nil
}

実行結果

{
  "kind": "Deployment",
  "apiVersion": "apps/v1",
  "metadata": {
    "name": "sample",
    "namespace": "default",
    "labels": {
      "app": "nginx"
    }
  },
  "spec": {
    "replicas": 1,
    "selector": {
      "matchLabels": {
        "app": "nginx"
      }
    },
    "template": {
      "metadata": {
        "labels": {
          "app": "nginx"
        }
      },
      "spec": {
        "containers": [
          {
            "name": "nginx",
            "image": "nginx:latest",
            "ports": [
              {
                "containerPort": 80
              }
            ]
          }
        ]
      }
    }
  }
}
zoetrozoetro

上記のExtractXXXX関数は、標準リソースに対するものが用意されている。
これをカスタムリソースを含む任意のリソースに対して実行できると便利なのではないか。

というわけで書いてみた。

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"

	appsv1 "k8s.io/api/apps/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/client-go/discovery"
	"k8s.io/client-go/discovery/cached/memory"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	"k8s.io/client-go/restmapper"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
	"sigs.k8s.io/structured-merge-diff/v4/typed"
)

func main() {
	cli, err := getClient()
	if err != nil {
		log.Fatal(err)
	}

	u := &unstructured.Unstructured{}
	u.SetGroupVersionKind(schema.GroupVersionKind{
		Group:   appsv1.GroupName,
		Version: appsv1.SchemeGroupVersion.Version,
		Kind:    "Deployment",
	})
	err = cli.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: "sample"}, u)
	if err != nil {
		log.Fatal(err)
	}

	fieldset := &fieldpath.Set{}
	objManagedFields := u.GetManagedFields()
	for _, mf := range objManagedFields {
		if mf.Manager != "kubectl" || mf.Operation != metav1.ManagedFieldsOperationApply {
			continue
		}
		fs := &fieldpath.Set{}
		err = fs.FromJSON(bytes.NewReader(mf.FieldsV1.Raw))
		if err != nil {
			log.Fatal(err)
		}
		fieldset = fieldset.Union(fs)
	}

	d, err := typed.DeducedParseableType.FromUnstructured(u.Object)
	if err != nil {
		log.Fatal(err)
	}

	x := d.ExtractItems(fieldset.Leaves()).AsValue().Unstructured()
	m, ok := x.(map[string]interface{})
	if !ok {
		log.Fatal("cannot cast")
	}
	j, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(j))
}

実行結果

{
  "metadata": {
    "labels": {
      "app": "nginx"
    }
  },
  "spec": {
    "replicas": 1,
    "selector": null,
    "template": {
      "metadata": {
        "labels": {
          "app": "nginx"
        }
      },
      "spec": {
        "containers": [
          {
            "image": "nginx:latest",
            "imagePullPolicy": "Always",
            "name": "nginx",
            "ports": [
              {
                "containerPort": 80,
                "protocol": "TCP"
              }
            ],
            "resources": {},
            "terminationMessagePath": "/dev/termination-log",
            "terminationMessagePolicy": "File"
          }
        ]
      }
    }
  }
}

うまくいったかと思いきや、selectorがnullになってしまっている。
あと、resources, terminationMessagePath, terminationMessagePolicyが入ってるのはなぜ?

zoetrozoetro

なぜselectorがnullになってしまうのか。

まずは、DeploymentリソースのmanagedFieldsを見てみる。

  managedFields:
  - apiVersion: apps/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:app: {}
      f:spec:
        f:replicas: {}
        f:selector: {}
        f:template:
          f:metadata:
            f:labels:
              f:app: {}
          f:spec:
            f:containers:
              k:{"name":"nginx"}:
                .: {}
                f:image: {}
                f:name: {}
                f:ports:
                  k:{"containerPort":80,"protocol":"TCP"}:
                    .: {}
                    f:containerPort: {}
    manager: kubectl
    operation: Apply
    time: "2021-09-11T02:30:32Z"

f:selectorの下の要素がない…。

というわけで、LabelSelectorの構造体の定義を見てみる。

// A label selector is a label query over a set of resources. The result of matchLabels and
// matchExpressions are ANDed. An empty label selector matches all objects. A null
// label selector matches no objects.
// +structType=atomic
type LabelSelector struct {
	// matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
	// map is equivalent to an element of matchExpressions, whose key field is "key", the
	// operator is "In", and the values array contains only "value". The requirements are ANDed.
	// +optional
	MatchLabels map[string]string `json:"matchLabels,omitempty" protobuf:"bytes,1,rep,name=matchLabels"`
	// matchExpressions is a list of label selector requirements. The requirements are ANDed.
	// +optional
	MatchExpressions []LabelSelectorRequirement `json:"matchExpressions,omitempty" protobuf:"bytes,2,rep,name=matchExpressions"`
}

// A label selector requirement is a selector that contains values, a key, and an operator that
// relates the key and values.
type LabelSelectorRequirement struct {
	// key is the label key that the selector applies to.
	// +patchMergeKey=key
	// +patchStrategy=merge
	Key string `json:"key" patchStrategy:"merge" patchMergeKey:"key" protobuf:"bytes,1,opt,name=key"`
	// operator represents a key's relationship to a set of values.
	// Valid operators are In, NotIn, Exists and DoesNotExist.
	Operator LabelSelectorOperator `json:"operator" protobuf:"bytes,2,opt,name=operator,casttype=LabelSelectorOperator"`
	// values is an array of string values. If the operator is In or NotIn,
	// the values array must be non-empty. If the operator is Exists or DoesNotExist,
	// the values array must be empty. This array is replaced during a strategic
	// merge patch.
	// +optional
	Values []string `json:"values,omitempty" protobuf:"bytes,3,rep,name=values"`
}

MatchLabelsのほうはただのmapなので問題ないように見える。
MatchExpressionsのほうは、もしこれがカスタムリソースなのであれば、+listType=map, +listMapKey=keyなどのマーカーをつけてやるとよさそう。(参考)
(ところで、patchMergeKeyをつける場所間違ってね?)

ちなみに、カスタムリソースにLabelSelectorを持たせてみるとmanagedFieldsは以下のようになり、Extractも問題なく動く。なぜこうなるのか分からん…

  managedFields:
  - apiVersion: sample.zoetrope.github.io/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:spec:
        f:selector:
          f:matchLabels:
            f:key: {}
    manager: kubectl
    operation: Apply
    time: "2021-09-11T02:56:27Z"
zoetrozoetro

つぎに、ExtractDeploymentではselectorが抽出できるのに、unstructured方式だと抽出できないのはなぜか調べてみる。

どうやら以下のschemaYAMLというものが鍵らしい。
https://github.com/kubernetes/client-go/blob/release-1.21/applyconfigurations/internal/internal.go#L41

このYAMLはapplyconfiguration-genというツールで生成されている。
https://github.com/kubernetes/code-generator/tree/release-1.21/cmd/applyconfiguration-gen

さらにapplyconfiguration-genの入力となるデータは、models-schemaというツールで生成されている。
https://github.com/kubernetes/kubernetes/tree/release-1.21/pkg/generated/openapi/cmd/models-schema

そしてmodels-schemaはgoの型定義から生成された情報を元にしている。

zoetrozoetro

カスタムリソースの場合は、goの型定義を持ってくることは難しい。
じゃあ、CRDに含まれてるOpenAPIV3Schemaから、Extractに必要なschemaを生成してあげればうれしいのでは?と思ってコードを書いてみた。

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"

	openapiv2 "github.com/googleapis/gnostic/openapiv2"
	"gopkg.in/yaml.v2"
	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/client-go/discovery"
	"k8s.io/client-go/discovery/cached/memory"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	"k8s.io/client-go/restmapper"
	"k8s.io/kube-openapi/pkg/schemaconv"
	utilproto "k8s.io/kube-openapi/pkg/util/proto"
	"k8s.io/kube-openapi/pkg/validation/spec"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
	"sigs.k8s.io/structured-merge-diff/v4/typed"
)

func main() {
	cli, err := getClient()
	if err != nil {
		log.Fatal(err)
	}

	u := &unstructured.Unstructured{}
	u.SetGroupVersionKind(schema.GroupVersionKind{
		Group:   "sample.zoetrope.github.io",
		Version: "v1",
		Kind:    "Test",
	})
	err = cli.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: "sample"}, u)
	if err != nil {
		log.Fatal(err)
	}

	fieldset := &fieldpath.Set{}
	objManagedFields := u.GetManagedFields()
	for _, mf := range objManagedFields {
		if mf.Manager != "kubectl" || mf.Operation != metav1.ManagedFieldsOperationApply {
			continue
		}
		fs := &fieldpath.Set{}
		err = fs.FromJSON(bytes.NewReader(mf.FieldsV1.Raw))
		if err != nil {
			log.Fatal(err)
		}
		fieldset = fieldset.Union(fs)
	}

	schema, err := genSchema()
	if err != nil {
		log.Fatal(err)
	}
	objectType := parser(schema).Type("v1.Test")
	d, err := objectType.FromStructured(u)
	if err != nil {
		log.Fatal(err)
	}

	x := d.ExtractItems(fieldset.Leaves()).AsValue().Unstructured()
	m, ok := x.(map[string]interface{})
	if !ok {
		log.Fatal("cannot cast")
	}
	j, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(j))
}

func parser(schema string) *typed.Parser {
	var err error
	parser, err := typed.NewParser(typed.YAMLObject(schema))
	if err != nil {
		panic(fmt.Sprintf("Failed to parse schema: %v", err))
	}
	return parser
}

func genSchema() (string, error) {
	cli, err := getClient()
	if err != nil {
		return "", err
	}

	crd := &extv1.CustomResourceDefinition{}
	err = cli.Get(context.Background(), client.ObjectKey{Name: "tests.sample.zoetrope.github.io"}, crd)
	if err != nil {
		return "", err
	}

	schemaDefs := make(map[string]spec.Schema, len(crd.Spec.Versions))
	for _, version := range crd.Spec.Versions {
		b, err := json.Marshal(version.Schema.OpenAPIV3Schema)
		if err != nil {
			return "", err
		}
		schema := spec.Schema{}
		err = json.Unmarshal(b, &schema)
		if err != nil {
			return "", err
		}
		schemaDefs[version.Name+"."+crd.Spec.Names.Kind] = schema
	}

	model := &spec.Swagger{
		SwaggerProps: spec.SwaggerProps{
			Definitions: schemaDefs,
			Info: &spec.Info{
				InfoProps: spec.InfoProps{
					Title:   "Kubernetes",
					Version: "unversioned",
				},
			},
			Swagger: "2.0",
		},
	}

	models, err := toValidatedModels(model)
	if err != nil {
		return "", err
	}

	schema, err := schemaconv.ToSchema(models)
	if err != nil {
		return "", err
	}

	schemaYAML, err := yaml.Marshal(schema)
	if err != nil {
		return "", err
	}
	return string(schemaYAML), nil
}

func toValidatedModels(openAPISchema *spec.Swagger) (utilproto.Models, error) {
	rawMinimalOpenAPISchema, err := json.Marshal(openAPISchema)
	if err != nil {
		return nil, fmt.Errorf("failed to unmarshal openAPI as JSON: %w", err)
	}

	document, err := openapiv2.ParseDocument(rawMinimalOpenAPISchema)
	if err != nil {
		return nil, fmt.Errorf("failed to parse OpenAPI document for file: %w", err)
	}
	models, err := utilproto.NewOpenAPIData(document)
	if err != nil {
		return nil, fmt.Errorf("failed to create OpenAPI models for file: %w", err)
	}
	return models, nil
}

func getClient() (client.Client, error) {
	config := ctrl.GetConfigOrDie()

	dc, err := discovery.NewDiscoveryClientForConfig(config)
	if err != nil {
		return nil, err
	}
	mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(dc))
	scheme := runtime.NewScheme()
	err = clientgoscheme.AddToScheme(scheme)
	if err != nil {
		return nil, err
	}
	err = extv1.AddToScheme(scheme)
	if err != nil {
		return nil, err
	}

	cli, err := client.New(config, client.Options{Scheme: scheme, Mapper: mapper})
	if err != nil {
		return nil, err
	}
	return cli, nil
}

で、書いてみたのはいいんだけど、そもそもカスタムリソースの場合は問題なくExtractできてるから、こんなことしなくてもいいのでは…?
あと、Goの構造体から情報をとってくるのに比べて、CRDに含まれている情報は少ないので、schemaとして不十分かもしれない。

zoetrozoetro

結論としては

  • 標準リソースは、用意されているExtractXXXX関数を利用する
  • カスタムリソースは、2番目の投稿に書いた方式で抽出する

でOK?

このスクラップは2021/09/11にクローズされました