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 }