github.com/opendevstack/tailor@v1.3.5-0.20220119161809-cab064e60a67/pkg/openshift/item.go (about)

     1  package openshift
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"regexp"
     7  	"strconv"
     8  	"strings"
     9  
    10  	"github.com/ghodss/yaml"
    11  	"github.com/opendevstack/tailor/pkg/cli"
    12  	"github.com/opendevstack/tailor/pkg/utils"
    13  	"github.com/xeipuuv/gojsonpointer"
    14  )
    15  
    16  var (
    17  	annotationsPath             = "/metadata/annotations"
    18  	platformManagedSimpleFields = []string{
    19  		"/groupNames",
    20  		"/imagePullSecrets",
    21  		"/metadata/creationTimestamp",
    22  		"/metadata/generation",
    23  		"/metadata/managedFields",
    24  		"/metadata/namespace",
    25  		"/metadata/resourceVersion",
    26  		"/metadata/selfLink",
    27  		"/metadata/uid",
    28  		"/secrets",
    29  		"/spec/clusterIP",
    30  		"/spec/clusterIPs",
    31  		"/spec/jobTemplate/metadata/creationTimestamp",
    32  		"/spec/jobTemplate/spec/template/metadata/creationTimestamp",
    33  		"/spec/selector/matchLabels/controller-uid",
    34  		"/spec/tags",
    35  		"/spec/template/metadata/creationTimestamp",
    36  		"/spec/template/metadata/labels/controller-uid",
    37  		"/spec/volumeName",
    38  		"/status",
    39  		"/userNames",
    40  	}
    41  	platformManagedRegexFields = []string{
    42  		"^/spec/triggers/[0-9]*/imageChangeParams/lastTriggeredImage",
    43  	}
    44  	immutableFields = map[string][]string{
    45  		"PersistentVolumeClaim": {
    46  			"/spec/accessModes",
    47  			"/spec/storageClassName",
    48  			"/spec/resources/requests/storage",
    49  		},
    50  		"Route": {
    51  			"/spec/host",
    52  		},
    53  		"Secret": {
    54  			"/type",
    55  		},
    56  	}
    57  
    58  	KindMapping = map[string]string{
    59  		"svc":                   "Service",
    60  		"service":               "Service",
    61  		"route":                 "Route",
    62  		"dc":                    "DeploymentConfig",
    63  		"deploymentconfig":      "DeploymentConfig",
    64  		"deployment":            "Deployment",
    65  		"bc":                    "BuildConfig",
    66  		"buildconfig":           "BuildConfig",
    67  		"is":                    "ImageStream",
    68  		"imagestream":           "ImageStream",
    69  		"pvc":                   "PersistentVolumeClaim",
    70  		"persistentvolumeclaim": "PersistentVolumeClaim",
    71  		"template":              "Template",
    72  		"cm":                    "ConfigMap",
    73  		"configmap":             "ConfigMap",
    74  		"secret":                "Secret",
    75  		"rolebinding":           "RoleBinding",
    76  		"serviceaccount":        "ServiceAccount",
    77  		"cronjob":               "CronJob",
    78  		"cj":                    "CronJob",
    79  		"job":                   "Job",
    80  		"limitrange":            "LimitRange",
    81  		"resourcequota":         "ResourceQuota",
    82  		"quota":                 "ResourceQuota",
    83  		"hpa":                   "HorizontalPodAutoscaler",
    84  		"statefulset":           "StatefulSet",
    85  	}
    86  )
    87  
    88  type ResourceItem struct {
    89  	Source                   string
    90  	Kind                     string
    91  	Name                     string
    92  	Labels                   map[string]interface{}
    93  	Annotations              map[string]interface{}
    94  	Paths                    []string
    95  	Config                   map[string]interface{}
    96  	AnnotationsPresent       bool
    97  	LastAppliedConfiguration map[string]interface{}
    98  	LastAppliedAnnotations   map[string]interface{}
    99  	Comparable               bool
   100  }
   101  
   102  func NewResourceItem(m map[string]interface{}, source string) (*ResourceItem, error) {
   103  	item := &ResourceItem{Source: source}
   104  	err := item.parseConfig(m)
   105  	return item, err
   106  }
   107  
   108  // FullName returns kind/name, with kind being the long form (e.g. "DeploymentConfig").
   109  func (i *ResourceItem) FullName() string {
   110  	return i.Kind + "/" + i.Name
   111  }
   112  
   113  // ShortName returns kind/name, with kind being the shortest possible
   114  // reference of kind (e.g. "dc" for "DeploymentConfig").
   115  func (i *ResourceItem) ShortName() string {
   116  	return kindToShortMapping[i.Kind] + "/" + i.Name
   117  }
   118  
   119  func (i *ResourceItem) HasLabel(label string) bool {
   120  	labelParts := strings.Split(label, "=")
   121  	if _, ok := i.Labels[labelParts[0]]; !ok {
   122  		return false
   123  	} else if i.Labels[labelParts[0]].(string) != labelParts[1] {
   124  		return false
   125  	}
   126  	return true
   127  }
   128  
   129  func (i *ResourceItem) DesiredConfig() (string, error) {
   130  	y, _ := yaml.Marshal(i.Config)
   131  	return string(y), nil
   132  }
   133  
   134  func (i *ResourceItem) YamlConfig() string {
   135  	y, _ := yaml.Marshal(i.Config)
   136  	return string(y)
   137  }
   138  
   139  // parseConfig uses the config to initialise an item. The logic is the same
   140  // for template and platform items, with no knowledge of the "other" item - it
   141  // may or may not exist.
   142  func (i *ResourceItem) parseConfig(m map[string]interface{}) error {
   143  	// Extract kind
   144  	kindPointer, _ := gojsonpointer.NewJsonPointer("/kind")
   145  	kind, _, err := kindPointer.Get(m)
   146  	if err != nil {
   147  		return err
   148  	}
   149  	i.Kind = kind.(string)
   150  
   151  	// Extract name
   152  	namePointer, _ := gojsonpointer.NewJsonPointer("/metadata/name")
   153  	name, _, noNameErr := namePointer.Get(m)
   154  	if noNameErr == nil {
   155  		i.Name = name.(string)
   156  	} else {
   157  		generateNamePointer, _ := gojsonpointer.NewJsonPointer("/metadata/generateName")
   158  		generateName, _, err := generateNamePointer.Get(m)
   159  		if err != nil {
   160  			return fmt.Errorf("Resource does not have paths /metadata/name or /metadata/generateName: %s", err)
   161  		}
   162  		i.Name = generateName.(string)
   163  	}
   164  
   165  	// Determine if item is comparable and therefore relevant for Tailor
   166  	i.Comparable = true
   167  	// Secrets of type "kubernetes.io/dockercfg" and
   168  	// "kubernetes.io/service-account-token" were not returned in "oc export".
   169  	// Those secrets are generated by OpenShift automatically and should not
   170  	// be controlled by Tailor.
   171  	if i.Kind == "Secret" {
   172  		typePointer, _ := gojsonpointer.NewJsonPointer("/type")
   173  		typeVal, _, err := typePointer.Get(m)
   174  		if err != nil {
   175  			return fmt.Errorf("Secret has no field /type: %s", err)
   176  		}
   177  		irrelevantSecrets := []string{
   178  			"kubernetes.io/dockercfg",
   179  			"kubernetes.io/service-account-token",
   180  		}
   181  		if utils.Includes(irrelevantSecrets, typeVal.(string)) {
   182  			i.Comparable = false
   183  			cli.DebugMsg(
   184  				"Removed secret",
   185  				i.Name,
   186  				"of type",
   187  				typeVal.(string),
   188  				"as it cannot be compared properly",
   189  			)
   190  		}
   191  	}
   192  
   193  	// Extract labels
   194  	labelsPointer, _ := gojsonpointer.NewJsonPointer("/metadata/labels")
   195  	labels, _, err := labelsPointer.Get(m)
   196  	if err != nil {
   197  		i.Labels = make(map[string]interface{})
   198  	} else {
   199  		i.Labels = labels.(map[string]interface{})
   200  	}
   201  
   202  	// Extract annotations
   203  	annotationsPointer, _ := gojsonpointer.NewJsonPointer("/metadata/annotations")
   204  	annotations, _, err := annotationsPointer.Get(m)
   205  	i.Annotations = make(map[string]interface{})
   206  	i.AnnotationsPresent = false
   207  	if err == nil {
   208  		i.AnnotationsPresent = true
   209  		for k, v := range annotations.(map[string]interface{}) {
   210  			i.Annotations[k] = v
   211  		}
   212  	}
   213  
   214  	i.LastAppliedConfiguration = make(map[string]interface{})
   215  	i.LastAppliedAnnotations = make(map[string]interface{})
   216  
   217  	// kubectl.kubernetes.io/last-applied-configuration
   218  	lastAppliedConfigurationPointer, _ := gojsonpointer.NewJsonPointer("/metadata/annotations/kubectl.kubernetes.io~1last-applied-configuration")
   219  	lastAppliedConfiguration, _, err := lastAppliedConfigurationPointer.Get(m)
   220  	if err == nil {
   221  		s := lastAppliedConfiguration.(string)
   222  		var f interface{}
   223  		err := json.Unmarshal([]byte(s), &f)
   224  		if err != nil {
   225  			return err
   226  		}
   227  		lac := f.(map[string]interface{})
   228  		i.LastAppliedConfiguration = lac
   229  	}
   230  	// kubectl.kubernetes.io/last-applied-configuration -> annotations
   231  	lastAppliedAnnotationsPointer, _ := gojsonpointer.NewJsonPointer("/metadata/annotations")
   232  	lastAppliedAnnotations, _, err := lastAppliedAnnotationsPointer.Get(i.LastAppliedConfiguration)
   233  	if err == nil {
   234  		i.LastAppliedAnnotations = lastAppliedAnnotations.(map[string]interface{})
   235  	}
   236  
   237  	// kubectl.kubernetes.io/last-applied-configuration -> container images
   238  	// get all container image definitions, and paste them into the spec.
   239  	if i.Kind == "DeploymentConfig" {
   240  		containerSpecsPointer, _ := gojsonpointer.NewJsonPointer("/spec/template/spec/containers")
   241  		appliedContainerSpecs, _, err := containerSpecsPointer.Get(i.LastAppliedConfiguration)
   242  		if err == nil {
   243  			for i, val := range appliedContainerSpecs.([]interface{}) {
   244  				acs := val.(map[string]interface{})
   245  				if appliedImageVal, ok := acs["image"]; ok {
   246  					_, _, err := containerSpecsPointer.Get(m)
   247  					if err == nil {
   248  						imagePointer, _ := gojsonpointer.NewJsonPointer(fmt.Sprintf("/spec/template/spec/containers/%d/image", i))
   249  						_, err := imagePointer.Set(m, appliedImageVal)
   250  						if err != nil {
   251  							cli.VerboseMsg("could not apply:", err.Error())
   252  						}
   253  					}
   254  				}
   255  			}
   256  		} else { // backwards compatibility for pre 0.13.0
   257  			tailorAppliedConfigAnnotation := "tailor.opendevstack.org/applied-config"
   258  			escapedTailorAppliedConfigAnnotation := strings.Replace(tailorAppliedConfigAnnotation, "/", "~1", -1)
   259  			tailorAppliedConfigAnnotationPath := annotationsPath + "/" + escapedTailorAppliedConfigAnnotation
   260  
   261  			tailorAppliedConfigAnnotationPointer, err := gojsonpointer.NewJsonPointer(tailorAppliedConfigAnnotationPath)
   262  			if err != nil {
   263  				return fmt.Errorf("Could not create JSON pointer %s: %s", tailorAppliedConfigAnnotationPath, err)
   264  			}
   265  			val, _, err := tailorAppliedConfigAnnotationPointer.Get(m)
   266  			if err == nil {
   267  				valBytes := []byte(val.(string))
   268  				tacFields := map[string]string{}
   269  				err = json.Unmarshal(valBytes, &tacFields)
   270  				if err != nil {
   271  					return fmt.Errorf("Could not unmarshal JSON %s: %s", tailorAppliedConfigAnnotationPath, val)
   272  				}
   273  				for k, v := range tacFields {
   274  					specPointer, err := gojsonpointer.NewJsonPointer(k)
   275  					if err != nil {
   276  						return fmt.Errorf("Could not create JSON pointer %s: %s", k, err)
   277  					}
   278  					_, err = specPointer.Set(m, v)
   279  					if err != nil {
   280  						return fmt.Errorf("Could not set %s: %s", k, err)
   281  					}
   282  				}
   283  				_, err = tailorAppliedConfigAnnotationPointer.Delete(m)
   284  				if err != nil {
   285  					return fmt.Errorf("Could not delete %s: %s", tailorAppliedConfigAnnotationPath, err)
   286  				}
   287  			}
   288  			delete(i.Annotations, tailorAppliedConfigAnnotation)
   289  		}
   290  	}
   291  
   292  	// Remove platform-managed simple fields
   293  	legacyFields := []string{"/userNames", "/groupNames"}
   294  	for _, p := range platformManagedSimpleFields {
   295  		deletePointer, _ := gojsonpointer.NewJsonPointer(p)
   296  		_, _ = deletePointer.Delete(m)
   297  		if utils.Includes(legacyFields, p) {
   298  			cli.DebugMsg("Removed", p, "which is used for legacy clients, but not supported by Tailor")
   299  		}
   300  	}
   301  
   302  	i.Config = m
   303  
   304  	// Build list of JSON pointers
   305  	i.walkMap(m, "")
   306  
   307  	// Iterate over extracted paths and massage as necessary
   308  	newPaths := []string{}
   309  	deletedPathIndices := []int{}
   310  	for pathIndex, path := range i.Paths {
   311  
   312  		// Remove platform-managed regex fields
   313  		for _, platformManagedField := range platformManagedRegexFields {
   314  			matched, _ := regexp.MatchString(platformManagedField, path)
   315  			if matched {
   316  				deletePointer, _ := gojsonpointer.NewJsonPointer(path)
   317  				_, _ = deletePointer.Delete(i.Config)
   318  				deletedPathIndices = append(deletedPathIndices, pathIndex)
   319  			}
   320  		}
   321  	}
   322  
   323  	// As we delete items from a slice, we need to adjust the pre-calculated
   324  	// indices to delete (shift to left by one for each deletion).
   325  	indexOffset := 0
   326  	for _, pathIndex := range deletedPathIndices {
   327  		deletionIndex := pathIndex + indexOffset
   328  		cli.DebugMsg("Removing platform managed path", i.Paths[deletionIndex])
   329  		i.Paths = append(i.Paths[:deletionIndex], i.Paths[deletionIndex+1:]...)
   330  		indexOffset = indexOffset - 1
   331  	}
   332  	if len(newPaths) > 0 {
   333  		i.Paths = append(i.Paths, newPaths...)
   334  	}
   335  
   336  	return nil
   337  }
   338  
   339  func (i *ResourceItem) isImmutableField(field string) bool {
   340  	for _, key := range immutableFields[i.Kind] {
   341  		if key == field {
   342  			return true
   343  		}
   344  	}
   345  	return false
   346  }
   347  
   348  func (i *ResourceItem) walkMap(m map[string]interface{}, pointer string) {
   349  	for k, v := range m {
   350  		i.handleKeyValue(k, v, pointer)
   351  	}
   352  }
   353  
   354  func (i *ResourceItem) walkArray(a []interface{}, pointer string) {
   355  	for k, v := range a {
   356  		i.handleKeyValue(k, v, pointer)
   357  	}
   358  }
   359  
   360  func (i *ResourceItem) handleKeyValue(k interface{}, v interface{}, pointer string) {
   361  
   362  	strK := ""
   363  	switch kv := k.(type) {
   364  	case string:
   365  		strK = kv
   366  	case int:
   367  		strK = strconv.Itoa(kv)
   368  	}
   369  
   370  	relativePointer := utils.JSONPointerPath(strK)
   371  	absolutePointer := pointer + "/" + relativePointer
   372  	i.Paths = append(i.Paths, absolutePointer)
   373  
   374  	switch vv := v.(type) {
   375  	case []interface{}:
   376  		i.walkArray(vv, absolutePointer)
   377  	case map[string]interface{}:
   378  		i.walkMap(vv, absolutePointer)
   379  	}
   380  }
   381  
   382  func (i *ResourceItem) removeAnnotion(annotation string) {
   383  	path := "/metadata/annotations/" + utils.JSONPointerPath(annotation)
   384  	deletePointer, _ := gojsonpointer.NewJsonPointer(path)
   385  	_, err := deletePointer.Delete(i.Config)
   386  	if err != nil {
   387  		cli.DebugMsg(fmt.Sprintf("Could not remove annotation %s from item", annotation))
   388  	}
   389  }
   390  
   391  // prepareForComparisonWithPlatformItem massages template item in such a way
   392  // that it can be compared with the given platform item:
   393  // - copy value from platformItem to templateItem for externally modified paths
   394  func (templateItem *ResourceItem) prepareForComparisonWithPlatformItem(platformItem *ResourceItem, preservePaths []string) error {
   395  	for _, path := range preservePaths {
   396  		cli.DebugMsg("Trying to preserve path", path, "in platform item", platformItem.FullName())
   397  		pathPointer, _ := gojsonpointer.NewJsonPointer(path)
   398  		platformItemVal, _, err := pathPointer.Get(platformItem.Config)
   399  		if err != nil {
   400  			cli.DebugMsg("No such path", path, "in platform item", platformItem.FullName())
   401  			// As the current state for this path is "undefined" we need to make
   402  			// sure that the desired state does not define any value for it,
   403  			// otherwise it will show in the diff.
   404  			_, _ = pathPointer.Delete(templateItem.Config)
   405  		} else {
   406  			_, err = pathPointer.Set(templateItem.Config, platformItemVal)
   407  			if err != nil {
   408  				cli.DebugMsg(fmt.Sprintf(
   409  					"Could not set %s to %v in template item %s",
   410  					path,
   411  					platformItemVal,
   412  					templateItem.FullName(),
   413  				))
   414  			} else {
   415  				// Add preserved path and its subpaths to the paths slice
   416  				// of the template item.
   417  				templateItem.Paths = append(templateItem.Paths, path)
   418  				switch vv := platformItemVal.(type) {
   419  				case []interface{}:
   420  					templateItem.walkArray(vv, path)
   421  				case map[string]interface{}:
   422  					templateItem.walkMap(vv, path)
   423  				}
   424  			}
   425  		}
   426  	}
   427  
   428  	return nil
   429  }
   430  
   431  // prepareForComparisonWithTemplateItem massages platform item in such a way
   432  // that it can be compared with the given template item:
   433  // - remove all annotations which are not managed
   434  func (platformItem *ResourceItem) prepareForComparisonWithTemplateItem(templateItem *ResourceItem) error {
   435  	// Fix apiVersion
   436  	// When running "oc process" on a template with a "Deployment" in
   437  	// "apps/v1", and then running "oc export", the export contains
   438  	// "apiVersion=extensions/v1beta1". If "oc process" is run *after*
   439  	// "oc export", this issue is not present. Tailor runs "oc process" first
   440  	// because it uncovers potential issues with local, desired state.
   441  	// Therefore, we use the last applied apiVersion if we find
   442  	// "apiVersion=extensions/v1beta1" so that no drift is reported.
   443  	apiVersionPath := "/apiVersion"
   444  	apiVersionPointer, _ := gojsonpointer.NewJsonPointer(apiVersionPath)
   445  	apiVersion, _, err := apiVersionPointer.Get(platformItem.Config)
   446  	if err == nil {
   447  		lastAppliedAPIVersion, _, err := apiVersionPointer.Get(platformItem.LastAppliedConfiguration)
   448  		if err == nil {
   449  			if apiVersion.(string) == "extensions/v1beta1" {
   450  				_, err := apiVersionPointer.Set(platformItem.Config, lastAppliedAPIVersion)
   451  				if err != nil {
   452  					cli.DebugMsg("could not set apiVersion:", err.Error())
   453  				}
   454  			}
   455  		}
   456  	}
   457  
   458  	// Annotations
   459  	unmanagedAnnotations := []string{}
   460  	for a := range platformItem.Annotations {
   461  		if _, ok := templateItem.Annotations[a]; ok {
   462  			continue
   463  		}
   464  		if _, ok := platformItem.LastAppliedAnnotations[a]; ok {
   465  			continue
   466  		}
   467  		unmanagedAnnotations = append(unmanagedAnnotations, a)
   468  	}
   469  	for _, a := range unmanagedAnnotations {
   470  		path := "/metadata/annotations/" + utils.JSONPointerPath(a)
   471  		deletePointer, _ := gojsonpointer.NewJsonPointer(path)
   472  		_, err := deletePointer.Delete(platformItem.Config)
   473  		if err != nil {
   474  			return fmt.Errorf("Could not delete %s from configuration", path)
   475  		}
   476  		platformItem.Paths = utils.Remove(platformItem.Paths, path)
   477  	}
   478  
   479  	return nil
   480  }