karmada/pkg/karmadactl/interpret/edit.go

525 lines
15 KiB
Go

/*
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
}