github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/cmd/backuprepo/create.go (about)

     1  /*
     2  Copyright (C) 2022-2023 ApeCloud Co., Ltd
     3  
     4  This file is part of KubeBlocks project
     5  
     6  This program is free software: you can redistribute it and/or modify
     7  it under the terms of the GNU Affero General Public License as published by
     8  the Free Software Foundation, either version 3 of the License, or
     9  (at your option) any later version.
    10  
    11  This program is distributed in the hope that it will be useful
    12  but WITHOUT ANY WARRANTY; without even the implied warranty of
    13  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    14  GNU Affero General Public License for more details.
    15  
    16  You should have received a copy of the GNU Affero General Public License
    17  along with this program.  If not, see <http://www.gnu.org/licenses/>.
    18  */
    19  
    20  package backuprepo
    21  
    22  import (
    23  	"context"
    24  	"encoding/json"
    25  	"errors"
    26  	"fmt"
    27  
    28  	corev1 "k8s.io/api/core/v1"
    29  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    30  	"k8s.io/apimachinery/pkg/api/resource"
    31  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    32  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    33  	"k8s.io/apimachinery/pkg/runtime"
    34  	k8stypes "k8s.io/apimachinery/pkg/types"
    35  	"k8s.io/cli-runtime/pkg/genericiooptions"
    36  	"k8s.io/client-go/dynamic"
    37  	"k8s.io/client-go/kubernetes"
    38  	"k8s.io/kube-openapi/pkg/validation/spec"
    39  	cmdutil "k8s.io/kubectl/pkg/cmd/util"
    40  	utilcomp "k8s.io/kubectl/pkg/util/completion"
    41  	"k8s.io/kubectl/pkg/util/templates"
    42  
    43  	jsonpatch "github.com/evanphx/json-patch"
    44  	"github.com/spf13/cobra"
    45  	"github.com/spf13/pflag"
    46  	"github.com/stoewer/go-strcase"
    47  	"github.com/xeipuuv/gojsonschema"
    48  	"golang.org/x/exp/slices"
    49  
    50  	dpv1alpha1 "github.com/1aal/kubeblocks/apis/dataprotection/v1alpha1"
    51  	storagev1alpha1 "github.com/1aal/kubeblocks/apis/storage/v1alpha1"
    52  	"github.com/1aal/kubeblocks/pkg/cli/printer"
    53  	"github.com/1aal/kubeblocks/pkg/cli/types"
    54  	"github.com/1aal/kubeblocks/pkg/cli/util"
    55  	"github.com/1aal/kubeblocks/pkg/cli/util/flags"
    56  	dptypes "github.com/1aal/kubeblocks/pkg/dataprotection/types"
    57  )
    58  
    59  const (
    60  	providerFlagName = "provider"
    61  )
    62  
    63  var (
    64  	allowedAccessMethods = []string{
    65  		string(dpv1alpha1.AccessMethodMount),
    66  		string(dpv1alpha1.AccessMethodTool),
    67  	}
    68  	allowedPVReclaimPolicies = []string{
    69  		string(corev1.PersistentVolumeReclaimRetain),
    70  		string(corev1.PersistentVolumeReclaimDelete),
    71  	}
    72  )
    73  
    74  type createOptions struct {
    75  	genericiooptions.IOStreams
    76  	dynamic dynamic.Interface
    77  	client  kubernetes.Interface
    78  	factory cmdutil.Factory
    79  
    80  	accessMethod    string
    81  	storageProvider string
    82  	providerObject  *storagev1alpha1.StorageProvider
    83  	isDefault       bool
    84  	pvReclaimPolicy string
    85  	volumeCapacity  string
    86  	repoName        string
    87  	config          map[string]string
    88  	credential      map[string]string
    89  	allValues       map[string]string
    90  }
    91  
    92  var backupRepoCreateExamples = templates.Examples(`
    93      # Create a default backup repo using S3 as the backend
    94      kbcli backuprepo create \
    95        --provider s3 \
    96        --region us-west-1 \
    97        --bucket test-kb-backup \
    98        --access-key-id <ACCESS KEY> \
    99        --secret-access-key <SECRET KEY> \
   100        --default
   101  
   102      # Create a non-default backup repo with a specified name
   103      kbcli backuprepo create my-backup-repo \
   104        --provider s3 \
   105        --region us-west-1 \
   106        --bucket test-kb-backup \
   107        --access-key-id <ACCESS KEY> \
   108        --secret-access-key <SECRET KEY>
   109  `)
   110  
   111  func newCreateCommand(o *createOptions, f cmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Command {
   112  	if o == nil {
   113  		o = &createOptions{}
   114  	}
   115  	o.IOStreams = streams
   116  	cmd := &cobra.Command{
   117  		Use:     "create [NAME]",
   118  		Short:   "Create a backup repo",
   119  		Example: backupRepoCreateExamples,
   120  		RunE: func(cmd *cobra.Command, args []string) error {
   121  			util.CheckErr(o.init(f))
   122  			err := o.parseProviderFlags(cmd, args, f)
   123  			if errors.Is(err, pflag.ErrHelp) {
   124  				return err
   125  			} else {
   126  				util.CheckErr(err)
   127  			}
   128  			util.CheckErr(o.complete(cmd))
   129  			util.CheckErr(o.validate(cmd))
   130  			util.CheckErr(o.run())
   131  			return nil
   132  		},
   133  		DisableFlagParsing: true,
   134  	}
   135  	cmd.Flags().StringVar(&o.accessMethod, "access-method", "",
   136  		fmt.Sprintf("Specify the access method for the backup repository, \"Tool\" is preferred if not specified. options: %q", allowedAccessMethods))
   137  	cmd.Flags().StringVar(&o.storageProvider, providerFlagName, "", "Specify storage provider")
   138  	util.CheckErr(cmd.MarkFlagRequired(providerFlagName))
   139  	cmd.Flags().BoolVar(&o.isDefault, "default", false, "Specify whether to set the created backup repo as default")
   140  	cmd.Flags().StringVar(&o.pvReclaimPolicy, "pv-reclaim-policy", "Retain",
   141  		`Specify the reclaim policy for PVs created by this backup repo, the value can be "Retain" or "Delete"`)
   142  	cmd.Flags().StringVar(&o.volumeCapacity, "volume-capacity", "100Gi",
   143  		`Specify the capacity of the new created PVC"`)
   144  
   145  	// register flag completion func
   146  	registerFlagCompletionFunc(cmd, f)
   147  
   148  	return cmd
   149  }
   150  
   151  func (o *createOptions) init(f cmdutil.Factory) error {
   152  	var err error
   153  	if o.dynamic, err = f.DynamicClient(); err != nil {
   154  		return err
   155  	}
   156  	if o.client, err = f.KubernetesClientSet(); err != nil {
   157  		return err
   158  	}
   159  	o.factory = f
   160  	return nil
   161  }
   162  
   163  func flagsToValues(fs *pflag.FlagSet) map[string]string {
   164  	values := make(map[string]string)
   165  	fs.VisitAll(func(f *pflag.Flag) {
   166  		if f.Name == "help" {
   167  			return
   168  		}
   169  		val, _ := fs.GetString(f.Name)
   170  		values[f.Name] = val
   171  	})
   172  	return values
   173  }
   174  
   175  func (o *createOptions) parseProviderFlags(cmd *cobra.Command, args []string, f cmdutil.Factory) error {
   176  	// Since we disabled the flag parsing of the cmd, we need to parse it from args
   177  	help := false
   178  	tmpFlags := pflag.NewFlagSet("tmp", pflag.ContinueOnError)
   179  	tmpFlags.StringVar(&o.storageProvider, providerFlagName, "", "")
   180  	tmpFlags.BoolVarP(&help, "help", "h", false, "") // eat --help and -h
   181  	tmpFlags.ParseErrorsWhitelist.UnknownFlags = true
   182  	_ = tmpFlags.Parse(args)
   183  	if o.storageProvider == "" {
   184  		if help {
   185  			cmd.Long = templates.LongDesc(`
   186                  Note: This help information only shows the common flags for creating a 
   187                  backup repository, to show provider-specific flags, please specify 
   188                  the --provider flag. For example:
   189  
   190                      kbcli backuprepo create --provider s3 --help
   191              `)
   192  			return pflag.ErrHelp
   193  		}
   194  		return fmt.Errorf("please specify the --%s flag", providerFlagName)
   195  	}
   196  
   197  	// Get provider info from API server
   198  	obj, err := o.dynamic.Resource(types.StorageProviderGVR()).Get(
   199  		context.Background(), o.storageProvider, metav1.GetOptions{})
   200  	if err != nil {
   201  		if apierrors.IsNotFound(err) {
   202  			return fmt.Errorf("storage provider \"%s\" is not found", o.storageProvider)
   203  		}
   204  		return err
   205  	}
   206  	provider := &storagev1alpha1.StorageProvider{}
   207  	err = runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, provider)
   208  	if err != nil {
   209  		return err
   210  	}
   211  	o.providerObject = provider
   212  
   213  	// Build flags by schema
   214  	if provider.Spec.ParametersSchema != nil &&
   215  		provider.Spec.ParametersSchema.OpenAPIV3Schema != nil {
   216  		// Convert apiextensionsv1.JSONSchemaProps to spec.Schema
   217  		schemaData, err := json.Marshal(provider.Spec.ParametersSchema.OpenAPIV3Schema)
   218  		if err != nil {
   219  			return err
   220  		}
   221  		schema := &spec.Schema{}
   222  		if err = json.Unmarshal(schemaData, schema); err != nil {
   223  			return err
   224  		}
   225  		if err = flags.BuildFlagsBySchema(cmd, schema); err != nil {
   226  			return err
   227  		}
   228  	}
   229  
   230  	// Parse dynamic flags
   231  	cmd.DisableFlagParsing = false
   232  	err = cmd.ParseFlags(args)
   233  	if err != nil {
   234  		return err
   235  	}
   236  	helpFlag := cmd.Flags().Lookup("help")
   237  	if helpFlag != nil && helpFlag.Value.String() == "true" {
   238  		return pflag.ErrHelp
   239  	}
   240  	if err := cmd.ValidateRequiredFlags(); err != nil {
   241  		return err
   242  	}
   243  
   244  	return nil
   245  }
   246  
   247  func (o *createOptions) complete(cmd *cobra.Command) error {
   248  	o.config = map[string]string{}
   249  	o.credential = map[string]string{}
   250  	o.allValues = map[string]string{}
   251  	schema := o.providerObject.Spec.ParametersSchema
   252  	// Construct config and credential map from flags
   253  	if schema != nil && schema.OpenAPIV3Schema != nil {
   254  		credMap := map[string]bool{}
   255  		for _, x := range schema.CredentialFields {
   256  			credMap[x] = true
   257  		}
   258  		fromFlags := flagsToValues(cmd.LocalNonPersistentFlags())
   259  		for name := range schema.OpenAPIV3Schema.Properties {
   260  			flagName := strcase.KebabCase(name)
   261  			if val, ok := fromFlags[flagName]; ok {
   262  				o.allValues[name] = val
   263  				if credMap[name] {
   264  					o.credential[name] = val
   265  				} else {
   266  					o.config[name] = val
   267  				}
   268  			}
   269  		}
   270  	}
   271  	// Set repo name if specified
   272  	positionArgs := cmd.Flags().Args()
   273  	if len(positionArgs) > 0 {
   274  		o.repoName = positionArgs[0]
   275  	}
   276  	return nil
   277  }
   278  
   279  func (o *createOptions) supportedAccessMethods() []string {
   280  	var methods []string
   281  	if o.providerObject.Spec.StorageClassTemplate != "" || o.providerObject.Spec.PersistentVolumeClaimTemplate != "" {
   282  		methods = append(methods, string(dpv1alpha1.AccessMethodMount))
   283  	}
   284  	if o.providerObject.Spec.DatasafedConfigTemplate != "" {
   285  		methods = append(methods, string(dpv1alpha1.AccessMethodTool))
   286  	}
   287  	return methods
   288  }
   289  
   290  func (o *createOptions) validate(cmd *cobra.Command) error {
   291  	// Validate values by the json schema
   292  	schema := o.providerObject.Spec.ParametersSchema
   293  	if schema != nil && schema.OpenAPIV3Schema != nil {
   294  		schemaLoader := gojsonschema.NewGoLoader(schema.OpenAPIV3Schema)
   295  		docLoader := gojsonschema.NewGoLoader(o.allValues)
   296  		result, err := gojsonschema.Validate(schemaLoader, docLoader)
   297  		if err != nil {
   298  			return err
   299  		}
   300  		if !result.Valid() {
   301  			for _, err := range result.Errors() {
   302  				flagName := strcase.KebabCase(err.Field())
   303  				cmd.Printf("invalid value \"%v\" for \"--%s\": %s\n",
   304  					err.Value(), flagName, err.Description())
   305  			}
   306  			return fmt.Errorf("invalid flags")
   307  		}
   308  	}
   309  
   310  	// Validate access method
   311  	supportedAccessMethods := o.supportedAccessMethods()
   312  	if len(supportedAccessMethods) == 0 {
   313  		return fmt.Errorf("invalid provider \"%s\", it doesn't support any access method", o.storageProvider)
   314  	}
   315  	if o.accessMethod != "" && !slices.Contains(supportedAccessMethods, o.accessMethod) {
   316  		return fmt.Errorf("provider \"%s\" doesn't support \"%s\" access method, supported methods: %q",
   317  			o.storageProvider, o.accessMethod, supportedAccessMethods)
   318  	}
   319  	if o.accessMethod == "" {
   320  		// Prefer using AccessMethodTool if it's supported
   321  		if slices.Contains(supportedAccessMethods, string(dpv1alpha1.AccessMethodTool)) {
   322  			o.accessMethod = string(dpv1alpha1.AccessMethodTool)
   323  		} else {
   324  			o.accessMethod = supportedAccessMethods[0]
   325  		}
   326  	}
   327  
   328  	// Validate pv reclaim policy
   329  	if !slices.Contains(allowedPVReclaimPolicies, o.pvReclaimPolicy) {
   330  		return fmt.Errorf("invalid --pv-reclaim-policy \"%s\", the value must be one of %q",
   331  			o.pvReclaimPolicy, allowedPVReclaimPolicies)
   332  	}
   333  
   334  	// Validate volume capacity
   335  	if _, err := resource.ParseQuantity(o.volumeCapacity); err != nil {
   336  		return fmt.Errorf("invalid --volume-capacity \"%s\", err: %s", o.volumeCapacity, err)
   337  	}
   338  
   339  	// Check if the repo already exists
   340  	if o.repoName != "" {
   341  		_, err := o.dynamic.Resource(types.BackupRepoGVR()).Get(
   342  			context.Background(), o.repoName, metav1.GetOptions{})
   343  		if err == nil {
   344  			return fmt.Errorf(`BackupRepo "%s" is already exists`, o.repoName)
   345  		}
   346  		if !apierrors.IsNotFound(err) {
   347  			return err
   348  		}
   349  	}
   350  
   351  	// Check if there are any default backup repo already exists
   352  	if o.isDefault {
   353  		list, err := o.dynamic.Resource(types.BackupRepoGVR()).List(
   354  			context.Background(), metav1.ListOptions{})
   355  		if err != nil {
   356  			return err
   357  		}
   358  		for _, item := range list.Items {
   359  			if item.GetAnnotations()[dptypes.DefaultBackupRepoAnnotationKey] == "true" {
   360  				name := item.GetName()
   361  				return fmt.Errorf("there is already a default backup repo \"%s\","+
   362  					" please don't specify the --default flag,\n"+
   363  					"\tor set \"%s\" as non-default first",
   364  					name, name)
   365  			}
   366  		}
   367  	}
   368  
   369  	return nil
   370  }
   371  
   372  func (o *createOptions) createCredentialSecret() (*corev1.Secret, error) {
   373  	// if failed to get the namespace of KubeBlocks,
   374  	// then create the secret in the current namespace
   375  	namespace, err := util.GetKubeBlocksNamespace(o.client)
   376  	if err != nil {
   377  		namespace, _, err = o.factory.ToRawKubeConfigLoader().Namespace()
   378  		if err != nil {
   379  			return nil, err
   380  		}
   381  	}
   382  	secretData := map[string][]byte{}
   383  	for k, v := range o.credential {
   384  		secretData[k] = []byte(v)
   385  	}
   386  	secretObj := &corev1.Secret{
   387  		ObjectMeta: metav1.ObjectMeta{
   388  			GenerateName: "kb-backuprepo-",
   389  			Namespace:    namespace,
   390  		},
   391  		Type: corev1.SecretTypeOpaque,
   392  		Data: secretData,
   393  	}
   394  	return o.client.CoreV1().Secrets(namespace).Create(
   395  		context.Background(), secretObj, metav1.CreateOptions{})
   396  }
   397  
   398  func (o *createOptions) buildBackupRepoObject(secret *corev1.Secret) (*unstructured.Unstructured, error) {
   399  	backupRepo := &dpv1alpha1.BackupRepo{
   400  		TypeMeta: metav1.TypeMeta{
   401  			APIVersion: fmt.Sprintf("%s/%s", types.DPAPIGroup, types.DPAPIVersion),
   402  			Kind:       "BackupRepo",
   403  		},
   404  		Spec: dpv1alpha1.BackupRepoSpec{
   405  			AccessMethod:       dpv1alpha1.AccessMethod(o.accessMethod),
   406  			StorageProviderRef: o.storageProvider,
   407  			PVReclaimPolicy:    corev1.PersistentVolumeReclaimPolicy(o.pvReclaimPolicy),
   408  			VolumeCapacity:     resource.MustParse(o.volumeCapacity),
   409  			Config:             o.config,
   410  		},
   411  	}
   412  	if o.repoName != "" {
   413  		backupRepo.Name = o.repoName
   414  	} else {
   415  		backupRepo.GenerateName = "backuprepo-"
   416  	}
   417  	if secret != nil {
   418  		backupRepo.Spec.Credential = &corev1.SecretReference{
   419  			Name:      secret.Name,
   420  			Namespace: secret.Namespace,
   421  		}
   422  	}
   423  	if o.isDefault {
   424  		backupRepo.Annotations = map[string]string{
   425  			dptypes.DefaultBackupRepoAnnotationKey: "true",
   426  		}
   427  	}
   428  	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(backupRepo)
   429  	if err != nil {
   430  		return nil, err
   431  	}
   432  	return &unstructured.Unstructured{Object: obj}, nil
   433  }
   434  
   435  func (o *createOptions) setSecretOwnership(secret *corev1.Secret, owner *unstructured.Unstructured) error {
   436  	old := secret.DeepCopyObject()
   437  	refs := secret.GetOwnerReferences()
   438  	refs = append(refs, metav1.OwnerReference{
   439  		APIVersion: owner.GetAPIVersion(),
   440  		Kind:       owner.GetKind(),
   441  		Name:       owner.GetName(),
   442  		UID:        owner.GetUID(),
   443  	})
   444  	secret.SetOwnerReferences(refs)
   445  	oldData, err := json.Marshal(old)
   446  	if err != nil {
   447  		return err
   448  	}
   449  	newData, err := json.Marshal(secret)
   450  	if err != nil {
   451  		return err
   452  	}
   453  	patchData, err := jsonpatch.CreateMergePatch(oldData, newData)
   454  	if err != nil {
   455  		return err
   456  	}
   457  	_, err = o.client.CoreV1().Secrets(secret.GetNamespace()).Patch(
   458  		context.Background(), secret.Name, k8stypes.MergePatchType, patchData, metav1.PatchOptions{})
   459  	return err
   460  }
   461  
   462  func (o *createOptions) run() error {
   463  	// create secret
   464  	var createdSecret *corev1.Secret
   465  	if len(o.credential) > 0 {
   466  		var err error
   467  		if createdSecret, err = o.createCredentialSecret(); err != nil {
   468  			return fmt.Errorf("create credential secret failed: %w", err)
   469  		}
   470  	}
   471  
   472  	rollbackFn := func() {
   473  		// rollback the created secret if the backup repo creation failed
   474  		if createdSecret != nil {
   475  			_ = o.client.CoreV1().Secrets(createdSecret.Namespace).Delete(
   476  				context.Background(), createdSecret.Name, metav1.DeleteOptions{})
   477  		}
   478  	}
   479  
   480  	// create backup repo
   481  	backupRepoObj, err := o.buildBackupRepoObject(createdSecret)
   482  	if err != nil {
   483  		rollbackFn()
   484  		return fmt.Errorf("build BackupRepo object failed: %w", err)
   485  	}
   486  	createdBackupRepo, err := o.dynamic.Resource(types.BackupRepoGVR()).Create(
   487  		context.Background(), backupRepoObj, metav1.CreateOptions{})
   488  	if err != nil {
   489  		rollbackFn()
   490  		return fmt.Errorf("create BackupRepo object failed: %w", err)
   491  	}
   492  
   493  	// set ownership of the secret to the repo object
   494  	if createdSecret != nil {
   495  		_ = o.setSecretOwnership(createdSecret, createdBackupRepo)
   496  	}
   497  
   498  	printer.PrintLine(fmt.Sprintf("Successfully create backup repo \"%s\".", createdBackupRepo.GetName()))
   499  	return nil
   500  }
   501  
   502  func registerFlagCompletionFunc(cmd *cobra.Command, f cmdutil.Factory) {
   503  	util.CheckErr(cmd.RegisterFlagCompletionFunc(
   504  		providerFlagName,
   505  		func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
   506  			return utilcomp.CompGetResource(f, util.GVRToString(types.StorageProviderGVR()), toComplete), cobra.ShellCompDirectiveNoFileComp
   507  		}))
   508  	util.CheckErr(cmd.RegisterFlagCompletionFunc(
   509  		"access-method",
   510  		cobra.FixedCompletions(allowedAccessMethods, cobra.ShellCompDirectiveNoFileComp)))
   511  	util.CheckErr(cmd.RegisterFlagCompletionFunc(
   512  		"pv-reclaim-policy",
   513  		cobra.FixedCompletions(allowedPVReclaimPolicies, cobra.ShellCompDirectiveNoFileComp)))
   514  
   515  	// TODO: support completion for dynamic flags, if possible
   516  }