github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/edit/custom_edit.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 edit
    21  
    22  import (
    23  	"bufio"
    24  	"bytes"
    25  	"fmt"
    26  	"io"
    27  	"os"
    28  	"path/filepath"
    29  	"strings"
    30  
    31  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    32  	"k8s.io/apimachinery/pkg/runtime"
    33  	"k8s.io/apimachinery/pkg/util/yaml"
    34  	"k8s.io/cli-runtime/pkg/genericclioptions"
    35  	"k8s.io/cli-runtime/pkg/genericiooptions"
    36  	cmdutil "k8s.io/kubectl/pkg/cmd/util"
    37  	"k8s.io/kubectl/pkg/cmd/util/editor"
    38  
    39  	"github.com/1aal/kubeblocks/pkg/cli/printer"
    40  	"github.com/1aal/kubeblocks/pkg/cli/util"
    41  	"github.com/1aal/kubeblocks/pkg/cli/util/prompt"
    42  )
    43  
    44  // CustomEditOptions is used to edit the resource manifest when creating or updating the resource,
    45  // instead of using -o yaml to output the yaml file before editing the manifest.
    46  type CustomEditOptions struct {
    47  	Factory    cmdutil.Factory
    48  	PrintFlags *genericclioptions.PrintFlags
    49  	Method     string
    50  	TestEnv    bool
    51  
    52  	genericiooptions.IOStreams
    53  }
    54  
    55  func NewCustomEditOptions(f cmdutil.Factory, streams genericiooptions.IOStreams, method string) *CustomEditOptions {
    56  	return &CustomEditOptions{
    57  		Factory:    f,
    58  		PrintFlags: genericclioptions.NewPrintFlags("").WithDefaultOutput("yaml"),
    59  		IOStreams:  streams,
    60  		Method:     method,
    61  		TestEnv:    false,
    62  	}
    63  }
    64  
    65  func (o *CustomEditOptions) Run(originalObj runtime.Object) error {
    66  	buf := &bytes.Buffer{}
    67  	var (
    68  		original []byte
    69  		edited   []byte
    70  		tmpFile  string
    71  		w        io.Writer = buf
    72  	)
    73  	editPrinter, err := o.PrintFlags.ToPrinter()
    74  	if err != nil {
    75  		return fmt.Errorf("failed to create printer: %v", err)
    76  	}
    77  	if err := editPrinter.PrintObj(originalObj, w); err != nil {
    78  		return fmt.Errorf("failed to print object: %v", err)
    79  	}
    80  	original = buf.Bytes()
    81  
    82  	if !o.TestEnv {
    83  		edited, tmpFile, err = editObject(original)
    84  		if err != nil {
    85  			return fmt.Errorf("failed to lanch editor: %v", err)
    86  		}
    87  	} else {
    88  		edited = original
    89  	}
    90  
    91  	// apply validation
    92  	schemaValidator, err := o.Factory.Validator(metav1.FieldValidationStrict)
    93  	if err != nil {
    94  		return fmt.Errorf("failed to get validator: %v", err)
    95  	}
    96  	err = schemaValidator.ValidateBytes(cmdutil.StripComments(edited))
    97  	if err != nil {
    98  		return fmt.Errorf("the edited file failed validation: %v", err)
    99  	}
   100  
   101  	// Compare content without comments
   102  	if bytes.Equal(cmdutil.StripComments(original), cmdutil.StripComments(edited)) {
   103  		os.Remove(tmpFile)
   104  		_, err = fmt.Fprintln(o.ErrOut, "Edit cancelled, no changes made.")
   105  		if err != nil {
   106  			return fmt.Errorf("error writing to stderr: %v", err)
   107  		}
   108  		return nil
   109  	}
   110  
   111  	// Returns an error if comments are included.
   112  	lines, err := hasComment(bytes.NewBuffer(edited))
   113  	if err != nil {
   114  		return fmt.Errorf("error checking for comment: %v", err)
   115  	}
   116  	if !lines {
   117  		os.Remove(tmpFile)
   118  		_, err = fmt.Fprintln(o.ErrOut, "Edit cancelled, saved file was empty.")
   119  		if err != nil {
   120  			return fmt.Errorf("error writing to stderr: %v", err)
   121  		}
   122  	}
   123  
   124  	decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(edited), len(edited))
   125  	if err := decoder.Decode(originalObj); err != nil {
   126  		return fmt.Errorf("failed to decode edited object: %v", err)
   127  	}
   128  
   129  	if o.Method == "patched" {
   130  		diff, err := util.GetUnifiedDiffString(string(original), string(edited), "Original", "Current", 3)
   131  		if err != nil {
   132  			return fmt.Errorf("failed to get diff: %v", err)
   133  		}
   134  		util.DisplayDiffWithColor(o.IOStreams.Out, diff)
   135  	} else if o.Method == "create" {
   136  		err := editPrinter.PrintObj(originalObj, o.IOStreams.Out)
   137  		if err != nil {
   138  			return fmt.Errorf("failed to print object: %v", err)
   139  		}
   140  	}
   141  	return confirmToContinue(o.IOStreams)
   142  }
   143  
   144  func editObject(original []byte) ([]byte, string, error) {
   145  	err := addHeader(bytes.NewBuffer(original))
   146  	if err != nil {
   147  		return nil, "", err
   148  	}
   149  
   150  	edit := editor.NewDefaultEditor([]string{
   151  		"KUBE_EDITOR",
   152  		"EDITOR",
   153  	})
   154  	// launch the editor
   155  	edited, tmpFile, err := edit.LaunchTempFile(fmt.Sprintf("%s-edit-", filepath.Base(os.Args[0])), ".yaml", bytes.NewBuffer(original))
   156  	if err != nil {
   157  		return nil, "", err
   158  	}
   159  
   160  	return edited, tmpFile, nil
   161  }
   162  
   163  // HasComment returns true if any line in the provided stream is non empty - has non-whitespace
   164  // characters, or the first non-whitespace character is a '#' indicating a comment. Returns
   165  // any errors encountered reading the stream.
   166  func hasComment(r io.Reader) (bool, error) {
   167  	s := bufio.NewScanner(r)
   168  	for s.Scan() {
   169  		if line := strings.TrimSpace(s.Text()); len(line) > 0 && line[0] != '#' {
   170  			return true, nil
   171  		}
   172  	}
   173  	if err := s.Err(); err != nil && err != io.EOF {
   174  		return false, err
   175  	}
   176  	return false, nil
   177  }
   178  
   179  // AddHeader adds a header to the provided writer
   180  func addHeader(w io.Writer) error {
   181  	_, err := fmt.Fprint(w, `# Please edit the object below. Lines beginning with a '#' will be ignored,
   182  # and an empty file will abort the edit. If an error occurs while saving this file will be
   183  # reopened with the relevant failures.
   184  #
   185  `)
   186  	return err
   187  }
   188  
   189  func confirmToContinue(stream genericiooptions.IOStreams) error {
   190  	printer.Warning(stream.Out, "Above resource will be created or changed, do you want to continue to create or change this resource?\n  Only 'yes' will be accepted to confirm.\n\n")
   191  	entered, _ := prompt.NewPrompt("Enter a value:", nil, stream.In).Run()
   192  	if entered != "yes" {
   193  		_, err := fmt.Fprintf(stream.Out, "\nCancel resource creation.\n")
   194  		if err != nil {
   195  			return err
   196  		}
   197  		return cmdutil.ErrExit
   198  	}
   199  	return nil
   200  }