/* Copyright 2022 The Karmada 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 interpret import ( "bufio" "bytes" "encoding/json" "fmt" "io" "os" "path/filepath" "reflect" "strings" jsonpatch "github.com/evanphx/json-patch/v5" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/mergepatch" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/cli-runtime/pkg/printers" "k8s.io/cli-runtime/pkg/resource" "k8s.io/klog/v2" cmdutil "k8s.io/kubectl/pkg/cmd/util" "k8s.io/kubectl/pkg/cmd/util/editor" "k8s.io/kubectl/pkg/cmd/util/editor/crlf" configv1alpha1 "github.com/karmada-io/karmada/pkg/apis/config/v1alpha1" "github.com/karmada-io/karmada/pkg/util/interpreter" ) func (o *Options) completeEdit() error { if !o.Edit { return nil } o.ToPrinter = func(operation string) (printers.ResourcePrinter, error) { o.PrintFlags.NamePrintFlags.Operation = operation return o.PrintFlags.ToPrinter() } return nil } // this logic is modified from: https://github.com/kubernetes/kubernetes/blob/70617042976dc168208a41b8a10caa61f9748617/staging/src/k8s.io/kubectl/pkg/cmd/util/editor/editoptions.go#L246-L479 // nolint:gocyclo func (o *Options) runEdit() error { infos, err := o.CustomizationResult.Infos() if err != nil { return err } var info *resource.Info switch len(infos) { case 0: if len(o.Filenames) != 1 { return fmt.Errorf("no customizations found. If you want to create a new one, please set only one file with '-f'") } info = &resource.Info{ Source: o.Filenames[0], Object: &configv1alpha1.ResourceInterpreterCustomization{ TypeMeta: metav1.TypeMeta{ APIVersion: "config.karmada.io/v1alpha1", Kind: "ResourceInterpreterCustomization", }, }, } case 1: info = infos[0] default: return fmt.Errorf("only one customization can be edited") } originalCustomization, err := asResourceInterpreterCustomization(info.Object) if err != nil { return err } editedCustomization := originalCustomization.DeepCopy() editedCustomization.Spec = configv1alpha1.ResourceInterpreterCustomizationSpec{} var ( edit = editor.NewDefaultEditor(editorEnvs()) results = editResults{} edited = make([]byte, 0) file string containsError = false ) // loop until we succeed or cancel editing for { // generate the file to edit buf := &bytes.Buffer{} var w io.Writer = buf if o.WindowsLineEndings { w = crlf.NewCRLFWriter(w) } err = results.header.writeTo(w) if err != nil { return err } if !containsError { printCustomization(w, originalCustomization, o.Rules, o.ShowDoc) } else { // In case of an error, preserve the edited file. // Remove the comments (header) from it since we already // have included the latest header in the buffer above. buf.Write(stripComments(edited)) } // launch the editor editedDiff := edited edited, file, err = edit.LaunchTempFile(fmt.Sprintf("%s-edit-", filepath.Base(os.Args[0])), ".lua", buf) if err != nil { return preservedFile(err, results.file, o.ErrOut) } // If we're retrying the loop because of an error, and no change was made in the file, short-circuit if containsError && bytes.Equal(stripComments(editedDiff), stripComments(edited)) { return preservedFile(fmt.Errorf("%s", "Edit cancelled, no valid changes were saved."), file, o.ErrOut) } // cleanup any file from the previous pass if len(results.file) > 0 { os.Remove(results.file) } klog.V(4).Infof("User edited:\n%s", string(edited)) // build new customization from edited file err = parseEditedIntoCustomization(edited, editedCustomization, o.Rules) if err != nil { results = editResults{ file: file, } containsError = true fmt.Fprintln(o.ErrOut, results.addError(apierrors.NewInvalid(corev1.SchemeGroupVersion.WithKind("").GroupKind(), "", field.ErrorList{field.Invalid(nil, "The edited file failed validation", fmt.Sprintf("%v", err))}), info)) continue } // Compare content if isEqualsCustomization(originalCustomization, editedCustomization) { os.Remove(file) fmt.Fprintln(o.ErrOut, "Edit cancelled, no changes made.") return nil } results = editResults{ file: file, } // TODO: validate edited customization // not a syntax error as it turns out... containsError = false // TODO: add last-applied-configuration annotation switch { case info.Source != "": err = o.saveToPath(info, editedCustomization, &results) default: err = o.saveToServer(info, editedCustomization, &results) } if err != nil { return preservedFile(err, results.file, o.ErrOut) } // Handle all possible errors // // 1. retryable: propose kubectl replace -f // 2. notfound: indicate the location of the saved configuration of the deleted resource // 3. invalid: retry those on the spot by looping ie. reloading the editor if results.retryable > 0 { fmt.Fprintf(o.ErrOut, "You can run `%s replace -f %s` to try this update again.\n", filepath.Base(os.Args[0]), file) return cmdutil.ErrExit } if results.notfound > 0 { fmt.Fprintf(o.ErrOut, "The edits you made on deleted resources have been saved to %q\n", file) return cmdutil.ErrExit } if len(results.edit) == 0 { if results.notfound == 0 { os.Remove(file) } else { fmt.Fprintf(o.Out, "The edits you made on deleted resources have been saved to %q\n", file) } return nil } if len(results.header.reasons) > 0 { containsError = true } } } func (o *Options) saveToPath(originalInfo *resource.Info, editedObj runtime.Object, results *editResults) error { var w io.Writer var writer printers.ResourcePrinter = &printers.YAMLPrinter{} source := originalInfo.Source switch { case source == "": return fmt.Errorf("resource %s/%s is not from file", originalInfo.Namespace, originalInfo.Name) case source == "STDIN" || strings.HasPrefix(source, "http"): w = os.Stdout default: f, err := os.OpenFile(originalInfo.Source, os.O_RDWR|os.O_TRUNC, 0) if err != nil { return err } defer f.Close() w = f _, _, isJSON := yaml.GuessJSONStream(f, 4096) if isJSON { writer = &printers.JSONPrinter{} } } err := writer.PrintObj(editedObj, w) if err != nil { fmt.Fprint(o.ErrOut, results.addError(err, originalInfo)) } printer, err := o.ToPrinter("edited") if err != nil { return err } return printer.PrintObj(originalInfo.Object, o.Out) } func (o *Options) saveToServer(originalInfo *resource.Info, editedObj runtime.Object, results *editResults) error { originalJS, err := json.Marshal(originalInfo.Object) if err != nil { return err } editedJS, err := json.Marshal(editedObj) if err != nil { return err } preconditions := []mergepatch.PreconditionFunc{ mergepatch.RequireKeyUnchanged("apiVersion"), mergepatch.RequireKeyUnchanged("kind"), mergepatch.RequireMetadataKeyUnchanged("name"), mergepatch.RequireKeyUnchanged("managedFields"), } patchType := types.MergePatchType patch, err := jsonpatch.CreateMergePatch(originalJS, editedJS) if err != nil { klog.V(4).Infof("Unable to calculate diff, no merge is possible: %v", err) return err } var patchMap map[string]interface{} err = json.Unmarshal(patch, &patchMap) if err != nil { klog.V(4).Infof("Unable to calculate diff, no merge is possible: %v", err) return err } for _, precondition := range preconditions { if !precondition(patchMap) { klog.V(4).Infof("Unable to calculate diff, no merge is possible: %v", err) return fmt.Errorf("%s", "At least one of apiVersion, kind and name was changed") } } if o.OutputPatch { fmt.Fprintf(o.Out, "Patch: %s\n", string(patch)) } patched, err := resource.NewHelper(originalInfo.Client, originalInfo.Mapping). WithFieldManager(o.FieldManager). WithFieldValidation(o.ValidationDirective). WithSubresource(o.Subresource). Patch(originalInfo.Namespace, originalInfo.Name, patchType, patch, nil) if err != nil { fmt.Fprintln(o.ErrOut, results.addError(err, originalInfo)) return nil } err = originalInfo.Refresh(patched, true) if err != nil { return err } printer, err := o.ToPrinter("edited") if err != nil { return err } return printer.PrintObj(originalInfo.Object, o.Out) } func isEqualsCustomization(a, b *configv1alpha1.ResourceInterpreterCustomization) bool { return a.Namespace == b.Namespace && a.Name == b.Name && reflect.DeepEqual(a.Spec, b.Spec) } const ( luaCommentPrefix = "--" luaAnnotationPrefix = "---@" luaAnnotationName = luaAnnotationPrefix + "name:" luaAnnotationAPIVersion = luaAnnotationPrefix + "apiVersion:" luaAnnotationKind = luaAnnotationPrefix + "kind:" luaAnnotationRule = luaAnnotationPrefix + "rule:" ) func printCustomization(w io.Writer, c *configv1alpha1.ResourceInterpreterCustomization, rules interpreter.Rules, showDoc bool) { fmt.Fprintf(w, "%s %s\n", luaAnnotationName, c.Name) fmt.Fprintf(w, "%s %s\n", luaAnnotationAPIVersion, c.Spec.Target.APIVersion) fmt.Fprintf(w, "%s %s\n", luaAnnotationKind, c.Spec.Target.Kind) for _, r := range rules { fmt.Fprintf(w, "%s %s\n", luaAnnotationRule, r.Name()) if showDoc { if doc := r.Document(); doc != "" { fmt.Fprintf(w, "%s %s\n", luaCommentPrefix, commentOnLineBreak(doc)) } } if script := r.GetScript(c); script != "" { fmt.Fprintf(w, "%s", script) } } } // The file is like: // -- Please edit the object below. Lines beginning with a '--' will be ignored, // -- and an empty file will abort the edit. If an error occurs while saving this file will be // -- reopened with the relevant failures. // -- // ---@name: foo // ---@apiVersion: apps/v1 // ---@kind: Deployment // ---@rule: Retain // -- This rule is used to retain runtime values to the desired specification. // -- The script should implement a function as follows: // -- function Retain(desiredObj, observedObj) // -- desiredObj.spec.fieldFoo = observedObj.spec.fieldFoo // -- return desiredObj // -- end // function Retain(desiredObj, runtimeObj) // // desiredObj.spec.fieldFoo = runtimeObj.spec.fieldFoo // return desiredObj // // end func parseEditedIntoCustomization(file []byte, into *configv1alpha1.ResourceInterpreterCustomization, rules interpreter.Rules) error { var currRule interpreter.Rule var script string scanner := bufio.NewScanner(bytes.NewBuffer(file)) for scanner.Scan() { line := scanner.Text() trimline := strings.TrimSpace(line) switch { case strings.HasPrefix(trimline, luaAnnotationName): into.Name = strings.TrimSpace(strings.TrimPrefix(trimline, luaAnnotationName)) case strings.HasPrefix(trimline, luaAnnotationAPIVersion): into.Spec.Target.APIVersion = strings.TrimSpace(strings.TrimPrefix(trimline, luaAnnotationAPIVersion)) case strings.HasPrefix(trimline, luaAnnotationKind): into.Spec.Target.Kind = strings.TrimSpace(strings.TrimPrefix(trimline, luaAnnotationKind)) case strings.HasPrefix(trimline, luaAnnotationRule): name := strings.TrimSpace(strings.TrimPrefix(trimline, luaAnnotationRule)) r := rules.Get(name) if r != nil { if currRule != nil { currRule.SetScript(into, script) } currRule = r script = "" } case strings.HasPrefix(trimline, luaCommentPrefix): // comments are skipped default: if currRule == nil { return fmt.Errorf("unexpected line %q", line) } script += line + "\n" } } if currRule != nil { currRule.SetScript(into, script) } return nil } func stripComments(file []byte) []byte { stripped := make([]byte, 0, len(file)) lines := bytes.Split(file, []byte("\n")) for _, line := range lines { trimline := bytes.TrimSpace(line) if bytes.HasPrefix(trimline, []byte(luaCommentPrefix)) && !bytes.HasPrefix(trimline, []byte(luaAnnotationPrefix)) { continue } if len(stripped) != 0 { stripped = append(stripped, '\n') } stripped = append(stripped, line...) } return stripped } // editReason preserves a message about the reason this file must be edited again type editReason struct { head string other []string } // editHeader includes a list of reasons the edit must be retried type editHeader struct { reasons []editReason } // writeTo outputs the current header information into a stream func (h *editHeader) writeTo(w io.Writer) error { writeComment(w, `Please edit the object below. Lines beginning with a '--' will be ignored, and an empty file will abort the edit. If an error occurs while saving this file will be reopened with the relevant failures. `) for _, r := range h.reasons { if len(r.other) > 0 { writeComment(w, r.head+":\n") } else { writeComment(w, r.head+"\n") } for _, o := range r.other { writeComment(w, o+"\n") } fmt.Fprintln(w, luaCommentPrefix) } return nil } // editResults capture the result of an update type editResults struct { header editHeader retryable int notfound int edit []*resource.Info file string } func (r *editResults) addError(err error, info *resource.Info) string { switch { case apierrors.IsInvalid(err): r.edit = append(r.edit, info) reason := editReason{ head: fmt.Sprintf("%s %q was not valid", customizationResourceName, info.Name), } if err, ok := err.(apierrors.APIStatus); ok { if details := err.Status().Details; details != nil { for _, cause := range details.Causes { reason.other = append(reason.other, fmt.Sprintf("%s: %s", cause.Field, cause.Message)) } } } r.header.reasons = append(r.header.reasons, reason) return fmt.Sprintf("error: %s %q is invalid", customizationResourceName, info.Name) case apierrors.IsNotFound(err): r.notfound++ return fmt.Sprintf("error: %s %q could not be found on the server", customizationResourceName, info.Name) default: r.retryable++ return fmt.Sprintf("error: %s %q could not be patched: %v", customizationResourceName, info.Name, err) } } func writeComment(w io.Writer, comment string) { fmt.Fprintf(w, "%s %s", luaCommentPrefix, commentOnLineBreak(comment)) } // commentOnLineBreak returns a string built from the provided string by inserting any necessary '-- ' // characters after '\n' characters, indicating a comment. func commentOnLineBreak(s string) string { r := "" for i, ch := range s { j := i + 1 if j < len(s) && ch == '\n' && s[j] != '-' { r += "\n-- " } else { r += string(ch) } } return r } // editorEnvs returns an ordered list of env vars to check for editor preferences. func editorEnvs() []string { return []string{ "KUBE_EDITOR", "EDITOR", } } // preservedFile writes out a message about the provided file if it exists to the // provided output stream when an error happens. Used to notify the user where // their updates were preserved. func preservedFile(err error, path string, out io.Writer) error { if len(path) > 0 { if _, err := os.Stat(path); !os.IsNotExist(err) { fmt.Fprintf(out, "A copy of your changes has been stored to %q\n", path) } } return err }