Repository: kubeclipper/kubeclipper
Pull request #651 (2023-08-08 10:11:44)

Modify kc-server boot initialization iam data (#649)
<!-- Thanks for sending a pull request! Here are some tips for you:

1. If you want **faster** PR reviews, read how: https://github.com/kubeclipper/community/blob/master/developer-guide/development/the-pr-author-guide-to-getting-through-code-review.md
2. In case you want to know how your PR got reviewed, read: https://github.com/kubeclipper/community/blob/master/developer-guide/development/code-review-guide.md
3. Here are some coding convetions followed by Kubeclipper community: https://github.com/kubeclipper/community/blob/master/developer-guide/development/coding-conventions.md
-->

### What type of PR is this?
/kind bug
<!--
Add one of the following kinds:
/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind design

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->


### What this PR does / why we need it:
Modify the kc-server startup initialization iam data, the current initialization logic is not compatible with etcd failures.


### Which issue(s) this PR fixes:
<!--
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
-->
Fixes #

### Special notes for reviewers:
```
@x893675 @lixd 
```

### Does this PR introduced a user-facing change?
<!--
If no, just write "None" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

For more information on release notes see: https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md
-->
```release-note
None
```

### Additional documentation, usage docs, etc.:
<!--
This section can be blank if this pull request does not require a release note.
Please use the following format for linking documentation or pass the
section below:
- [KEP]: <link>
- [Usage]: <link>
- [Other doc]: <link>
-->
```docs

```
@x893675 @lixd 

kubeclipper-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeclipper/kubeclipper/pull/651#" title="Author self-approved">zhuzhenfan</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **wu-wenxiang** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @wu-wenxiang` in a comment when ready.

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

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

- **[OWNERS](https://github.com/kubeclipper/kubeclipper/blob/master/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":["wu-wenxiang"]} -->
kubeclipper-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubeclipper/kubeclipper/pull/651#pullrequestreview-1590608253" title="LGTM">lixd</a>*, *<a href="https://github.com/kubeclipper/kubeclipper/pull/651#" title="Author self-approved">zhuzhenfan</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **wu-wenxiang** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @wu-wenxiang` in a comment when ready.

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

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

- **[OWNERS](https://github.com/kubeclipper/kubeclipper/blob/master/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":["wu-wenxiang"]} -->
kubeclipper-bot: LGTM label has been added.  <details>Git tree hash: 0a2f18093de21ac2202048fade4707405521fccf</details>

Patch:
diff --git a/cmd/kcctl/app/options/options.go b/cmd/kcctl/app/options/options.go
index 2c56128..ea18f4a 100644
--- a/cmd/kcctl/app/options/options.go
+++ b/cmd/kcctl/app/options/options.go
@@ -34,8 +34,8 @@ import (
 	"github.com/kubeclipper/kubeclipper/pkg/utils/autodetection"
 	"github.com/kubeclipper/kubeclipper/pkg/utils/sliceutil"
 
+	"gopkg.in/yaml.v2"
 	"k8s.io/client-go/util/homedir"
-	"sigs.k8s.io/yaml"
 
 	"github.com/kubeclipper/kubeclipper/pkg/cli/utils"
 
diff --git a/pkg/apis/core/v1/handler.go b/pkg/apis/core/v1/handler.go
index d9f4df8..0afedb6 100644
--- a/pkg/apis/core/v1/handler.go
+++ b/pkg/apis/core/v1/handler.go
@@ -953,6 +953,13 @@ func (h *handler) getClusterMetadata(ctx context.Context, c *v1.Cluster, skipNod
 		CNINamespace:       c.CNI.Namespace,
 	}
 
+	if c.Annotations != nil {
+		_, ok := c.Annotations[common.AnnotationOnlyInstallKubernetesComp]
+		if ok {
+			meta.OnlyInstallKubernetesComp = true
+		}
+	}
+
 	meta.Addons = append(meta.Addons, c.Addons...)
 
 	masters, err := h.getNodeInfo(ctx, c.Masters, skipNodeNotFound)
diff --git a/pkg/apis/core/v1/utils.go b/pkg/apis/core/v1/utils.go
index 3b345b3..0efd06d 100644
--- a/pkg/apis/core/v1/utils.go
+++ b/pkg/apis/core/v1/utils.go
@@ -131,11 +131,13 @@ func (h *handler) parseOperationFromCluster(extraMetadata *component.ExtraMetada
 		steps = append(steps, k8sSteps...)
 	}
 
-	addonSteps, err := h.parseAddonStep(ctx, c, carr, action)
-	if err != nil {
-		return nil, err
+	if !extraMetadata.OnlyInstallKubernetesComp {
+		addonSteps, err := h.parseAddonStep(ctx, c, carr, action)
+		if err != nil {
+			return nil, err
+		}
+		steps = append(steps, addonSteps...)
 	}
-	steps = append(steps, addonSteps...)
 
 	if action == v1.ActionUninstall {
 		steps = append(steps, k8sSteps...)
diff --git a/pkg/cli/create/create.go b/pkg/cli/create/create.go
index 72e4690..f1fdd1b 100644
--- a/pkg/cli/create/create.go
+++ b/pkg/cli/create/create.go
@@ -19,7 +19,18 @@
 package create
 
 import (
+	"context"
+	"os"
+
+	"github.com/kubeclipper/kubeclipper/pkg/cli/logger"
+	"github.com/kubeclipper/kubeclipper/pkg/scheme/common"
+
 	"github.com/spf13/cobra"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
+
+	"github.com/kubeclipper/kubeclipper/pkg/scheme"
+	corev1 "github.com/kubeclipper/kubeclipper/pkg/scheme/core/v1"
 
 	"github.com/kubeclipper/kubeclipper/cmd/kcctl/app/options"
 	"github.com/kubeclipper/kubeclipper/pkg/cli/printer"
@@ -41,7 +52,67 @@ const (
   kcctl create role --name cluster_viewer --rules=role-template-view-clusters
 
   # Create user with required parameters
-  kcctl create user --name simple-user --role=platform-view --password 123456 --phone 10086 --email simple@example.com`
+  kcctl create user --name simple-user --role=platform-view --password 123456 --phone 10086 --email simple@example.com
+  
+  # Create cluster use cluster.yaml. 
+  kcctl create -f cluster.yaml
+
+cluster.yaml example:
+
+kind: Cluster
+apiVersion: core.kubeclipper.io/v1
+metadata:
+  annotations:
+    kubeclipper.io/offline: "true"
+    #kubeclipper.io/only-install-kubernetes-component: "true"
+  name: test
+kubernetesVersion: v1.27.4
+localRegistry: ""
+masters:
+  - id: 088885e3-4098-413d-a7e7-39adf0ffa95f
+#    labels:
+#      test: "1234"
+#    taints:
+#      - key: node-role.kubernetes.io/control-plane
+#        value: ""
+#        effect: NoSchedule
+workers: []
+certSans: []
+#featureGates:
+#  xxx: true
+cni:
+  calico:
+    IPManger: true
+    IPv4AutoDetection: first-found
+    IPv6AutoDetection: first-found
+    mode: Overlay-Vxlan-All
+    mtu: 1440
+  criType: containerd
+  localRegistry: ""
+  namespace: calico-system
+  offline: true
+  type: calico
+  version: v3.26.1
+containerRuntime:
+  type: containerd
+  version: 1.6.4
+etcd:
+  dataDir: /var/lib/etcd
+kubeProxy: {}
+kubelet:
+  ipAsName: false
+  rootDir: /var/lib/kubelet
+networking:
+  dnsDomain: cluster.local
+  ipFamily: IPv4
+  pods:
+    cidrBlocks:
+      - "172.25.0.0/16"
+  proxyMode: ipvs
+  services:
+    cidrBlocks:
+      - "10.96.0.0/12"
+  workerNodeVip: 169.254.169.100`
 )
 
 type BaseOptions struct {
@@ -76,6 +147,7 @@ func NewCmdCreate(streams options.IOStreams) *cobra.Command {
 		Example:               createExample,
 		Run: func(cmd *cobra.Command, args []string) {
 			utils.CheckErr(o.Complete(o.CliOpts))
+			utils.CheckErr(o.Run(cmd))
 		},
 	}
 	cmd.Flags().StringVarP(&o.Filename, "filename", "f", "", "use resource file to create")
@@ -99,3 +171,97 @@ func (l *CreateOptions) Complete(opts *options.CliOptions) error {
 	l.Client = c
 	return nil
 }
+
+func (l *CreateOptions) Run(cmd *cobra.Command) error {
+	rData, err := os.ReadFile(l.Filename)
+	if err != nil {
+		return err
+	}
+	obj := unstructured.Unstructured{}
+	dec := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme)
+	objRet, gvk, err := dec.Decode(rData, nil, &obj)
+	if err != nil {
+		return err
+	}
+	switch gvk.GroupKind().String() {
+	case corev1.Kind(corev1.KindCluster).String():
+		cluster := &corev1.Cluster{}
+		if err := scheme.Scheme.Convert(objRet, cluster, nil); err != nil {
+			return err
+		}
+		l.MergeDefaultCluster(cluster)
+		if err := l.ValidateCluster(cmd, cluster); err != nil {
+			return err
+		}
+		cluster.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind(corev1.KindCluster))
+		resp, err := l.Client.CreateCluster(context.TODO(), cluster)
+		if err != nil {
+			return err
+		}
+
+		return l.PrintFlags.Print(resp, l.IOStreams.Out)
+	}
+
+	return nil
+}
+
+func (l *CreateOptions) MergeDefaultCluster(cluster *corev1.Cluster) {
+	if cluster.Annotations == nil {
+		cluster.Annotations = make(map[string]string)
+	}
+	if cluster.Labels == nil {
+		cluster.Labels = make(map[string]string)
+	}
+	_, ok := cluster.Annotations[common.AnnotationOffline]
+	if !ok {
+		cluster.Annotations[common.AnnotationOffline] = "true"
+	}
+	if cluster.Etcd.DataDir == "" {
+		cluster.Etcd.DataDir = "/var/lib/etcd"
+	}
+	if cluster.Kubelet.RootDir == "" {
+		cluster.Kubelet.RootDir = "/var/lib/kubelet"
+	}
+	if len(cluster.Networking.Pods.CIDRBlocks) == 0 {
+		cluster.Networking.Pods.CIDRBlocks = []string{"172.25.0.0/16"}
+	}
+	if len(cluster.Networking.Services.CIDRBlocks) == 0 {
+		cluster.Networking.Services.CIDRBlocks = []string{"10.96.0.0/12"}
+	}
+	if cluster.Networking.DNSDomain == "" {
+		cluster.Networking.DNSDomain = "cluster.local"
+	}
+	if cluster.Networking.IPFamily == "" {
+		cluster.Networking.IPFamily = corev1.IPFamilyIPv4
+	}
+	if cluster.Networking.WorkerNodeVip == "" {
+		cluster.Networking.WorkerNodeVip = "169.254.169.100"
+	}
+}
+
+func (l *CreateOptions) ValidateCluster(cmd *cobra.Command, cluster *corev1.Cluster) error {
+	_, offline := cluster.Annotations[common.AnnotationOffline]
+	_, onlyInstallK8sComp := cluster.Annotations[common.AnnotationOnlyInstallKubernetesComp]
+	if !allowedCRI.Has(cluster.ContainerRuntime.Type) {
+		return utils.UsageErrorf(cmd, "unsupported cri,support %v now", allowedCRI.List())
+	}
+	allowedCRIVersion := getComponentAvailableVersions(l.Client, offline, cluster.ContainerRuntime.Type)
+	if !allowedCRIVersion.Has(cluster.ContainerRuntime.Version) {
+		return utils.UsageErrorf(cmd, "unsupported cri version,support %v now", allowedCRIVersion.UnsortedList())
+	}
+	allowedK8sVersion := getComponentAvailableVersions(l.Client, offline, "k8s")
+	if !allowedK8sVersion.Has(cluster.KubernetesVersion) {
+		return utils.UsageErrorf(cmd, "unsupported kubernetes version,support %v now", allowedK8sVersion.UnsortedList())
+	}
+	if !allowedCNI.Has(cluster.CNI.Type) {
+		logger.V(2).Infof("unsupported cni %s,support %s now", cluster.CNI.Type, allowedCNI)
+		return nil
+	}
+	if !onlyInstallK8sComp {
+		allCNIVersion := getComponentAvailableVersions(l.Client, offline, cluster.CNI.Type)
+		if !allCNIVersion.Has(cluster.CNI.Version) {
+			return utils.UsageErrorf(cmd, "unsupported cni version,support %v now", allCNIVersion.UnsortedList())
+		}
+	}
+	return nil
+}
diff --git a/pkg/cli/create/create_cluster.go b/pkg/cli/create/create_cluster.go
index 38f9cbd..a52d2c2 100644
--- a/pkg/cli/create/create_cluster.go
+++ b/pkg/cli/create/create_cluster.go
@@ -25,6 +25,7 @@ import (
 	"fmt"
 	"net"
 	"os"
+	"strconv"
 	"strings"
 
 	"github.com/kubeclipper/kubeclipper/pkg/constatns"
@@ -114,26 +115,29 @@ IP6_AUTODETECTION_METHOD=can-reach=www.google.com`
 
 type CreateClusterOptions struct {
 	BaseOptions
-	Masters            []string
-	Workers            []string
-	UntaintMaster      bool
-	Offline            bool
-	LocalRegistry      string
-	InsecureRegistries []string
-	CRI                string
-	CRIVersion         string
-	K8sVersion         string
-	CNI                string
-	CNIVersion         string
-	Name               string
-	createdByIP        bool
-	CertSans           []string
-	CaCertFile         string
-	CaKeyFile          string
-	DNSDomain          string
-	IPv4AutoDetection  string
-	ServiceSubnet      string
-	PodSubnet          string
+	Masters                   []string
+	Workers                   []string
+	UntaintMaster             bool
+	Offline                   bool
+	LocalRegistry             string
+	InsecureRegistries        []string
+	CRI                       string
+	CRIVersion                string
+	K8sVersion                string
+	CNI                       string
+	CNIVersion                string
+	Name                      string
+	createdByIP               bool
+	CertSans                  []string
+	CaCertFile                string
+	CaKeyFile                 string
+	DNSDomain                 string
+	IPv4AutoDetection         string
+	ServiceSubnet             string
+	PodSubnet                 string
+	OnlyInstallKubernetesComp bool
+	FeatureGatesString        []string
+	FeatureGates              map[string]bool
 }
 
 var (
@@ -148,12 +152,16 @@ func NewCreateClusterOptions(streams options.IOStreams) *CreateClusterOptions {
 			CliOpts:    options.NewCliOptions(),
 			IOStreams:  streams,
 		},
-		UntaintMaster: false,
-		Offline:       true,
-		CRI:           "containerd",
-		CNI:           "calico",
-		createdByIP:   false,
-		DNSDomain:     "cluster.local",
+		UntaintMaster:     false,
+		Offline:           true,
+		CRI:               "containerd",
+		CNI:               "calico",
+		createdByIP:       false,
+		DNSDomain:         "cluster.local",
+		IPv4AutoDetection: autodetection.MethodFirst,
+		ServiceSubnet:     "10.96.0.0/12",
+		PodSubnet:         "172.25.0.0/16",
+		FeatureGates:      map[string]bool{},
 	}
 }
 
@@ -192,6 +200,8 @@ func NewCmdCreateCluster(streams options.IOStreams) *cobra.Command {
 	cmd.Flags().StringVar(&o.IPv4AutoDetection, "calico.ipv4-auto-detection", o.IPv4AutoDetection, fmt.Sprintf("node ipv4 auto detection. \n%s", IPDetectDescription))
 	cmd.Flags().StringVar(&o.ServiceSubnet, "service-subnet", o.ServiceSubnet, "serviceSubnet is the subnet used by Kubernetes Services. Defaults to '10.96.0.0/12'")
 	cmd.Flags().StringVar(&o.PodSubnet, "pod-subnet", o.PodSubnet, "podSubnet is the subnet used by Pods. Defaults to '172.25.0.0/16'")
+	cmd.Flags().BoolVar(&o.OnlyInstallKubernetesComp, "only-install-kubernetes-component", o.OnlyInstallKubernetesComp, "only install kubernetes component, not install cni")
+	cmd.Flags().StringSliceVar(&o.FeatureGatesString, "feature-gates", o.FeatureGatesString, "k8s feature gates, format as: --feature-gates=xxx=true|false")
 	o.CliOpts.AddFlags(cmd.Flags())
 	o.PrintFlags.AddFlags(cmd)
 
@@ -325,6 +335,23 @@ func (l *CreateClusterOptions) ValidateArgs(cmd *cobra.Command) error {
 		l.CaCertFile = base64.StdEncoding.EncodeToString(caCert)
 		l.CaKeyFile = base64.StdEncoding.EncodeToString(caKey)
 	}
+
+	for _, fg := range l.FeatureGatesString {
+		if strings.Contains(fg, "=") {
+			arr := strings.Split(fg, "=")
+			if len(arr) != 2 {
+				continue
+			}
+			k := strings.TrimSpace(arr[0])
+			v := strings.TrimSpace(arr[1])
+			boolValue, err := strconv.ParseBool(v)
+			if err != nil {
+				return fmt.Errorf("invalid value %v for feature-gate key: %s, use true|false instead", v, k)
+			}
+			l.FeatureGates[k] = boolValue
+		}
+	}
+
 	return nil
 }
 
@@ -382,6 +409,9 @@ func (l *CreateClusterOptions) newCluster() *v1.Cluster {
 	if l.Offline {
 		annotations[common.AnnotationOffline] = ""
 	}
+	if l.OnlyInstallKubernetesComp {
+		annotations[common.AnnotationOnlyInstallKubernetesComp] = "true"
+	}
 
 	c := &v1.Cluster{
 		TypeMeta: metav1.TypeMeta{
@@ -424,6 +454,7 @@ func (l *CreateClusterOptions) newCluster() *v1.Cluster {
 				MTU:               1440,
 			},
 		},
+		FeatureGates: l.FeatureGates,
 
 		Status: v1.ClusterStatus{},
 	}
diff --git a/pkg/cli/create/utils.go b/pkg/cli/create/utils.go
new file mode 100644
index 0000000..580ea1f
--- /dev/null
+++ b/pkg/cli/create/utils.go
@@ -0,0 +1,31 @@
+package create
+
+import (
+	"context"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+
+	"github.com/kubeclipper/kubeclipper/pkg/cli/logger"
+	"github.com/kubeclipper/kubeclipper/pkg/simple/client/kc"
+)
+
+func getComponentAvailableVersions(client *kc.Client, offline bool, component string) sets.Set[string] {
+	var metas *kc.ComponentMeta
+	var err error
+	if offline {
+		metas, err = client.GetComponentMeta(context.TODO(), map[string][]string{"online": {"false"}})
+	} else {
+		metas, err = client.GetComponentMeta(context.TODO(), map[string][]string{"online": {"true"}})
+	}
+	if err != nil {
+		logger.Errorf("get component meta failed: %s. please check .kc/config", err)
+		return nil
+	}
+	set := sets.New[string]()
+	for _, resource := range metas.Addons {
+		if resource.Name == component {
+			set.Insert(resource.Version)
+		}
+	}
+	return set
+}
diff --git a/pkg/cli/join/join.go b/pkg/cli/join/join.go
index 1ae603d..f6c3b28 100644
--- a/pkg/cli/join/join.go
+++ b/pkg/cli/join/join.go
@@ -22,10 +22,13 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"os"
 	"path"
 	"path/filepath"
 	"text/template"
 
+	"gopkg.in/yaml.v2"
+
 	"github.com/google/uuid"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
@@ -72,6 +75,35 @@ const (
   # Add multiple agent nodes and config float ip.
   kcctl join --agent 192.168.10.123,192.168.10.124 --float-ip 192.168.10.123:172.20.149.199 --float-ip 192.168.10.124:172.20.149.200
 
+  # Add agent nodes use config file. join config example:
+ssh:
+  user: root
+  password: "0000"
+  pkFile: ""
+  privateKey: ""
+  pkPassword: ""
+  port: 22
+  connectionTimeout: 1m0s
+#	MethodFirst     = "first-found"
+#	MethodInterface = "interface="
+#	MethodCidr      = "cidr="
+#	MethodCanReach  = "can-reach="
+ipDetect: first-found
+nodeIPDetect: first-found
+agents:
+  192.168.234.41:
+    #region: default
+    #floatIP:
+    #proxyServer:
+    #proxyAPIServer:
+    #proxySSH:
+  192.168.234.42:
+    #region: default2
+    #floatIP:
+    #proxyServer:
+    #proxyAPIServer:
+    #proxySSH:
+  kcctl join --join-config join-config.yaml
   Please read 'kcctl join -h' get more deploy flags`
 )
 
@@ -89,9 +121,18 @@ type JoinOptions struct {
 
 	sshConfig *sshutils.SSH
 
+	joinConfigPath string
+
 	Pkg string `json:"pkg" yaml:"pkg,omitempty"`
 }
 
+type JoinConfig struct {
+	Agents       options.Agents `json:"agents,omitempty" yaml:"agents,omitempty"`
+	IPDetect     string         `json:"ipDetect,omitempty" yaml:"ipDetect,omitempty"`
+	NodeIPDetect string         `json:"nodeIPDetect,omitempty" yaml:"nodeIPDetect,omitempty"`
+	SSHConfig    *sshutils.SSH  `json:"ssh,omitempty" yaml:"ssh,omitempty"`
+}
+
 func NewJoinOptions(streams options.IOStreams) *JoinOptions {
 	return &JoinOptions{
 		cliOpts:      options.NewCliOptions(),
@@ -126,16 +167,50 @@ func NewCmdJoin(streams options.IOStreams) *cobra.Command {
 	cmd.Flags().StringArrayVar(&o.agents, "agent", o.agents, "join agent node.")
 	cmd.Flags().StringArrayVar(&o.floatIPs, "float-ip", o.floatIPs, "Kc agent ip and float ip.")
 	cmd.Flags().StringVar(&o.Pkg, "pkg", o.Pkg, "Package resource url (path or http url). Default is inherited from the deploy config.")
+	cmd.Flags().StringVar(&o.joinConfigPath, "join-config", "", "path to the join config file to use for join")
+
 	options.AddFlagsToSSH(o.sshConfig, cmd.Flags())
-	utils.CheckErr(cmd.MarkFlagRequired("agent"))
 	return cmd
 }
 
+func readJoinConfig(path string) (*JoinConfig, error) {
+	joinConfig := &JoinConfig{}
+	fData, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	err = yaml.Unmarshal(fData, joinConfig)
+	return joinConfig, err
+}
+
 func (c *JoinOptions) Complete() error {
 	var err error
-	if c.parseAgent, err = deploy.BuildAgent(c.agents, c.floatIPs, c.deployConfig.DefaultRegion); err != nil {
-		return err
+	if c.joinConfigPath != "" {
+		joinConfig, err := readJoinConfig(c.joinConfigPath)
+		if err != nil {
+			return err
+		}
+		if len(joinConfig.Agents) == 0 {
+			return fmt.Errorf("join-config must specify at least one agent node")
+		}
+		if joinConfig.Agents != nil {
+			c.parseAgent = joinConfig.Agents
+		}
+		if joinConfig.IPDetect != "" {
+			c.ipDetect = joinConfig.IPDetect
+		}
+		if joinConfig.NodeIPDetect != "" {
+			c.nodeIPDetect = joinConfig.NodeIPDetect
+		}
+		if joinConfig.SSHConfig != nil {
+			c.sshConfig = joinConfig.SSHConfig
+		}
+	} else {
+		if c.parseAgent, err = deploy.BuildAgent(c.agents, c.floatIPs, c.deployConfig.DefaultRegion); err != nil {
+			return err
+		}
 	}
+
 	// config Complete
 	if err = c.cliOpts.Complete(); err != nil {
 		return err
@@ -197,7 +272,7 @@ func (c *JoinOptions) ValidateArgs() error {
 	if c.nodeIPDetect != "" && !autodetection.CheckMethod(c.nodeIPDetect) {
 		return fmt.Errorf("invalid node ip detect method,suppot [first-found,interface=xxx,cidr=xxx] now")
 	}
-	if len(c.agents) == 0 {
+	if len(c.parseAgent) == 0 {
 		return fmt.Errorf("must specified at least one agent node")
 	}
 	if len(c.deployConfig.ServerIPs) == 0 {
diff --git a/pkg/component/ctx.go b/pkg/component/ctx.go
index 71f6e1b..3262477 100644
--- a/pkg/component/ctx.go
+++ b/pkg/component/ctx.go
@@ -41,21 +41,22 @@ type (
 )
 
 type ExtraMetadata struct {
-	Masters            NodeList
-	Workers            NodeList
-	ClusterStatus      v1.ClusterPhase
-	Offline            bool
-	LocalRegistry      string
-	CRI                string
-	ClusterName        string
-	KubeVersion        string
-	OperationID        string
-	OperationType      string
-	KubeletDataDir     string
-	ControlPlaneStatus []v1.ControlPlaneHealth
-	Addons             []v1.Addon
-	CNI                string
-	CNINamespace       string
+	Masters                   NodeList
+	Workers                   NodeList
+	ClusterStatus             v1.ClusterPhase
+	Offline                   bool
+	LocalRegistry             string
+	CRI                       string
+	ClusterName               string
+	KubeVersion               string
+	OperationID               string
+	OperationType             string
+	KubeletDataDir            string
+	ControlPlaneStatus        []v1.ControlPlaneHealth
+	Addons                    []v1.Addon
+	CNI                       string
+	CNINamespace              string
+	OnlyInstallKubernetesComp bool
 }
 
 type Node struct {
@@ -88,17 +89,23 @@ func (l NodeList) AvailableKubeMasters() (NodeList, error) {
 
 func (l NodeList) ReachableNodes(protocol string, port int, timeout time.Duration) NodeList {
 	wg := sync.WaitGroup{}
-	wg.Add(len(l))
-	var list NodeList
+	ch := make(chan Node, len(l))
+	defer close(ch)
 	for _, no := range l {
+		wg.Add(1)
 		go func(no Node) {
-			if netutil.Reachable(protocol, no.NodeIPv4+":"+strconv.Itoa(port), timeout) == nil {
-				list = append(list, no)
+			defer wg.Done()
+			if netutil.Reachable(protocol, no.IPv4+":"+strconv.Itoa(port), timeout) == nil {
+				ch <- no
 			}
-			wg.Done()
 		}(no)
 	}
 	wg.Wait()
+	var list NodeList
+	length := len(ch)
+	for i := 0; i < length; i++ {
+		list = append(list, <-ch)
+	}
 	return list
 }
 
diff --git a/pkg/scheme/common/well_known_labels.go b/pkg/scheme/common/well_known_labels.go
index dc9034d..96ca617 100644
--- a/pkg/scheme/common/well_known_labels.go
+++ b/pkg/scheme/common/well_known_labels.go
@@ -68,6 +68,9 @@ const (
 	AnnotationMetadataProxyServer    = "metadata.kubeclipper.io/proxyServer"
 	AnnotationMetadataProxyAPIServer = "metadata.kubeclipper.io/proxyAPIServer"
 	AnnotationMetadataProxySSH       = "metadata.kubeclipper.io/proxySSH"
+
+	// AnnotationOnlyInstallKubernetesComp mean not install cni when create cluster
+	AnnotationOnlyInstallKubernetesComp = "kubeclipper.io/only-install-kubernetes-component"
 )
 
 type NodeRole string // master/worker/ingress(worker)
diff --git a/pkg/scheme/core/v1/cluster_types.go b/pkg/scheme/core/v1/cluster_types.go
index 500e075..e65cee6 100644
--- a/pkg/scheme/core/v1/cluster_types.go
+++ b/pkg/scheme/core/v1/cluster_types.go
@@ -57,6 +57,7 @@ type Cluster struct {
 	Description       string             `json:"description,omitempty" optional:"true"`
 	Status            ClusterStatus      `json:"status,omitempty" optional:"true"`
 	PendingOperations []PendingOperation `json:"pendingOperations,omitempty" optional:"true"`
+	FeatureGates      map[string]bool    `json:"featureGates,omitempty"`
 }
 
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
diff --git a/pkg/scheme/core/v1/k8s/cluster.go b/pkg/scheme/core/v1/k8s/cluster.go
index 21c7791..97afffe 100644
--- a/pkg/scheme/core/v1/k8s/cluster.go
+++ b/pkg/scheme/core/v1/k8s/cluster.go
@@ -149,6 +149,7 @@ func (stepper *Upgrade) InitStepper(metadata *component.ExtraMetadata, c *v1.Clu
 		ControlPlaneEndpoint:    cpEndpoint,
 		CertSANs:                c.GetAllCertSANs(),
 		LocalRegistry:           c.LocalRegistry,
+		FeatureGates:            c.FeatureGates,
 	}
 	stepper.Offline = metadata.Offline
 	stepper.Version = metadata.KubeVersion
diff --git a/pkg/scheme/core/v1/k8s/kubeadm.go b/pkg/scheme/core/v1/k8s/kubeadm.go
index bc6ca23..6713920 100644
--- a/pkg/scheme/core/v1/k8s/kubeadm.go
+++ b/pkg/scheme/core/v1/k8s/kubeadm.go
@@ -105,22 +105,23 @@ type KubeadmConfig struct {
 	ClusterConfigAPIVersion string `json:"clusterConfigAPIVersion"`
 	// If both Docker and containerd are detected, Docker takes precedence,so we must specify cri.
 	// https://v1-20.docs.kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/#installing-runtime
-	ContainerRuntime     string        `json:"containerRuntime"`
-	Etcd                 v1.Etcd       `json:"etcd"`
-	Networking           v1.Networking `json:"networking"`
-	KubeProxy            v1.KubeProxy  `json:"kubeProxy"`
-	Kubelet              v1.Kubelet    `json:"kubelet"`
-	ClusterName          string        `json:"clusterName"`
-	KubernetesVersion    string        `json:"kubernetesVersion"`
-	ControlPlaneEndpoint string        `json:"controlPlaneEndpoint"`
-	CertSANs             []string      `json:"certSANs"`
-	LocalRegistry        string        `json:"localRegistry"`
-	Offline              bool          `json:"offline"`
-	IsControlPlane       bool          `json:"isControlPlane,omitempty"`
-	CACertHashes         string        `json:"caCertHashes,omitempty"`
-	BootstrapToken       string        `json:"bootstrapToken,omitempty"`
-	CertificateKey       string        `json:"certificateKey,omitempty"`
-	AdvertiseAddress     string        `json:"advertiseAddress,omitempty"`
+	ContainerRuntime     string          `json:"containerRuntime"`
+	Etcd                 v1.Etcd         `json:"etcd"`
+	Networking           v1.Networking   `json:"networking"`
+	KubeProxy            v1.KubeProxy    `json:"kubeProxy"`
+	Kubelet              v1.Kubelet      `json:"kubelet"`
+	ClusterName          string          `json:"clusterName"`
+	KubernetesVersion    string          `json:"kubernetesVersion"`
+	ControlPlaneEndpoint string          `json:"controlPlaneEndpoint"`
+	CertSANs             []string        `json:"certSANs"`
+	LocalRegistry        string          `json:"localRegistry"`
+	Offline              bool            `json:"offline"`
+	IsControlPlane       bool            `json:"isControlPlane,omitempty"`
+	CACertHashes         string          `json:"caCertHashes,omitempty"`
+	BootstrapToken       string          `json:"bootstrapToken,omitempty"`
+	CertificateKey       string          `json:"certificateKey,omitempty"`
+	AdvertiseAddress     string          `json:"advertiseAddress,omitempty"`
+	FeatureGates         map[string]bool `json:"featureGates,omitempty"`
 }
 
 type ControlPlane struct {
diff --git a/pkg/scheme/core/v1/k8s/kubeadm_step.go b/pkg/scheme/core/v1/k8s/kubeadm_step.go
index 22b200f..4318472 100644
--- a/pkg/scheme/core/v1/k8s/kubeadm_step.go
+++ b/pkg/scheme/core/v1/k8s/kubeadm_step.go
@@ -211,6 +211,17 @@ func (runnable *Runnable) makeInstallSteps(metadata *component.ExtraMetadata) ([
 		installSteps = append(installSteps, steps...)
 	}
 
+	// when disable cni, only install k8s control plane and kubelet
+	// user can install other cni and other plugin manually
+	if metadata.OnlyInstallKubernetesComp {
+		steps, err = PatchTaintAndLabelStep(runnable.Masters, runnable.Workers, metadata)
+		if err != nil {
+			return nil, err
+		}
+		installSteps = append(installSteps, steps...)
+		return installSteps, nil
+	}
+
 	//cn := CNIInfo{}
 	cf, err := cni.Load(c.CNI.Type)
 	if err != nil {
@@ -419,6 +430,7 @@ func (stepper *KubeadmConfig) InitStepper(c *v1.Cluster, metadata *component.Ext
 	stepper.CertSANs = c.GetAllCertSANs()
 	stepper.LocalRegistry = c.LocalRegistry
 	stepper.Offline = metadata.Offline
+	stepper.FeatureGates = c.FeatureGates
 	// TODO: No vip is currently introduced as controlPlaneEndpoint
 	stepper.AdvertiseAddress = metadata.Masters[0].NodeIPv4
 
diff --git a/pkg/scheme/core/v1/k8s/template.go b/pkg/scheme/core/v1/k8s/template.go
index 3fddda9..c77bff3 100644
--- a/pkg/scheme/core/v1/k8s/template.go
+++ b/pkg/scheme/core/v1/k8s/template.go
@@ -61,6 +61,9 @@ scheduler:
     pathType: File
 {{with .LocalRegistry}}imageRepository: {{.}}{{end}}
 {{with .ClusterName}}clusterName: {{.}}{{end}}
+{{if gt (len .FeatureGates) 0}}
+featureGates:{{range $key,$value := .FeatureGates}}
+  {{$key}}: {{$value}}{{end}}{{end}}
 ---
 kind: KubeProxyConfiguration
 apiVersion: kubeproxy.config.k8s.io/v1alpha1
diff --git a/pkg/scheme/core/v1/zz_generated.deepcopy.go b/pkg/scheme/core/v1/zz_generated.deepcopy.go
index c866936..e1dbc69 100644
--- a/pkg/scheme/core/v1/zz_generated.deepcopy.go
+++ b/pkg/scheme/core/v1/zz_generated.deepcopy.go
@@ -439,6 +439,13 @@ func (in *Cluster) DeepCopyInto(out *Cluster) {
 			(*in)[i].DeepCopyInto(&(*out)[i])
 		}
 	}
+	if in.FeatureGates != nil {
+		in, out := &in.FeatureGates, &out.FeatureGates
+		*out = make(map[string]bool, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
 	return
 }
 
diff --git a/pkg/server/config.go b/pkg/server/config.go
index 3b4a4dd..a9feecf 100644
--- a/pkg/server/config.go
+++ b/pkg/server/config.go
@@ -9,7 +9,58 @@ import (
 	iamv1 "github.com/kubeclipper/kubeclipper/pkg/scheme/iam/v1"
 )
 
-var Roles = []iamv1.GlobalRole{
+type GlobalRoleList []iamv1.GlobalRole
+
+type GlobalRoleBindingList []iamv1.GlobalRoleBinding
+
+type UserList []iamv1.User
+
+func (receiver *GlobalRoleList) Diff(diffList *iamv1.GlobalRoleList) GlobalRoleList {
+	result := make(map[string]iamv1.GlobalRole)
+	for _, v := range diffList.Items {
+		result[v.Name] = v
+	}
+
+	list := GlobalRoleList{}
+	for _, v := range *receiver {
+		if _, ok := result[v.Name]; !ok {
+			list = append(list, v)
+		}
+	}
+	return list
+}
+
+func (receiver *GlobalRoleBindingList) Diff(diffList *iamv1.GlobalRoleBindingList) GlobalRoleBindingList {
+	result := make(map[string]iamv1.GlobalRoleBinding)
+	for _, v := range diffList.Items {
+		result[v.Name] = v
+	}
+
+	list := GlobalRoleBindingList{}
+	for _, v := range *receiver {
+		if _, ok := result[v.Name]; !ok {
+			list = append(list, v)
+		}
+	}
+	return list
+}
+
+func (receiver *UserList) Diff(diffList *iamv1.UserList) UserList {
+	result := make(map[string]iamv1.User)
+	for _, v := range diffList.Items {
+		result[v.Name] = v
+	}
+
+	list := UserList{}
+	for _, v := range *receiver {
+		if _, ok := result[v.Name]; !ok {
+			list = append(list, v)
+		}
+	}
+	return list
+}
+
+var Roles = GlobalRoleList{
 	{
 		TypeMeta: metav1.TypeMeta{
 			Kind:       iamv1.KindGlobalRole,
@@ -907,7 +958,7 @@ var Roles = []iamv1.GlobalRole{
 	},
 }
 
-var RoleBindings = []iamv1.GlobalRoleBinding{
+var RoleBindings = GlobalRoleBindingList{
 	{
 		TypeMeta: metav1.TypeMeta{
 			Kind:       "GlobalRoleBinding",
@@ -987,7 +1038,7 @@ var RoleBindings = []iamv1.GlobalRoleBinding{
 	},
 }
 
-func GetInternalUser(password string) []iamv1.User {
+func GetInternalUser(password string) UserList {
 	return []iamv1.User{
 		{
 			TypeMeta: metav1.TypeMeta{
diff --git a/pkg/server/server.go b/pkg/server/server.go
index e081520..0eec90f 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -342,12 +342,10 @@ func (s *APIServer) migrateRole(operator iam.Operator) error {
 	if err != nil {
 		return err
 	}
-	if len(result.Items) > 0 {
-		return nil
-	}
+	roles := Roles.Diff(result)
 
-	for index := range Roles {
-		if _, err = operator.CreateRole(context.TODO(), &Roles[index]); err != nil {
+	for _, val := range roles {
+		if _, err := operator.CreateRole(context.TODO(), &val); err != nil {
 			return err
 		}
 	}
@@ -359,12 +357,10 @@ func (s *APIServer) migrateRoleBinding(operator iam.Operator) error {
 	if err != nil {
 		return err
 	}
-	if len(result.Items) > 0 {
-		return nil
-	}
+	roleBindings := RoleBindings.Diff(result)
 
-	for index := range RoleBindings {
-		if _, err := operator.CreateRoleBinding(context.TODO(), &RoleBindings[index]); err != nil {
+	for _, val := range roleBindings {
+		if _, err := operator.CreateRoleBinding(context.TODO(), &val); err != nil {
 			return err
 		}
 	}
@@ -376,20 +372,19 @@ func (s *APIServer) migrateUser(operator iam.Operator) error {
 	if err != nil {
 		return err
 	}
-	if len(result.Items) > 0 {
-		return nil
-	}
 
-	users := GetInternalUser(s.Config.AuthenticationOptions.InitialPassword)
-	for index := range users {
-		encPass, err := hashutil.EncryptPassword(users[index].Spec.EncryptedPassword)
+	userList := GetInternalUser(s.Config.AuthenticationOptions.InitialPassword)
+	users := userList.Diff(result)
+
+	for _, val := range users {
+		encPass, err := hashutil.EncryptPassword(val.Spec.EncryptedPassword)
 		if err != nil {
 			return err
 		}
-		users[index].Spec.EncryptedPassword = encPass
+		val.Spec.EncryptedPassword = encPass
 		state := v1.UserActive
-		users[index].Status.State = &state
-		if _, err := operator.CreateUser(context.TODO(), &users[index]); err != nil {
+		val.Status.State = &state
+		if _, err := operator.CreateUser(context.TODO(), &val); err != nil {
 			return err
 		}
 	}
diff --git a/pkg/simple/client/kc/api.go b/pkg/simple/client/kc/api.go
index 5575a87..9462b32 100644
--- a/pkg/simple/client/kc/api.go
+++ b/pkg/simple/client/kc/api.go
@@ -50,6 +50,9 @@ const (
 	templatePath      = "/api/core.kubeclipper.io/v1/templates"
 	registryPath      = "/api/core.kubeclipper.io/v1/registries"
 	regionPath        = "/api/core.kubeclipper.io/v1/regions"
+
+	operationPath = "/api/core.kubeclipper.io/v1/operations"
+	logPath       = "/api/core.kubeclipper.io/v1/logs"
 )
 
 func (cli *Client) ListNodes(ctx context.Context, query Queries) (*NodesList, error) {
@@ -145,6 +148,43 @@ func (cli *Client) DescribeCluster(ctx context.Context, name string) (*ClustersL
 	return &clusters, err
 }
 
+func (cli *Client) ListOperation(ctx context.Context, query Queries) (*OperationList, error) {
+	serverResp, err := cli.get(ctx, operationPath, query.ToRawQuery(), nil)
+	defer ensureReaderClosed(serverResp)
+	if err != nil {
+		return nil, err
+	}
+
+	opList := OperationList{}
+	err = json.NewDecoder(serverResp.body).Decode(&opList)
+	return &opList, err
+}
+
+func (cli *Client) PrintLogs(ctx context.Context, operation v1.Operation) error {
+	fmt.Println("operation: ", operation.Name)
+	for _, step := range operation.Steps {
+		for _, node := range step.Nodes {
+			v := url.Values{}
+			v.Add("operation", operation.Name)
+			v.Add("node", node.ID)
+			v.Add("step", step.ID)
+			serverResp, err := cli.get(ctx, logPath, v, nil)
+			if err != nil {
+				return err
+			}
+			log := corev1.StepLog{}
+			err = json.NewDecoder(serverResp.body).Decode(&log)
+			ensureReaderClosed(serverResp)
+			if err != nil {
+				return err
+			}
+			fmt.Printf("step: %s-%s\n", step.Name, step.ID)
+			fmt.Printf("logstatus: %s, log: %s\n", log.Status, log.Content)
+		}
+	}
+	return nil
+}
+
 func (cli *Client) ListRoles(ctx context.Context, query Queries) (*RoleList, error) {
 	serverResp, err := cli.get(ctx, rolesPath, query.ToRawQuery(), nil)
 	defer ensureReaderClosed(serverResp)
diff --git a/pkg/simple/client/kc/scheme.go b/pkg/simple/client/kc/scheme.go
index 301dacf..e81ba43 100644
--- a/pkg/simple/client/kc/scheme.go
+++ b/pkg/simple/client/kc/scheme.go
@@ -289,3 +289,22 @@ func (n *TemplateList) TablePrint() ([]string, [][]string) {
 	}
 	return headers, data
 }
+
+var _ printer.ResourcePrinter = (*OperationList)(nil)
+
+type OperationList struct {
+	Items      []v1.Operation `json:"items" description:"paging data"`
+	TotalCount int            `json:"totalCount,omitempty" description:"total count"`
+}
+
+func (n *OperationList) YAMLPrint() ([]byte, error) {
+	panic("implement me")
+}
+
+func (n *OperationList) TablePrint() ([]string, [][]string) {
+	panic("implement me")
+}
+
+func (n *OperationList) JSONPrint() ([]byte, error) {
+	panic("implement me")
+}


Test patch:
diff --git a/.github/workflows/test-aio.yml b/.github/workflows/test-aio.yml
index 3bdf2e0..e873211 100644
--- a/.github/workflows/test-aio.yml
+++ b/.github/workflows/test-aio.yml
@@ -44,6 +44,8 @@ jobs:
           echo current version is ${current_branch}
           curl -sfL https://oss.kubeclipper.io/get-kubeclipper.sh | sudo KC_VERSION=${current_branch} KC_REGION=cn bash -
           sudo kcctl deploy --pkg https://oss.kubeclipper.io/release/${current_branch}/kc-amd64.tar.gz
+          sudo rm -rf /tmp/kc-amd64.tar.gz
+          sudo rm -rf /tmp/kc
       - name: update kc assembly
         run: |
           sudo cp ./dist/kcctl /usr/local/bin/
diff --git a/pkg/scheme/core/v1/k8s/kubeadm_test.go b/pkg/scheme/core/v1/k8s/kubeadm_test.go
index 8f64711..94562cf 100644
--- a/pkg/scheme/core/v1/k8s/kubeadm_test.go
+++ b/pkg/scheme/core/v1/k8s/kubeadm_test.go
@@ -52,6 +52,7 @@ func TestKubeadmConfig_renderTo(t *testing.T) {
 		ControlPlaneEndpoint    string
 		CertSANs                []string
 		LocalRegistry           string
+		FeatureGates            map[string]bool
 	}
 	tests := []struct {
 		name   string
@@ -104,6 +105,33 @@ func TestKubeadmConfig_renderTo(t *testing.T) {
 				LocalRegistry:        "127.0.0.1:5000",
 			},
 		},
+		{
+			name: "add featureGates",
+			fields: fields{
+				ClusterConfigAPIVersion: "v1beta3",
+				ContainerRuntime:        "containerd",
+				Etcd:                    v1.Etcd{DataDir: "/var/lib/etcd"},
+				Networking: v1.Networking{
+					IPFamily:      v1.IPFamilyIPv4,
+					Services:      v1.NetworkRanges{CIDRBlocks: []string{constatns.ClusterServiceSubnet}},
+					Pods:          v1.NetworkRanges{CIDRBlocks: []string{constatns.ClusterPodSubnet}},
+					DNSDomain:     "cluster.local",
+					ProxyMode:     "ipvs",
+					WorkerNodeVip: "8.8.8.8",
+				},
+				KubeProxy:            v1.KubeProxy{},
+				Kubelet:              v1.Kubelet{RootDir: "/var/lib/kubelet", NodeIP: "127.0.0.1", IPAsName: true},
+				ClusterName:          "test-cluster",
+				KubernetesVersion:    "v1.23.6",
+				ControlPlaneEndpoint: "apiserver.cluster.local:6443",
+				CertSANs:             []string{"127.0.0.1"},
+				LocalRegistry:        "127.0.0.1:5000",
+				FeatureGates: map[string]bool{
+					"AdmissionWebhookMatchConditions": true,
+					"AggregatedDiscoveryEndpoint":     true,
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -119,6 +147,7 @@ func TestKubeadmConfig_renderTo(t *testing.T) {
 				ControlPlaneEndpoint:    tt.fields.ControlPlaneEndpoint,
 				CertSANs:                tt.fields.CertSANs,
 				LocalRegistry:           tt.fields.LocalRegistry,
+				FeatureGates:            tt.fields.FeatureGates,
 			}
 			w := &bytes.Buffer{}
 			err := stepper.renderTo(w)
diff --git a/test/e2e/cluster/spec.go b/test/e2e/cluster/spec.go
index e91630f..e9d5fd3 100644
--- a/test/e2e/cluster/spec.go
+++ b/test/e2e/cluster/spec.go
@@ -249,7 +249,7 @@ var _ = SIGDescribe("[Serial]", func() {
 		ginkgo.By("check whether the cluster registries is automatically removed after registries are deleted")
 		err = cluster.WaitForClusterCondition(f.Client, clu.Name, "delete cluster registries successful", f.Timeouts.CommonTimeout, func(clu *corev1.Cluster) (bool, error) {
 			return clu.Status.Registries == nil, nil
-		})
+		}, true)
 		framework.ExpectNoError(err)
 	})
 
@@ -306,7 +306,7 @@ var _ = SIGDescribe("[Serial]", func() {
 		ginkgo.By("check whether the cluster registries is automatically removed after registries are deleted")
 		err = cluster.WaitForClusterCondition(f.Client, clu.Name, "delete cluster registries successful", f.Timeouts.CommonTimeout, func(clu *corev1.Cluster) (bool, error) {
 			return clu.Status.Registries == nil, nil
-		})
+		}, true)
 		framework.ExpectNoError(err)
 	})
 
diff --git a/test/framework/cluster/wait.go b/test/framework/cluster/wait.go
index c1225a8..2e5f87d 100644
--- a/test/framework/cluster/wait.go
+++ b/test/framework/cluster/wait.go
@@ -9,6 +9,9 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/wait"
 
+	"github.com/kubeclipper/kubeclipper/pkg/query"
+	"github.com/kubeclipper/kubeclipper/pkg/scheme/common"
+
 	apierror "github.com/kubeclipper/kubeclipper/pkg/errors"
 	corev1 "github.com/kubeclipper/kubeclipper/pkg/scheme/core/v1"
 	"github.com/kubeclipper/kubeclipper/pkg/simple/client/kc"
@@ -20,7 +23,7 @@ type clusterCondition func(clu *corev1.Cluster) (bool, error)
 type backupCondition func(backup *corev1.Backup) (bool, error)
 
 // WaitForClusterCondition waits a cluster to be matched to the given condition.
-func WaitForClusterCondition(c *kc.Client, clusterName, conditionDesc string, timeout time.Duration, condition clusterCondition) error {
+func WaitForClusterCondition(c *kc.Client, clusterName, conditionDesc string, timeout time.Duration, condition clusterCondition, outLog bool) error {
 	framework.Logf("Waiting up to %v for cluster %q to be %q", timeout, clusterName, conditionDesc)
 	var (
 		lastClusterError error
@@ -36,6 +39,22 @@ func WaitForClusterCondition(c *kc.Client, clusterName, conditionDesc string, ti
 		lastCluster = clu.Items[0].DeepCopy()
 		framework.Logf("Cluster %q: Phase=%q, Elapsed: %v",
 			clusterName, lastCluster.Status.Phase, time.Since(start))
+		if outLog {
+			q := query.New()
+			q.LabelSelector = fmt.Sprintf("%s=%s", common.LabelClusterName, clusterName)
+			opList, err := c.ListOperation(context.TODO(), kc.Queries(*q))
+			if err != nil {
+				framework.Logf("List Cluster Operation Failed: %v", err)
+			}
+			for _, op := range opList.Items {
+				if op.Labels[common.LabelOperationAction] == "CreateCluster" {
+					err := c.PrintLogs(context.TODO(), op)
+					if err != nil {
+						framework.Logf("Print Step Log Failed: %v", err)
+					}
+				}
+			}
+		}
 
 		if done, err := condition(lastCluster); done {
 			if err == nil {
@@ -95,7 +114,7 @@ func WaitForBackupCondition(c *kc.Client, clusterName, backupName, conditionDesc
 func WaitForClusterRunning(c *kc.Client, clusterName string, timeout time.Duration) error {
 	return WaitForClusterCondition(c, clusterName, fmt.Sprintf("cluster %s running", clusterName), timeout, func(clu *corev1.Cluster) (bool, error) {
 		return clu.Status.Phase == corev1.ClusterRunning, nil
-	})
+	}, true)
 }
 
 func WaitForClusterHealthy(c *kc.Client, clusterName string, timeout time.Duration) error {
@@ -106,7 +125,7 @@ func WaitForClusterHealthy(c *kc.Client, clusterName string, timeout time.Durati
 			}
 		}
 		return false, nil
-	})
+	}, true)
 }
 
 // WaitForClusterNotFound returns an error if it takes too long for the pod to fully terminate.
@@ -208,7 +227,7 @@ func WaitForRecovery(c *kc.Client, clusterName string, timeout time.Duration) er
 			return false, fmt.Errorf("recovery cluster %s failed", clusterName)
 		}
 		return false, nil
-	})
+	}, true)
 }
 
 func WaitForUpgrade(c *kc.Client, clusterName string, timeout time.Duration) error {
@@ -219,7 +238,7 @@ func WaitForUpgrade(c *kc.Client, clusterName string, timeout time.Duration) err
 			return false, fmt.Errorf("upgrade cluster %s failed", clusterName)
 		}
 		return false, nil
-	})
+	}, true)
 }
 
 func WaitForCertInit(c *kc.Client, clusterName string, timeout time.Duration) error {
@@ -230,7 +249,7 @@ func WaitForCertInit(c *kc.Client, clusterName string, timeout time.Duration) er
 			}
 		}
 		return false, nil
-	})
+	}, true)
 }
 
 func WaitForCertUpdated(c *kc.Client, clusterName string, latestTime metav1.Time, timeout time.Duration) error {
@@ -243,7 +262,7 @@ func WaitForCertUpdated(c *kc.Client, clusterName string, latestTime metav1.Time
 			}
 		}
 		return false, nil
-	})
+	}, true)
 }
 
 func WaitForCriRegistry(c *kc.Client, clusterName string, timeout time.Duration, registries []string) error {
@@ -266,7 +285,7 @@ func WaitForCriRegistry(c *kc.Client, clusterName string, timeout time.Duration,
 			}
 		}
 		return true, nil
-	})
+	}, true)
 }
 
 func WaitForJoinNode(c *kc.Client, nodeIP string, timeout time.Duration) error {
diff --git a/test/framework/timeouts.go b/test/framework/timeouts.go
index e7e9f19..0720420 100644
--- a/test/framework/timeouts.go
+++ b/test/framework/timeouts.go
@@ -4,7 +4,7 @@ import "time"
 
 var (
 	// Default timeouts to be used in TimeoutContext
-	clusterInstall      = 15 * time.Minute
+	clusterInstall      = 20 * time.Minute
 	clusterInstallShort = 5 * time.Minute
 	clusterDelete       = 10 * time.Minute
 	commonTimeout       = 5 * time.Minute