pkg/options/patchtmpl/patch.go (267 lines of code) (raw):

// Copyright 2018 Google LLC // // 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 // // https://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 patchtmpl import ( "bytes" "errors" "fmt" "strconv" "text/template" bundle "github.com/GoogleCloudPlatform/k8s-cluster-bundle/pkg/apis/bundle/v1alpha1" "github.com/GoogleCloudPlatform/k8s-cluster-bundle/pkg/converter" "github.com/GoogleCloudPlatform/k8s-cluster-bundle/pkg/filter" "github.com/GoogleCloudPlatform/k8s-cluster-bundle/pkg/internal" "github.com/GoogleCloudPlatform/k8s-cluster-bundle/pkg/options" "github.com/GoogleCloudPlatform/k8s-cluster-bundle/pkg/options/openapi" jsonpatch "github.com/evanphx/json-patch" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/strategicpatch" ) // ApplierConfig is a config option that can be passed to NewApplier. type ApplierConfig func(*applier) // applier applies options via patch-templates type applier struct { scheme *PatcherScheme tmplFilter *filter.Options // If includeTemplates is true, applied patch templates will be included in the // component objects. includeTemplates bool // Set the template options templateOpts []string } // WithPatcherScheme modifies NewApplierWithConfig so that the returned Applier // uses the specified patcher scheme. func WithPatcherScheme(pt *PatcherScheme) ApplierConfig { return func(a *applier) { a.scheme = pt } } // WithGoTmplOptions modifies NewApplierWithConfig so that the returned Applier // uses the specified text/template options. func WithGoTmplOptions(goTmplOptions ...string) ApplierConfig { return func(a *applier) { a.templateOpts = append(a.templateOpts, goTmplOptions...) } } // WithFilterOpts modifies NewApplierWithConfig so that the returned Applier // uses the specified filter options. func WithFilterOpts(filterOpts *filter.Options) ApplierConfig { return func(a *applier) { a.tmplFilter = filterOpts } } // WithIncludeTemplates modifies NewApplierWithConfig so that the returned // Applier to include or not include patch templates. func WithIncludeTemplates(include bool) ApplierConfig { return func(a *applier) { a.includeTemplates = include } } // NewApplierWithConfig creates a new options applier instance with the // specified config options. func NewApplierWithConfig(opts ...ApplierConfig) options.Applier { a := &applier{ scheme: DefaultPatcherScheme(), tmplFilter: nil, includeTemplates: false, templateOpts: []string{}, } for _, opt := range opts { opt(a) } if len(a.templateOpts) == 0 { a.templateOpts = []string{options.MissingKeyError} } return a } // NewApplier creates a new options applier instance. The filter indicates // keep-only options for what subsets of patches to look for. func NewApplier(pt *PatcherScheme, opts *filter.Options, includeTemplates bool, templateOpts ...string) options.Applier { return NewApplierWithConfig( WithPatcherScheme(pt), WithFilterOpts(opts), WithIncludeTemplates(includeTemplates), WithGoTmplOptions(templateOpts...), ) } // NewDefaultApplier creates a default patch template applier. func NewDefaultApplier() options.Applier { return NewApplierWithConfig() } // ApplyOptions looks for PatchTemplates and applies them to the component objects. func (a *applier) ApplyOptions(comp *bundle.Component, p options.JSONOptions) (*bundle.Component, error) { comp = comp.DeepCopy() patchTemplates, objects := a.getPatchTemplates(comp) if len(patchTemplates) < 1 { return comp, nil } patches, objs, err := a.makePatches(patchTemplates, objects, p) if err != nil { return nil, err } newObjs, err := options.ApplyCommon(comp.ComponentReference(), objs, p, objectApplier(a.scheme, patches)) comp.Spec.Objects = newObjs return comp, err } // A parsedPatch has had options applied and has been converted both into raw // bytes and unstructured. type parsedPatch struct { // raw parsed patch. raw []byte // the parsed patch as a JSON Map jsonMap map[string]interface{} // selector for determining which objects to patch. selector *bundle.ObjectSelector // type of merging logic to use for the patch patchType bundle.PatchType } // String returns the string form of the parsedPatch. func (p *parsedPatch) String() string { return string(p.raw) } // getPatchTemplates returns all patch templates fom particular component func (a *applier) getPatchTemplates(comp *bundle.Component) ([]*unstructured.Unstructured, []*unstructured.Unstructured) { tfil := a.tmplFilter if tfil == nil { tfil = &filter.Options{} } tfil.Kinds = []string{"PatchTemplate"} // Filter all the objects to include just the patch templates + any additional values. ptObjs, objs := filter.NewFilter().PartitionObjects(comp.Spec.Objects, tfil) // PartitionObjects will exclude *matching* patch templates from objs; if we actually // want to include them, then use the original component objects for our object list if a.includeTemplates { objs = comp.Spec.Objects } return ptObjs, objs } func (a *applier) makePatches(ptObjs, objs []*unstructured.Unstructured, opts options.JSONOptions) ([]*parsedPatch, []*unstructured.Unstructured, error) { // First parse the objects back into go-objects. var pts []*bundle.PatchTemplate for _, o := range ptObjs { pto := &bundle.PatchTemplate{} err := converter.FromUnstructured(o).ToObject(pto) if err != nil { return nil, nil, fmt.Errorf("while converting object %v to PatchTemplate: %v", pto, err) } pts = append(pts, pto) } if opts == nil { opts = options.JSONOptions{} } // Next, de-templatize the templates. var patches []*parsedPatch for j, pto := range pts { patchType := bundle.PatchType(pto.PatchType) switch patchType { case bundle.StrategicMergePatch, bundle.JSONPatch: // known types case "": // use default patchType = bundle.StrategicMergePatch default: return nil, nil, fmt.Errorf("bad patch type: %s", patchType) } useSafeYAMLTemplater := internal.HasSafeYAMLAnnotation(pto.ObjectMeta) tmpl, err := internal.NewTemplater(fmt.Sprintf("patch-tmpl-%d", j), pto.Template, patchFuncs, useSafeYAMLTemplater) if err != nil { return nil, nil, fmt.Errorf("parsing patch template %d, %s: %v", j, pto.Template, err) } tmpl = tmpl.Option(a.templateOpts...) newOpts := opts if pto.OptionsSchema != nil { newOpts, err = openapi.ApplyDefaults(opts, pto.OptionsSchema) if err != nil { return nil, nil, fmt.Errorf("applying schema defaults for patch template %d, %s: %v", j, pto.Template, err) } } // Detemplatize the patch var buf bytes.Buffer err = tmpl.Execute(&buf, newOpts) if err != nil { return nil, nil, fmt.Errorf("while applying options to patch template %d: %v", j, err) } // Convert the patch into a JSONMap to prepare for Strategic Merge Patch. by := buf.Bytes() jsonMap := make(map[string]interface{}) err = converter.FromYAML(by).ToObject(&jsonMap) if err != nil { return nil, nil, fmt.Errorf("while converting patch template %d: %v", j, err) } // Neither Kind nor APIVersion are allowed as patchable fields in a // PatchTemplate -- we don't want to change the schema of the objects we're // patching. So, instead remove them from the PatchTemplate and add them as // an additional selector parameter (which supports the previous behavior). pKind := "" pAPIVersion := "" if jsonMap["kind"] != nil { var ok bool pKind, ok = jsonMap["kind"].(string) if !ok { return nil, nil, fmt.Errorf("found non-string type %T for Kind field for patch %s", jsonMap["kind"], string(by)) } delete(jsonMap, "kind") } if jsonMap["apiVersion"] != nil { var ok bool pAPIVersion, ok = jsonMap["apiVersion"].(string) if !ok { return nil, nil, fmt.Errorf("found a non-string APIVersion field for patch %s", string(by)) } delete(jsonMap, "apiVersion") } selector := pto.Selector if pKind != "" { if selector == nil { selector = &bundle.ObjectSelector{} } if pAPIVersion != "" { pKind = pAPIVersion + "," + pKind } selector.Kinds = append(selector.Kinds, pKind) } patches = append(patches, &parsedPatch{ raw: by, jsonMap: jsonMap, selector: selector, patchType: patchType, }) } return patches, objs, nil } // objectApplier creates a patch object-handler. For each patch, the object // applier function checks whether a patch can be applied, and if so, then // applies it. func objectApplier(scheme *PatcherScheme, patches []*parsedPatch) options.ObjHandler { return func(obj *unstructured.Unstructured, ref bundle.ComponentReference, _ options.JSONOptions) ([]*unstructured.Unstructured, error) { objJSON := obj.Object if obj.GetKind() == "PatchTemplate" { // Don't process PatchTemplates (possible if includeTemplates is set). In // other words, it's not allowed to apply patch templates to patch // templates. return []*unstructured.Unstructured{obj}, nil } // TODO(kashomon): Is there a faster way to convert from JSON-Map to string? objByt, err := converter.FromObject(objJSON).ToJSON() if err != nil { // This would be pretty unlikely return nil, err } deserializer := scheme.Codecs.UniversalDeserializer() kubeObj, decodeErr := runtime.Decode(deserializer, objByt) _, isUnstructured := kubeObj.(*unstructured.Unstructured) strategicWillFail := runtime.IsNotRegisteredError(decodeErr) || isUnstructured objSchema, objSchemaErr := strategicpatch.NewPatchMetaFromStruct(kubeObj) for _, pat := range patches { if !canApplyPatch(pat, obj) { continue } var newObjJSON map[string]interface{} switch pat.patchType { case bundle.JSONPatch: if oByt, err := converter.FromObject(objJSON).ToJSON(); err != nil { return nil, fmt.Errorf("while converting JSON obj\n%s to bytes: %v", objJSON, err) } else if pByt, err := converter.FromObject(pat.jsonMap).ToJSON(); err != nil { return nil, fmt.Errorf("while converting patch JSON obj\n%s to bytes: %v", pat.jsonMap, err) } else if newObjByt, err := jsonpatch.MergePatch(oByt, pByt); err != nil { return nil, fmt.Errorf("while applying JSON merge patch\n%s to \n%s: %v", pat.raw, oByt, err) } else if newObjJSON, err = converter.FromJSON(newObjByt).ToJSONMap(); err != nil { return nil, fmt.Errorf("while converting bytes\n%s to JSON: %v", newObjByt, err) } case bundle.StrategicMergePatch: if strategicWillFail { // Strategic merge patch can't handle unstructured.Unstructured or // unregistered objects, so return an error. return nil, fmt.Errorf("while converting object %q of kind %q and apiVersion %q: type not registered in scheme", obj.GetName(), obj.GetKind(), obj.GetAPIVersion()) } if objSchemaErr != nil { return nil, fmt.Errorf("while getting patch meta from object %s: %v", string(objByt), objSchemaErr) } if newObjJSON, err = strategicpatch.StrategicMergeMapPatchUsingLookupPatchMeta(objJSON, pat.jsonMap, objSchema); err != nil { return nil, fmt.Errorf("while applying strategic merge patch\n%sto \n%s: %v", pat.raw, objJSON, err) } default: return nil, fmt.Errorf("unknown patch type: %s", pat.patchType) } objJSON = newObjJSON } obj = &unstructured.Unstructured{ Object: runtime.DeepCopyJSON(objJSON), } return []*unstructured.Unstructured{obj}, nil } } // canApplyPatch determines whether a patch can be applied to an object. It // checks to ensure that if the patch defines a name, func canApplyPatch(pat *parsedPatch, obj *unstructured.Unstructured) bool { return filter.MatchesObject(obj, filter.OptionsFromObjectSelector(pat.selector)) } var floatConversionError = errors.New("error converting to float") // convertToFloat is a helper function that can be used at during func convertToFloat(val interface{}) (float64, error) { switch v := val.(type) { case int: return float64(v), nil case int32: return float64(v), nil case int64: return float64(v), nil case string: return strconv.ParseFloat(v, 64) case float32: return float64(v), nil case float64: return v, nil default: return 0, fmt.Errorf("%w; value %v with type %T could not be converted to float", floatConversionError, val, v) } } var patchFuncs template.FuncMap = map[string]interface{}{ "convertAnyToFloat": convertToFloat, }