package manifest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"reflect"
jsonpatch "github.com/evanphx/json-patch/v5"
jsoniter "github.com/json-iterator/go"
"helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/kube"
authorizationv1 "k8s.io/api/authorization/v1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/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/strategicpatch"
"k8s.io/cli-runtime/pkg/resource"
"sigs.k8s.io/yaml"
)
const (
Helm2TestSuccessHook = "test-success"
Helm3TestHook = "test"
)
type ThreeWayMergeMode string
const (
ThreeWayMergeAuto ThreeWayMergeMode = "auto"
ThreeWayMergeServer ThreeWayMergeMode = "server"
ThreeWayMergeClient ThreeWayMergeMode = "client"
)
var ValidThreeWayMergeModes = []string{
string(ThreeWayMergeAuto),
string(ThreeWayMergeServer),
string(ThreeWayMergeClient),
}
type generateOptions struct {
mergeMode ThreeWayMergeMode
}
type GenerateOption func(*generateOptions)
func WithThreeWayMergeMode(mode ThreeWayMergeMode) GenerateOption {
return func(o *generateOptions) {
o.mergeMode = mode
}
}
func Generate(actionConfig *action.Configuration, originalManifest, targetManifest []byte, opts ...GenerateOption) ([]byte, []byte, error) {
options := generateOptions{mergeMode: ThreeWayMergeAuto}
for _, opt := range opts {
opt(&options)
}
var err error
original, err := actionConfig.KubeClient.Build(bytes.NewBuffer(originalManifest), false)
if err != nil {
return nil, nil, fmt.Errorf("unable to build kubernetes objects from original release manifest: %w", err)
}
target, err := actionConfig.KubeClient.Build(bytes.NewBuffer(targetManifest), false)
if err != nil {
return nil, nil, fmt.Errorf("unable to build kubernetes objects from new release manifest: %w", err)
}
releaseManifest, installManifest := make([]byte, 0), make([]byte, 0)
targetResources := make(map[string]bool)
for _, r := range target {
targetResources[objectKey(r)] = true
}
for _, r := range original {
if !targetResources[objectKey(r)] {
out, _ := yaml.Marshal(r.Object)
releaseManifest = append(releaseManifest, yamlSeparator...)
releaseManifest = append(releaseManifest, out...)
}
}
existingResources := make(map[string]bool)
for _, r := range original {
existingResources[objectKey(r)] = true
}
var toBeCreated kube.ResourceList
for _, r := range target {
if !existingResources[objectKey(r)] {
toBeCreated = append(toBeCreated, r)
}
}
toBeUpdated, err := existingResourceConflict(toBeCreated)
if err != nil {
return nil, nil, fmt.Errorf("rendered manifests contain a resource that already exists. Unable to continue with update: %w", err)
}
_ = toBeUpdated.Visit(func(r *resource.Info, err error) error {
if err != nil {
return err
}
original.Append(r)
return nil
})
fallback := &clientSideFallback{
mode: options.mergeMode,
warn: os.Stderr,
canPatch: patchPermissionCheck(actionConfig),
}
err = target.Visit(func(info *resource.Info, err error) error {
if err != nil {
return err
}
kind := info.Mapping.GroupVersionKind.Kind
helper := resource.NewHelper(info.Client, info.Mapping)
currentObj, err := helper.Get(info.Namespace, info.Name)
if err != nil {
if !apierrors.IsNotFound(err) {
return fmt.Errorf("could not get information about the resource: %w", err)
}
out, _ := yaml.Marshal(info.Object)
installManifest = append(installManifest, yamlSeparator...)
installManifest = append(installManifest, out...)
return nil
}
out, _ := jsoniter.ConfigCompatibleWithStandardLibrary.Marshal(currentObj)
pruneObj, err := deleteStatusAndTidyMetadata(out)
if err != nil {
return fmt.Errorf("prune current obj %q with kind %s: %w", info.Name, kind, err)
}
pruneOut, err := yaml.Marshal(pruneObj)
if err != nil {
return fmt.Errorf("prune current out %q with kind %s: %w", info.Name, kind, err)
}
releaseManifest = append(releaseManifest, yamlSeparator...)
releaseManifest = append(releaseManifest, pruneOut...)
originalInfo := original.Get(info)
if originalInfo == nil {
return fmt.Errorf("could not find %q", info.Name)
}
patch, err := createPatch(originalInfo.Object, currentObj, info)
if err != nil {
return err
}
merged, err := applyPatch(helper, info, patch, out, fallback.currentMode(), fallback.patchDenied)
if err != nil {
return err
}
pruneObj, err = deleteStatusAndTidyMetadata(merged)
if err != nil {
return fmt.Errorf("prune current obj %q with kind %s: %w", info.Name, kind, err)
}
pruneOut, err = yaml.Marshal(pruneObj)
if err != nil {
return fmt.Errorf("prune current out %q with kind %s: %w", info.Name, kind, err)
}
installManifest = append(installManifest, yamlSeparator...)
installManifest = append(installManifest, pruneOut...)
return nil
})
return releaseManifest, installManifest, err
}
type clientSideFallback struct {
mode ThreeWayMergeMode
warn io.Writer
canPatch func(*resource.Info) (bool, error)
}
func (f *clientSideFallback) currentMode() ThreeWayMergeMode {
return f.mode
}
func (f *clientSideFallback) patchDenied(info *resource.Info, cause error) bool {
if f.mode == ThreeWayMergeClient {
return true
}
if apierrors.IsForbidden(cause) && f.canPatch != nil {
if allowed, err := f.canPatch(info); err == nil && allowed {
return false
}
}
f.mode = ThreeWayMergeClient
if _, err := fmt.Fprintf(f.warn, "Not allowed to dry-run the patch against the cluster (%v).\n"+
"Falling back to computing the three-way merge locally for the rest of this run. The diff\n"+
"may deviate from the actual upgrade result because server-side defaulting and mutating\n"+
"webhooks are not applied.\n", cause); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed writing fallback warning: %v\n", err)
}
return true
}
func patchPermissionCheck(actionConfig *action.Configuration) func(*resource.Info) (bool, error) {
client, ok := actionConfig.KubeClient.(*kube.Client)
if !ok || client.Factory == nil {
return nil
}
clientset, err := client.Factory.KubernetesClientSet()
if err != nil {
return nil
}
return func(info *resource.Info) (bool, error) {
review := &authorizationv1.SelfSubjectAccessReview{
Spec: authorizationv1.SelfSubjectAccessReviewSpec{
ResourceAttributes: &authorizationv1.ResourceAttributes{
Namespace: info.Namespace,
Name: info.Name,
Verb: "patch",
Group: info.Mapping.Resource.Group,
Resource: info.Mapping.Resource.Resource,
},
},
}
result, err := clientset.AuthorizationV1().SelfSubjectAccessReviews().
Create(context.Background(), review, metav1.CreateOptions{})
if err != nil {
return false, err
}
return result.Status.Allowed, nil
}
}
type resourcePatch struct {
data []byte
patchType types.PatchType
patchMeta strategicpatch.LookupPatchMeta
versionedObject runtime.Object
originalData []byte
modifiedData []byte
}
func (p *resourcePatch) apply(liveData []byte) ([]byte, error) {
var merged []byte
var err error
switch p.patchType {
case types.MergePatchType:
merged, err = jsonpatch.MergePatch(liveData, p.data)
case types.StrategicMergePatchType:
merged, err = strategicpatch.StrategicMergePatchUsingLookupPatchMeta(liveData, p.data, p.patchMeta)
default:
return nil, fmt.Errorf("unsupported patch type %q", p.patchType)
}
if err != nil {
return nil, err
}
if merged, err = p.normalize(merged); err != nil {
return nil, err
}
if p.patchType == types.MergePatchType {
return merged, nil
}
return p.restoreServerPopulatedFields(merged, liveData)
}
func (p *resourcePatch) normalize(merged []byte) ([]byte, error) {
if p.versionedObject == nil {
return merged, nil
}
objType := reflect.TypeOf(p.versionedObject)
if objType.Kind() != reflect.Pointer {
return merged, nil
}
typed, ok := reflect.New(objType.Elem()).Interface().(runtime.Object)
if !ok {
return merged, nil
}
if err := json.Unmarshal(merged, typed); err != nil {
return nil, fmt.Errorf("decoding the merged object: %w", err)
}
out, err := json.Marshal(typed)
if err != nil {
return nil, fmt.Errorf("encoding the merged object: %w", err)
}
return out, nil
}
func (p *resourcePatch) restoreServerPopulatedFields(merged, liveData []byte) ([]byte, error) {
var mergedObj, liveObj, originalObj, modifiedObj interface{}
for _, in := range []struct {
data []byte
out *interface{}
}{
{merged, &mergedObj},
{liveData, &liveObj},
{p.originalData, &originalObj},
{p.modifiedData, &modifiedObj},
} {
if len(in.data) == 0 {
continue
}
if err := json.Unmarshal(in.data, in.out); err != nil {
return nil, fmt.Errorf("decoding the object to restore defaulted fields: %w", err)
}
}
restored := restoreMissing(mergedObj, liveObj, originalObj, modifiedObj)
out, err := json.Marshal(restored)
if err != nil {
return nil, fmt.Errorf("encoding the object with the restored defaulted fields: %w", err)
}
return out, nil
}
func isEmpty(v interface{}) bool {
switch v := v.(type) {
case nil:
return true
case []interface{}:
return len(v) == 0
case map[string]interface{}:
return len(v) == 0
}
return false
}
func restoreMissing(merged, live, original, modified interface{}) interface{} {
switch live := live.(type) {
case map[string]interface{}:
mergedMap, ok := merged.(map[string]interface{})
if !ok {
return merged
}
originalMap, _ := original.(map[string]interface{})
modifiedMap, _ := modified.(map[string]interface{})
for key, liveValue := range live {
mergedValue, inMerged := mergedMap[key]
if inMerged && isEmpty(mergedValue) && isEmpty(liveValue) {
mergedMap[key] = liveValue
continue
}
if !inMerged {
if originalMap[key] == nil && modifiedMap[key] == nil {
mergedMap[key] = liveValue
}
continue
}
mergedMap[key] = restoreMissing(mergedValue, liveValue, originalMap[key], modifiedMap[key])
}
return mergedMap
case []interface{}:
mergedList, ok := merged.([]interface{})
if !ok || len(mergedList) != len(live) {
return merged
}
originalList, _ := original.([]interface{})
modifiedList, _ := modified.([]interface{})
if len(originalList) != len(mergedList) || !reflect.DeepEqual(originalList, modifiedList) {
return mergedList
}
for i := range mergedList {
mergedList[i] = restoreMissing(mergedList[i], live[i], originalList[i], modifiedList[i])
}
return mergedList
default:
return merged
}
}
func applyPatch(helper *resource.Helper, info *resource.Info, patch *resourcePatch, liveData []byte, mode ThreeWayMergeMode, patchDenied func(*resource.Info, error) bool) ([]byte, error) {
kind := info.Mapping.GroupVersionKind.Kind
if mode != ThreeWayMergeClient {
helper.ServerDryRun = true
targetObj, err := helper.Patch(info.Namespace, info.Name, patch.patchType, patch.data, nil)
switch {
case err == nil:
out, err := jsoniter.ConfigCompatibleWithStandardLibrary.Marshal(targetObj)
if err != nil {
return nil, fmt.Errorf("serializing patched %q with kind %s: %w", info.Name, kind, err)
}
return out, nil
case mode == ThreeWayMergeAuto && isPatchNotAllowed(err):
if !patchDenied(info, err) {
return nil, fmt.Errorf("cannot patch %q with kind %s: %w", info.Name, kind, err)
}
default:
return nil, fmt.Errorf("cannot patch %q with kind %s: %w", info.Name, kind, err)
}
}
out, err := patch.apply(liveData)
if err != nil {
return nil, fmt.Errorf("cannot merge %q with kind %s: %w", info.Name, kind, err)
}
return out, nil
}
func isPatchNotAllowed(err error) bool {
return apierrors.IsForbidden(err) || apierrors.IsMethodNotSupported(err)
}
func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) (*resourcePatch, error) {
oldData, err := json.Marshal(originalObj)
if err != nil {
return nil, fmt.Errorf("serializing original configuration: %w", err)
}
newData, err := json.Marshal(target.Object)
if err != nil {
return nil, fmt.Errorf("serializing target configuration: %w", err)
}
currentData, err := json.Marshal(currentObj)
if err != nil {
return nil, fmt.Errorf("serializing live configuration: %w", err)
}
versionedObject := kube.AsVersioned(target)
_, isUnstructured := versionedObject.(runtime.Unstructured)
_, isCRD := versionedObject.(*apiextv1.CustomResourceDefinition)
patch := &resourcePatch{originalData: oldData, modifiedData: newData}
if !isUnstructured {
patch.versionedObject = versionedObject
}
if isUnstructured || isCRD {
cleanedOldData, err := cleanMetadataForPatch(oldData)
if err != nil {
return nil, fmt.Errorf("cleaning old metadata: %w", err)
}
cleanedNewData, err := cleanMetadataForPatch(newData)
if err != nil {
return nil, fmt.Errorf("cleaning new metadata: %w", err)
}
cleanedCurrentData, err := cleanMetadataForPatch(currentData)
if err != nil {
return nil, fmt.Errorf("cleaning current metadata: %w", err)
}
chartChanges, err := jsonpatch.CreateMergePatch(cleanedOldData, cleanedNewData)
if err != nil {
return nil, fmt.Errorf("creating chart changes patch: %w", err)
}
chartChanged := !isPatchEmpty(chartChanges)
if chartChanged {
mergedData, err := jsonpatch.MergePatch(cleanedCurrentData, chartChanges)
if err != nil {
return nil, fmt.Errorf("applying chart changes to current: %w", err)
}
patch.data, err = jsonpatch.CreateMergePatch(cleanedCurrentData, mergedData)
if err != nil {
return nil, fmt.Errorf("creating patch from current to merged: %w", err)
}
patch.patchType = types.MergePatchType
return patch, nil
}
desiredData, err := jsonpatch.MergePatch(cleanedCurrentData, cleanedNewData)
if err != nil {
return nil, fmt.Errorf("building desired state: %w", err)
}
patch.data, err = jsonpatch.CreateMergePatch(cleanedCurrentData, desiredData)
if err != nil {
return nil, fmt.Errorf("creating patch from current to desired: %w", err)
}
patch.patchType = types.MergePatchType
return patch, nil
}
patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject)
if err != nil {
return nil, fmt.Errorf("unable to create patch metadata from object: %w", err)
}
patch.data, err = strategicpatch.CreateThreeWayMergePatch(oldData, newData, currentData, patchMeta, true)
if err != nil {
return nil, err
}
patch.patchType = types.StrategicMergePatchType
patch.patchMeta = patchMeta
return patch, nil
}
func isPatchEmpty(patch []byte) bool {
return len(patch) == 0 || string(patch) == "{}" || string(patch) == "null"
}
func cleanMetadataForPatch(data []byte) ([]byte, error) {
objMap, err := deleteStatusAndTidyMetadata(data)
if err != nil {
return nil, err
}
if objMap == nil {
return []byte("null"), nil
}
return json.Marshal(objMap)
}
func objectKey(r *resource.Info) string {
gvk := r.Object.GetObjectKind().GroupVersionKind()
return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name)
}
func existingResourceConflict(resources kube.ResourceList) (kube.ResourceList, error) {
var requireUpdate kube.ResourceList
err := resources.Visit(func(info *resource.Info, err error) error {
if err != nil {
return err
}
helper := resource.NewHelper(info.Client, info.Mapping)
_, err = helper.Get(info.Namespace, info.Name)
if err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return fmt.Errorf("could not get information about the resource: %w", err)
}
requireUpdate.Append(info)
return nil
})
return requireUpdate, err
}