Chapter 06

Admission Webhookのテスト

zoetro
zoetro
2022.08.22に更新

テスト対象

まずはテスト対象となるAdmission Webhookの実装をおこないます。

実装の詳細についての解説はおこないませんが、デフォルト値を設定するMutating Webhookと、値の検証をおこなうValidating Webhookを実装しています。

package v1

import (
	"strings"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/validation/field"
	ctrl "sigs.k8s.io/controller-runtime"
	logf "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/webhook"
)

// log is for logging in this package.
var samplelog = logf.Log.WithName("sample-resource")

func (r *Sample) SetupWebhookWithManager(mgr ctrl.Manager) error {
	return ctrl.NewWebhookManagedBy(mgr).
		For(r).
		Complete()
}

//+kubebuilder:webhook:path=/mutate-test-zoetrope-github-io-v1-sample,mutating=true,failurePolicy=fail,sideEffects=None,groups=test.zoetrope.github.io,resources=samples,verbs=create;update,versions=v1,name=msample.kb.io,admissionReviewVersions=v1

var _ webhook.Defaulter = &Sample{}

// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *Sample) Default() {
	samplelog.Info("default", "name", r.Name)

	defaultReplicas := int32(1)

	if r.Spec.Replicas == nil {
		r.Spec.Replicas = &defaultReplicas
	}
}

//+kubebuilder:webhook:path=/validate-test-zoetrope-github-io-v1-sample,mutating=false,failurePolicy=fail,sideEffects=None,groups=test.zoetrope.github.io,resources=samples,verbs=create;update,versions=v1,name=vsample.kb.io,admissionReviewVersions=v1

var _ webhook.Validator = &Sample{}

// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *Sample) ValidateCreate() error {
	samplelog.Info("validate create", "name", r.Name)

	return r.validate()
}

// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *Sample) ValidateUpdate(old runtime.Object) error {
	samplelog.Info("validate update", "name", r.Name)

	return r.validate()
}

func (r *Sample) validate() error {
	var errs field.ErrorList

	if r.Spec.Replicas == nil {
		errs = append(errs, field.Invalid(field.NewPath("spec", "replicas"), r.Spec.Replicas, "replicas cannot be empty"))
	} else if *r.Spec.Replicas < 1 {
		errs = append(errs, field.Invalid(field.NewPath("spec", "replicas"), r.Spec.Replicas, "replicas should be grater than 0"))
	}

	if len(r.Spec.Image) == 0 {
		errs = append(errs, field.Invalid(field.NewPath("spec", "image"), r.Spec.Image, "image cannot be empty"))
	} else if !strings.Contains(r.Spec.Image, ":") {
		errs = append(errs, field.Invalid(field.NewPath("spec", "image"), r.Spec.Image, "image should have a tag"))
	} else {
		images := strings.Split(r.Spec.Image, ":")
		if len(images) != 2 {
			errs = append(errs, field.Invalid(field.NewPath("spec", "image"), r.Spec.Image, "image is not valid format"))
		} else if images[1] == "latest" {
			errs = append(errs, field.Invalid(field.NewPath("spec", "image"), r.Spec.Image, "image cannot have latest tag"))
		}
	}

	if len(errs) > 0 {
		err := apierrors.NewInvalid(schema.GroupKind{Group: GroupVersion.Group, Kind: "Sample"}, r.Name, errs)
		return err
	}
	return nil
}

// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (r *Sample) ValidateDelete() error {
	samplelog.Info("validate delete", "name", r.Name)

	return nil
}

StatusErrorのAssertion

Validating Webhookは、Validationに失敗するとerrors.StatusError型のエラーを返します。
そこで、以下のような簡易的なMatcherを用意しておくと、エラーのAssertionの際に便利です。

package v1

import (
	"errors"
	"fmt"

	. "github.com/onsi/gomega"
	"github.com/onsi/gomega/types"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func HaveStatusErrorMessage(m types.GomegaMatcher) types.GomegaMatcher {
	return WithTransform(func(e error) (string, error) {
		statusErr := &apierrors.StatusError{}
		if !errors.As(e, &statusErr) {
			return "", fmt.Errorf("HaveStatusErrorMessage expects a *errors.StatusError, but got %T", e)
		}
		return statusErr.ErrStatus.Message, nil
	}, m)
}

func HaveStatusErrorReason(m types.GomegaMatcher) types.GomegaMatcher {
	return WithTransform(func(e error) (metav1.StatusReason, error) {
		statusErr := &apierrors.StatusError{}
		if !errors.As(e, &statusErr) {
			return "", fmt.Errorf("HaveStatusErrorReason expects a *errors.StatusError, but got %T", e)
		}
		return statusErr.ErrStatus.Reason, nil
	}, m)
}

Admission Webhookのテスト

それでは、Admission Webhookのテストを書いてみましょう。

Admission Webhookの起動と停止は、Kubebuilderが生成したwebhook_suite_test.goの中でおこなわれています。
Admission Webhookは非同期で動くものではないので、Controllerのようにテストケースごとに起動と停止をおこなう必要はありません。

さっそく先ほど作成したMatcherを利用してテストを書いてみましょう。

package v1

import (
	"context"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
)

var _ = Describe("Webhook Test", func() {
	ctx := context.Background()

	It("should deny image that has latest tag", func() {
		sample := &Sample{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "default",
				Name:      "invalid-sample",
			},
			Spec: SampleSpec{
				Image:    "nginx:latest",
				Replicas: pointer.Int32(2),
			},
		}
		err := k8sClient.Create(ctx, sample)

		Expect(err).Should(HaveStatusErrorReason(Equal(metav1.StatusReasonInvalid)))
		Expect(err).Should(HaveStatusErrorMessage(ContainSubstring("image cannot have latest tag")))
	})
})

入力となるSampleリソースを用意し、kube-apiserverにリソース作成のリクエストを投げ、その結果として期待するエラーが返ってくることを確認しています。

テーブル駆動テスト

Admission Webhookのテストでは、ロジックが同じで入力値と結果だけが異なるテストをたくさん書くことがあります。
このようなケースではテーブル駆動テストを利用すると便利です。

Ginkgoでは、DescribeTableを利用してテーブル駆動テストを書くことができます。

package v1

import (
	"context"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/utils/pointer"
)

var _ = Describe("Webhook Table Test", func() {

	DescribeTable("Validator Test", func(image string, replicas *int32, reason metav1.StatusReason, message string) {
		ctx := context.Background()
		sample := &Sample{
			ObjectMeta: metav1.ObjectMeta{
				Namespace: "default",
				Name:      "invalid-sample",
			},
			Spec: SampleSpec{
				Image:    image,
				Replicas: replicas,
			},
		}
		err := k8sClient.Create(ctx, sample)

		Expect(err).Should(HaveStatusErrorReason(Equal(reason)))
		Expect(err).Should(HaveStatusErrorMessage(ContainSubstring(message)))
	},
		Entry("replicas is negative", "nginx:1.14.2", pointer.Int32(-5), metav1.StatusReasonInvalid, "replicas should be grater than 0"),
		Entry("image is empty", "", pointer.Int32(1), metav1.StatusReasonInvalid, "image cannot be empty"),
		Entry("image does not have a tag", "nginx", pointer.Int32(1), metav1.StatusReasonInvalid, "image should have a tag"),
		Entry("image is invalid format", "nginx:invalid:latest", pointer.Int32(1), metav1.StatusReasonInvalid, "image is not valid format"),
		Entry("image has latest tag", "nginx:latest", pointer.Int32(1), metav1.StatusReasonInvalid, "image cannot have latest tag"),
	)

})

DescribeTableは、第1引数にテストの説明を指定し、第2引数のテストのロジックとなる関数を渡します。
第3引数以降には、入力と期待値の組み合わせであるEntryのセットを指定します。

この1つのEntryが、1つのテストケース(スペック)となります。