Repository: kurator-dev/kurator
Pull request #344 (2023-06-26 03:07:12)

attachedcluster: init webhook
**What type of PR is this?**

/kind feature

**What this PR does / why we need it**:

add validation for attachedcluster

part of #336 

**Which issue(s) this PR fixes**:
Fixes #

**Special notes for your reviewer**:

**Does this PR introduce a user-facing change?**:
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required.
-->
```release-note
add validation for attachedcluster

```



netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *kurator-dev* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | e40449cb3cea7b20dd204c47ff707ce5658b2a40 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/kurator-dev/deploys/649900e2e4733f0008d238c5 |
kurator-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
**Once this PR has been reviewed and has the lgtm label**, please assign kevin-wangzefeng for approval. For more information see [the Kubernetes Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kurator-dev%2Fkurator).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kurator-dev/kurator/blob/main/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["kevin-wangzefeng"]} -->

Patch:
diff --git a/cmd/cluster-operator/attachedcluster/attachedcluster.go b/cmd/cluster-operator/attachedcluster/attachedcluster.go
index 4eb6250..9fa6333 100644
--- a/cmd/cluster-operator/attachedcluster/attachedcluster.go
+++ b/cmd/cluster-operator/attachedcluster/attachedcluster.go
@@ -24,6 +24,7 @@ import (
 
 	"kurator.dev/kurator/cmd/cluster-operator/options"
 	clusteroperator "kurator.dev/kurator/pkg/cluster-operator"
+	"kurator.dev/kurator/pkg/webhooks"
 )
 
 var log = ctrl.Log.WithName("attached cluster")
@@ -38,5 +39,12 @@ func InitControllers(ctx context.Context, opts *options.Options, mgr ctrl.Manage
 		return err
 	}
 
+	if err := (&webhooks.AttachedClusterWebhook{
+		Client: mgr.GetClient(),
+	}).SetupWebhookWithManager(mgr); err != nil {
+		log.Error(err, "unable to create AttachedCluster webhook", "Webhook", "AttachedCluster")
+		return err
+	}
+
 	return nil
 }
diff --git a/docs/content/en/docs/cluster-operator/vms-cluster-lifecycle.md b/docs/content/en/docs/cluster-operator/vms-cluster-lifecycle.md
index 2daab64..b1ab1e1 100644
--- a/docs/content/en/docs/cluster-operator/vms-cluster-lifecycle.md
+++ b/docs/content/en/docs/cluster-operator/vms-cluster-lifecycle.md
@@ -389,7 +389,7 @@ spec:
       maxSurge: 1
     type: RollingUpdate
   # edit the version to desired upgrading version
-  version: v1.26.5
+  version: v1.24.6
 status:
   conditions:
   ...
diff --git a/examples/infra/customcluster/cc-kcp.yaml b/examples/infra/customcluster/cc-kcp.yaml
index f7480ea..52e3dae 100644
--- a/examples/infra/customcluster/cc-kcp.yaml
+++ b/examples/infra/customcluster/cc-kcp.yaml
@@ -19,4 +19,4 @@ spec:
       kind: customMachine
       name: cc-custommachine
       namespace: default
-  version: v1.26.5
+  version: v1.24.6
diff --git a/manifests/charts/cluster-operator/templates/webhooks.yaml b/manifests/charts/cluster-operator/templates/webhooks.yaml
index 1fb0710..04928a0 100644
--- a/manifests/charts/cluster-operator/templates/webhooks.yaml
+++ b/manifests/charts/cluster-operator/templates/webhooks.yaml
@@ -50,3 +50,25 @@ webhooks:
     resources:
     - customclusters
   sideEffects: None
+- admissionReviewVersions:
+    - v1
+    - v1beta1
+  clientConfig:
+    service:
+      name: kurator-webhook-service
+      namespace: {{ .Release.Namespace }}
+      path: /validate-cluster-kurator-dev-v1alpha1-attachedcluster # get this path from webhook service log
+  failurePolicy: Fail
+  matchPolicy: Equivalent
+  name: validation.attachedcluster.cluster.kurator.dev
+  rules:
+    - apiGroups:
+        - cluster.kurator.dev
+      apiVersions:
+        - v1alpha1
+      operations:
+        - CREATE
+        - UPDATE
+      resources:
+        - attachedclusters
+  sideEffects: None
diff --git a/pkg/cluster-operator/customcluster_controller.go b/pkg/cluster-operator/customcluster_controller.go
index a33ba25..800ac61 100644
--- a/pkg/cluster-operator/customcluster_controller.go
+++ b/pkg/cluster-operator/customcluster_controller.go
@@ -97,6 +97,13 @@ const (
 
 	// KubeVersionPrefix is the prefix string of version of kubernetes
 	KubeVersionPrefix = "kube_version: "
+	// DefaultKubesprayVersion is the version of Kubespray used by the customCluster manager worker pod.
+	DefaultKubesprayVersion = "v2.20.0"
+	// DefaultMaxVersion is the max version of Kubernetes that is supported by DefaultKubesprayVersion.
+	DefaultMaxVersion = "v1.24.6"
+	// DefaultMinVersion is the min version of Kubernetes that is supported by DefaultKubesprayVersion.
+	DefaultMinVersion = "v1.22.0"
+	// TODO: support more version to remove limited DefaultMaxVersion and DefaultMinVersion
 )
 
 // SetupWithManager sets up the controller with the Manager.
@@ -263,6 +270,11 @@ func (r *CustomClusterController) reconcile(ctx context.Context, customCluster *
 	desiredClusterInfo := getDesiredClusterInfo(customMachine, kcp)
 	// desiredVersion is the one recorded in kcp.version.
 	desiredVersion := desiredClusterInfo.KubeVersion
+	// If the desired version is not within a specified range of versions, return directly.
+	if !isSupportedVersion(desiredVersion, DefaultMinVersion, DefaultMaxVersion) {
+		log.Error(fmt.Errorf("the kubespray image version is %s. Then the minimum supported version of Kubernetes is %s, and the maximum is %s. However, the desired version of Kubernetes is %s ", DefaultKubesprayVersion, DefaultMinVersion, DefaultMaxVersion, desiredVersion), "")
+		return ctrl.Result{}, nil
+	}
 
 	// Handle cluster provision.
 	if phase == v1alpha1.PendingPhase || phase == v1alpha1.ProvisionFailedPhase || phase == v1alpha1.ProvisioningPhase {
@@ -292,10 +304,10 @@ func (r *CustomClusterController) reconcile(ctx context.Context, customCluster *
 	// Handle worker nodes scaling.
 	// By comparing desiredClusterInfo.WorkerNodes and provisionedClusterInfo.WorkerNodes to decide whether to proceed reconcileScaleUp or reconcileScaleDown.
 	if len(scaleUpWorkerNodes) != 0 {
-		return r.reconcileScaleUp(ctx, customCluster, scaleUpWorkerNodes, kcp)
+		return r.reconcileScaleUp(ctx, customCluster, scaleUpWorkerNodes)
 	}
 	if len(scaleDownWorkerNodes) != 0 {
-		return r.reconcileScaleDown(ctx, customCluster, customMachine, scaleDownWorkerNodes, kcp)
+		return r.reconcileScaleDown(ctx, customCluster, customMachine, scaleDownWorkerNodes)
 	}
 
 	// Handle cluster upgrade.
@@ -329,7 +341,7 @@ func (r *CustomClusterController) reconcileProvision(ctx context.Context, custom
 	}
 
 	// Create init worker pod to handle the provisioning process.
-	initWorker, err3 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterInitAction, KubesprayInitCMD, generateClusterHostsName(customCluster), generateClusterConfigName(customCluster), kcp.Spec.Version)
+	initWorker, err3 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterInitAction, KubesprayInitCMD, generateClusterHostsName(customCluster), generateClusterConfigName(customCluster))
 	if err3 != nil {
 		conditions.MarkFalse(customCluster, v1alpha1.ReadyCondition, v1alpha1.FailedCreateInitWorker,
 			clusterv1.ConditionSeverityWarning, "init worker is failed to create %s/%s", customCluster.Namespace, customCluster.Name)
@@ -386,7 +398,7 @@ func (r *CustomClusterController) reconcileDelete(ctx context.Context, customClu
 	}
 
 	// Create the termination worker to handle the cluster deletion.
-	terminateWorker, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterTerminateAction, KubesprayTerminateCMD, generateClusterHostsName(customCluster), generateClusterConfigName(customCluster), kcp.Spec.Version)
+	terminateWorker, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterTerminateAction, KubesprayTerminateCMD, generateClusterHostsName(customCluster), generateClusterConfigName(customCluster))
 	if err1 != nil {
 		conditions.MarkFalse(customCluster, v1alpha1.TerminatedCondition, v1alpha1.FailedCreateTerminateWorker,
 			clusterv1.ConditionSeverityWarning, "terminate worker is failed to create %s/%s.", customCluster.Namespace, customCluster.Name)
diff --git a/pkg/cluster-operator/customcluster_helper.go b/pkg/cluster-operator/customcluster_helper.go
index 2306072..6873235 100644
--- a/pkg/cluster-operator/customcluster_helper.go
+++ b/pkg/cluster-operator/customcluster_helper.go
@@ -24,7 +24,6 @@ import (
 	"strings"
 	"text/template"
 
-	"github.com/coreos/go-semver/semver"
 	"github.com/pkg/sftp"
 	"golang.org/x/crypto/ssh"
 	corev1 "k8s.io/api/core/v1"
@@ -33,7 +32,6 @@ import (
 	"k8s.io/apiserver/pkg/storage/names"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
 	controlplanev1 "sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1beta1"
-	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
 
@@ -41,11 +39,12 @@ import (
 )
 
 // generateClusterManageWorker generate a kubespray manage worker pod from configmap.
-func generateClusterManageWorker(customCluster *v1alpha1.CustomCluster, manageAction customClusterManageAction, manageCMD customClusterManageCMD, hostsName, configName, kubesprayImage string) *corev1.Pod {
+func generateClusterManageWorker(customCluster *v1alpha1.CustomCluster, manageAction customClusterManageAction, manageCMD customClusterManageCMD, hostsName, configName string) *corev1.Pod {
 	basePodName := customCluster.Name + "-" + string(manageAction)
 	podName := names.SimpleNameGenerator.GenerateName(basePodName + "-")
 	namespace := customCluster.Namespace
 	defaultMode := int32(0o600)
+	kubesprayImage := getKubesprayImage(DefaultKubesprayVersion)
 	managerWorker := &corev1.Pod{
 		ObjectMeta: metav1.ObjectMeta{
 			Namespace: namespace,
@@ -401,7 +400,7 @@ func (r *CustomClusterController) ensureWorkerPodDeleted(ctx context.Context, cu
 }
 
 // ensureWorkerPodCreated ensure that the worker pod is created.
-func (r *CustomClusterController) ensureWorkerPodCreated(ctx context.Context, customCluster *v1alpha1.CustomCluster, manageAction customClusterManageAction, manageCMD customClusterManageCMD, hostName, configName, kubeVersion string) (*corev1.Pod, error) {
+func (r *CustomClusterController) ensureWorkerPodCreated(ctx context.Context, customCluster *v1alpha1.CustomCluster, manageAction customClusterManageAction, manageCMD customClusterManageCMD, hostName, configName string) (*corev1.Pod, error) {
 	workerPod, err := r.findManageWorkerPod(ctx, customCluster, manageAction)
 
 	if err != nil {
@@ -411,9 +410,7 @@ func (r *CustomClusterController) ensureWorkerPodCreated(ctx context.Context, cu
 		return workerPod, nil
 	}
 
-	kubesprayImage := getKubesprayImage(ctx, kubeVersion)
-
-	newWorkerPod := generateClusterManageWorker(customCluster, manageAction, manageCMD, hostName, configName, kubesprayImage)
+	newWorkerPod := generateClusterManageWorker(customCluster, manageAction, manageCMD, hostName, configName)
 	newWorkerPod.OwnerReferences = []metav1.OwnerReference{generateOwnerRefFromCustomCluster(customCluster)}
 	if err := r.Client.Create(ctx, newWorkerPod); err != nil {
 		return nil, fmt.Errorf("failed to create customCluster manager worker pod: %v", err)
@@ -444,36 +441,9 @@ func (r *CustomClusterController) findManageWorkerPod(ctx context.Context, custo
 	return nil, nil
 }
 
-// getKubesprayImage takes a Kubernetes version string (in the format "vX.Y.Z")
-// and returns the corresponding Kubespray image URL for that version.
-// The function supports Kubernetes versions from 1.22.0 to 1.26.5.
-// Kubespray v2.20.0 supports Kubernetes versions from 1.22.0 to 1.24.6,
-// while Kubespray v2.22.1 supports Kubernetes versions from 1.24.0 to 1.26.5.
-// This function returns one of these two Kubespray versions based on the input Kubernetes version.
-func getKubesprayImage(ctx context.Context, kubeVersion string) string {
-	log := ctrl.LoggerFrom(ctx)
-	var kubesprayVersion string
-
-	kubeVersion = strings.TrimPrefix(kubeVersion, "v")
-
-	targetVersion, err := semver.NewVersion(kubeVersion)
-	// should not happen, if we have validation on kubeVersion
-	if err != nil {
-		log.Error(err, "unexpected kube version", "targetVersion", targetVersion)
-		return ""
-	}
-
-	midVersion, _ := semver.NewVersion("1.24.0")
-
-	// Determine the Kubespray version based on the Kubernetes version.
-	if targetVersion.Compare(*midVersion) >= 0 {
-		kubesprayVersion = "v2.22.1"
-	} else {
-		kubesprayVersion = "v2.20.0"
-	}
-
+// getKubesprayImage take in kubesprayVersion return the kubespray image url of this version.
+func getKubesprayImage(kubesprayVersion string) string {
 	imagePath := "quay.io/kubespray/kubespray:" + kubesprayVersion
-
 	return imagePath
 }
 
diff --git a/pkg/cluster-operator/customcluster_scale.go b/pkg/cluster-operator/customcluster_scale.go
index 7c7975e..4bca656 100644
--- a/pkg/cluster-operator/customcluster_scale.go
+++ b/pkg/cluster-operator/customcluster_scale.go
@@ -26,7 +26,6 @@ import (
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
-	controlplanev1 "sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1beta1"
 	"sigs.k8s.io/cluster-api/util/conditions"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -35,7 +34,7 @@ import (
 )
 
 // reconcileScaleUp is responsible for handling the customCluster reconciliation process when worker nodes need to be scaled up.
-func (r *CustomClusterController) reconcileScaleUp(ctx context.Context, customCluster *v1alpha1.CustomCluster, scaleUpWorkerNodes []NodeInfo, kcp *controlplanev1.KubeadmControlPlane) (ctrl.Result, error) {
+func (r *CustomClusterController) reconcileScaleUp(ctx context.Context, customCluster *v1alpha1.CustomCluster, scaleUpWorkerNodes []NodeInfo) (ctrl.Result, error) {
 	log := ctrl.LoggerFrom(ctx)
 
 	// Create a temporary configmap that represents the desired state to create the scaleUp pod.
@@ -45,7 +44,7 @@ func (r *CustomClusterController) reconcileScaleUp(ctx context.Context, customCl
 	}
 
 	// Create the scaleUp pod.
-	workerPod, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterScaleUpAction, KubesprayScaleUpCMD, generateScaleUpHostsName(customCluster), generateClusterConfigName(customCluster), kcp.Spec.Version)
+	workerPod, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterScaleUpAction, KubesprayScaleUpCMD, generateScaleUpHostsName(customCluster), generateClusterConfigName(customCluster))
 	if err1 != nil {
 		conditions.MarkFalse(customCluster, v1alpha1.ScaledUpCondition, v1alpha1.FailedCreateScaleUpWorker,
 			clusterv1.ConditionSeverityWarning, "scale up worker is failed to create %s/%s.", customCluster.Namespace, customCluster.Name)
@@ -99,11 +98,11 @@ func (r *CustomClusterController) reconcileScaleUp(ctx context.Context, customCl
 }
 
 // reconcileScaleDown is responsible for handling the customCluster reconciliation process when worker nodes need to be scaled down.
-func (r *CustomClusterController) reconcileScaleDown(ctx context.Context, customCluster *v1alpha1.CustomCluster, customMachine *v1alpha1.CustomMachine, scaleDownWorkerNodes []NodeInfo, kcp *controlplanev1.KubeadmControlPlane) (ctrl.Result, error) {
+func (r *CustomClusterController) reconcileScaleDown(ctx context.Context, customCluster *v1alpha1.CustomCluster, customMachine *v1alpha1.CustomMachine, scaleDownWorkerNodes []NodeInfo) (ctrl.Result, error) {
 	log := ctrl.LoggerFrom(ctx)
 
 	// Checks whether the worker node for scaling down already exists. If it does not exist, the function creates it.
-	workerPod, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterScaleDownAction, generateScaleDownManageCMD(scaleDownWorkerNodes), generateClusterHostsName(customCluster), generateClusterConfigName(customCluster), kcp.Spec.Version)
+	workerPod, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterScaleDownAction, generateScaleDownManageCMD(scaleDownWorkerNodes), generateClusterHostsName(customCluster), generateClusterConfigName(customCluster))
 	if err1 != nil {
 		conditions.MarkFalse(customCluster, v1alpha1.ScaledDownCondition, v1alpha1.FailedCreateScaleDownWorker,
 			clusterv1.ConditionSeverityWarning, "scale down worker is failed to create %s/%s.", customCluster.Namespace, customCluster.Name)
diff --git a/pkg/cluster-operator/customcluster_upgrade.go b/pkg/cluster-operator/customcluster_upgrade.go
index 1adc9f1..9298452 100644
--- a/pkg/cluster-operator/customcluster_upgrade.go
+++ b/pkg/cluster-operator/customcluster_upgrade.go
@@ -36,7 +36,7 @@ func (r *CustomClusterController) reconcileUpgrade(ctx context.Context, customCl
 
 	cmd := generateUpgradeManageCMD(targetVersion)
 	// Checks whether the worker node for upgrading already exists. If it does not exist, then create it.
-	workerPod, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterUpgradeAction, cmd, generateClusterHostsName(customCluster), generateClusterConfigName(customCluster), targetVersion)
+	workerPod, err1 := r.ensureWorkerPodCreated(ctx, customCluster, CustomClusterUpgradeAction, cmd, generateClusterHostsName(customCluster), generateClusterConfigName(customCluster))
 	if err1 != nil {
 		conditions.MarkFalse(customCluster, v1alpha1.UpgradeCondition, v1alpha1.UpgradeWorkerCreateFailed,
 			clusterv1.ConditionSeverityWarning, "upgrade worker is failed to create %s/%s.", customCluster.Namespace, customCluster.Name)
diff --git a/pkg/webhooks/attachedcluster_webhook.go b/pkg/webhooks/attachedcluster_webhook.go
new file mode 100644
index 0000000..2a21cf2
--- /dev/null
+++ b/pkg/webhooks/attachedcluster_webhook.go
@@ -0,0 +1,101 @@
+/*
+Copyright Kurator Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package webhooks
+
+import (
+	"context"
+	"fmt"
+
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/validation/field"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+
+	"kurator.dev/kurator/pkg/apis/cluster/v1alpha1"
+)
+
+var _ webhook.CustomValidator = &AttachedClusterWebhook{}
+
+type AttachedClusterWebhook struct {
+	Client client.Reader
+}
+
+func (wh *AttachedClusterWebhook) SetupWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(&v1alpha1.AttachedCluster{}).
+		WithValidator(wh).
+		Complete()
+}
+
+func (wh *AttachedClusterWebhook) ValidateCreate(_ context.Context, obj runtime.Object) error {
+	in, ok := obj.(*v1alpha1.AttachedCluster)
+	if !ok {
+		return apierrors.NewBadRequest(fmt.Sprintf("expected a AttachedCluster but got a %T", obj))
+	}
+
+	return wh.validate(in)
+}
+
+func (wh *AttachedClusterWebhook) validate(in *v1alpha1.AttachedCluster) error {
+	var allErrs field.ErrorList
+
+	allErrs = append(allErrs, validateSecretKeyRef(in.Spec.Kubeconfig)...)
+
+	if len(allErrs) > 0 {
+		return apierrors.NewInvalid(v1alpha1.SchemeGroupVersion.WithKind("AttachedCluster").GroupKind(), in.Name, allErrs)
+	}
+
+	return nil
+}
+
+func validateSecretKeyRef(kubeconfig v1alpha1.SecretKeyRef) field.ErrorList {
+	var allErrs field.ErrorList
+
+	if kubeconfig.Name == "" {
+		allErrs = append(allErrs, field.Required(field.NewPath("spec", "kubeconfig", "name"), "must be set"))
+	} else {
+		allErrs = append(allErrs, validateDNS1123Domain(kubeconfig.Name, field.NewPath("spec", "kubeconfig", "name"))...)
+	}
+	if kubeconfig.Key == "" {
+		allErrs = append(allErrs, field.Required(field.NewPath("spec", "kubeconfig", "key"), "must be set"))
+	} else {
+		// The IsDNS1123Subdomain is used to validate the keys due to the portability and utility of this format across various contexts.
+		allErrs = append(allErrs, validateDNS1123Domain(kubeconfig.Key, field.NewPath("spec", "kubeconfig", "key"))...)
+	}
+
+	return allErrs
+}
+
+func (wh *AttachedClusterWebhook) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) error {
+	_, ok := oldObj.(*v1alpha1.AttachedCluster)
+	if !ok {
+		return apierrors.NewBadRequest(fmt.Sprintf("expected a AttachedCluster but got a %T", oldObj))
+	}
+
+	newAttachedCluster, ok := newObj.(*v1alpha1.AttachedCluster)
+	if !ok {
+		return apierrors.NewBadRequest(fmt.Sprintf("expected a AttachedCluster but got a %T", newObj))
+	}
+
+	return wh.validate(newAttachedCluster)
+}
+
+func (wh *AttachedClusterWebhook) ValidateDelete(_ context.Context, obj runtime.Object) error {
+	return nil
+}
diff --git a/pkg/webhooks/validation_utils.go b/pkg/webhooks/validation_utils.go
index 3e59271..ccab75d 100644
--- a/pkg/webhooks/validation_utils.go
+++ b/pkg/webhooks/validation_utils.go
@@ -46,7 +46,7 @@ func validateDNS1123Label(label string, fldPath *field.Path) field.ErrorList {
 }
 
 // validateDNS1123Domain checks if a string is a valid DNS1123 domain.
-// A lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-',
+// A lowercase RFC 1123 domain must consist of lower case alphanumeric characters, '-' or '.'
 // must start and end with an alphanumeric character,
 // and must have a maximum length of 253 characters.
 func validateDNS1123Domain(domain string, fldPath *field.Path) field.ErrorList {


Test patch:
diff --git a/pkg/webhooks/attachedcluster_webhook_test.go b/pkg/webhooks/attachedcluster_webhook_test.go
new file mode 100644
index 0000000..6b59b5b
--- /dev/null
+++ b/pkg/webhooks/attachedcluster_webhook_test.go
@@ -0,0 +1,73 @@
+/*
+Copyright Kurator Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package webhooks
+
+import (
+	"io/fs"
+	"os"
+	"path"
+	"path/filepath"
+	"testing"
+
+	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/assert"
+	"sigs.k8s.io/yaml"
+
+	"kurator.dev/kurator/pkg/apis/cluster/v1alpha1"
+)
+
+func TestInvalidAttachedClusterValidation(t *testing.T) {
+	r := path.Join("testdata", "attachedcluster")
+	caseNames := make([]string, 0)
+	err := filepath.WalkDir(r, func(path string, d fs.DirEntry, err error) error {
+		if d.IsDir() {
+			return nil
+		}
+
+		caseNames = append(caseNames, path)
+
+		return nil
+	})
+	assert.NoError(t, err)
+
+	wh := &AttachedClusterWebhook{}
+	for _, tt := range caseNames {
+		t.Run(tt, func(t *testing.T) {
+			g := NewWithT(t)
+			c, err := readAttachedCluster(tt)
+			g.Expect(err).NotTo(HaveOccurred())
+
+			err = wh.validate(c)
+			g.Expect(err).To(HaveOccurred())
+			t.Logf("%v", err)
+		})
+	}
+}
+
+func readAttachedCluster(filename string) (*v1alpha1.AttachedCluster, error) {
+	b, err := os.ReadFile(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	c := &v1alpha1.AttachedCluster{}
+	if err := yaml.Unmarshal(b, c); err != nil {
+		return nil, err
+	}
+
+	return c, nil
+}
diff --git a/pkg/webhooks/testdata/attachedcluster/invalid-kubeconfig-name.yaml b/pkg/webhooks/testdata/attachedcluster/invalid-kubeconfig-name.yaml
new file mode 100644
index 0000000..e8dd6cd
--- /dev/null
+++ b/pkg/webhooks/testdata/attachedcluster/invalid-kubeconfig-name.yaml
@@ -0,0 +1,9 @@
+apiVersion: cluster.kurator.dev/v1alpha1
+kind: AttachedCluster
+metadata:
+  name: kurator-member1
+  namespace: default
+spec:
+  kubeconfig:
+    name: kurator-member1/a
+    key: kurator-member1.config
diff --git a/pkg/webhooks/testdata/attachedcluster/miss-kubeconfig-key.yaml b/pkg/webhooks/testdata/attachedcluster/miss-kubeconfig-key.yaml
new file mode 100644
index 0000000..00f3cb7
--- /dev/null
+++ b/pkg/webhooks/testdata/attachedcluster/miss-kubeconfig-key.yaml
@@ -0,0 +1,8 @@
+apiVersion: cluster.kurator.dev/v1alpha1
+kind: AttachedCluster
+metadata:
+  name: kurator-member1
+  namespace: default
+spec:
+  kubeconfig:
+    name: kurator-member1