Skip to content

Commit

Permalink
Adding support to get Yaml for k8s object (#45)
Browse files Browse the repository at this point in the history
* Adding support to get Yaml for k8s object

* Added logic to compute yaml diff, refactored hashing helper

* Nit changes for review

* Updating link for unified context
  • Loading branch information
AshishKhatkar authored Mar 2, 2020
1 parent 41d174e commit a689c52
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 38 deletions.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ require (
github.com/googleapis/gnostic v0.3.1 // indirect
github.com/hashicorp/golang-lru v0.5.3 // indirect
github.com/imdario/mergo v0.3.8 // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
github.com/pborman/uuid v1.2.0 // indirect
github.com/pkg/errors v0.8.1
github.com/pmezard/go-difflib v1.0.0
github.com/prometheus/client_golang v1.3.0 // indirect
github.com/spf13/pflag v1.0.3 // indirect
github.com/stretchr/testify v1.4.0
Expand All @@ -23,6 +23,7 @@ require (
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 // indirect
golang.org/x/time v0.0.0-20191024005414-555d28b269f0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.2.2
k8s.io/api v0.0.0-20190313235455-40a48860b5ab
k8s.io/apimachinery v0.0.0-20190313205120-d7deff9243b1
k8s.io/client-go v11.0.0+incompatible
Expand Down
47 changes: 10 additions & 37 deletions pkg/hashing/hashing.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,17 @@ import (
)

func ComputeHashForKubernetesObject(object interface{}) (string, error) {
// By marshaling/unmarshaling the object via JSON we're copying it into a
// map, which is easier to manipulate in generic way than structs.
if b, err := json.Marshal(object); err != nil {
return "", fmt.Errorf("Error while encoding %+v into JSON: %s", object, err)
if m, err := GetFilteredK8sObjectForHashing(object); err != nil {
return "", err
} else {
var v map[string]interface{}
if err := json.Unmarshal(b, &v); err != nil {
return "", fmt.Errorf("Error while decoding JSON %s into an object: %s", v, err)
// By using serialized JSON for hashing we're making the hashing process
// a bit easier (like having maps always being sorted by keys).
if b, err := json.Marshal(m); err != nil {
return "", fmt.Errorf("error while encoding %+v into JSON: %s", m, err)
} else {
// We need only kind/version/spec and labels excluding the label with the
// current hash value while calculating the hash. Also Kubernetes adds
// its own info into `metadata` which we need to ignore.
meta := v["metadata"].(map[string]interface{})
labels := meta["labels"]
if labels != nil {
delete(labels.(map[string]interface{}), "yelp.com/operator_config_hash")
} else {
labels = make(map[string]interface{})
}
m := map[string]interface{}{
"kind": v["kind"],
"apiVersion": v["apiVersion"],
"spec": v["spec"],
"metadata": map[string]interface{}{
"name": meta["name"],
"namespace": meta["namespace"],
"labels": labels,
},
}
// By using serialized JSON for hashing we're making the hashing process
// a bit easier (like having maps always being sorted by keys).
if b, err := json.Marshal(m); err != nil {
return "", fmt.Errorf("Error while encoding %+v into JSON: %s", m, err)
} else {
hasher := fnv.New32a()
hasher.Write(b)
return rand.SafeEncodeString(fmt.Sprint(hasher.Sum32())), nil
}
hasher := fnv.New32a()
hasher.Write(b)
return rand.SafeEncodeString(fmt.Sprint(hasher.Sum32())), nil
}
}
}
Expand All @@ -58,7 +31,7 @@ func SetKubernetesObjectHash(configHash string, object interface{}) error {
if value.Kind() == reflect.Ptr {
objectMeta = value.Elem().FieldByName("ObjectMeta")
} else {
return fmt.Errorf("Must pass pointer to AddLabelsToMetadata so we can update labels using reflection.")
return fmt.Errorf("must pass pointer to AddLabelsToMetadata so we can update labels using reflection")
}
if objectMeta.Kind() == reflect.Struct {
labels := objectMeta.FieldByName("Labels")
Expand Down
42 changes: 42 additions & 0 deletions pkg/hashing/helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package hashing

import (
"encoding/json"
"fmt"
)

// function to get the relevant information out of k8s object that is needed to compute hash
func GetFilteredK8sObjectForHashing(object interface{}) (map[string]interface{}, error) {
// By marshaling/unmarshaling the object via JSON we're copying it into a
// map, which is easier to manipulate in generic way than structs.
if b, err := json.Marshal(object); err != nil {
return nil, fmt.Errorf("error while encoding %+v into JSON: %s", object, err)
} else {
var v map[string]interface{}
if err := json.Unmarshal(b, &v); err != nil {
return nil, fmt.Errorf("error while decoding JSON %s into an object: %s", v, err)
} else {
// We need only kind/version/spec and labels excluding the label with the
// current hash value while calculating the hash. Also Kubernetes adds
// its own info into `metadata` which we need to ignore.
meta := v["metadata"].(map[string]interface{})
labels := meta["labels"]
if labels != nil {
delete(labels.(map[string]interface{}), "yelp.com/operator_config_hash")
} else {
labels = make(map[string]interface{})
}
m := map[string]interface{}{
"kind": v["kind"],
"apiVersion": v["apiVersion"],
"spec": v["spec"],
"metadata": map[string]interface{}{
"name": meta["name"],
"namespace": meta["namespace"],
"labels": labels,
},
}
return m, nil
}
}
}
59 changes: 59 additions & 0 deletions pkg/hashing/helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package hashing

import (
"encoding/json"
assert "github.com/stretchr/testify/assert"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"testing"
)

func TestGetHashObjectOfKubernetes(t *testing.T) {
labels := map[string]string{
"yelp.com/rick": "andmortyadventures",
"yelp.com/operator_config_hash": "somerandomhash",
}
labelsWithoutHash := map[string]string{
"yelp.com/rick": "andmortyadventures",
}
replicas := int32(2)
someStatefulSet := &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
APIVersion: "apps/v1",
Kind: "StatefulSet",
},
ObjectMeta: metav1.ObjectMeta{
Name: "morty-test-cluster",
Namespace: "paasta-cassandra",
Labels: labels,
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas,
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{corev1.PersistentVolumeClaim{}},
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Volumes: []corev1.Volume{},
Containers: []corev1.Container{},
},
},
},
}
expectedHashObject := map[string]interface{}{
"kind": someStatefulSet.TypeMeta.Kind,
"apiVersion": someStatefulSet.TypeMeta.APIVersion,
"spec": someStatefulSet.Spec,
"metadata": map[string]interface{}{
"name": someStatefulSet.ObjectMeta.Name,
"namespace": someStatefulSet.ObjectMeta.Namespace,
"labels": labelsWithoutHash,
},
}
expectedOutString, err := json.Marshal(expectedHashObject)
_ = json.Unmarshal(expectedOutString, &expectedHashObject)
hashObject, err := GetFilteredK8sObjectForHashing(someStatefulSet)
if err != nil {
t.Errorf("Failed to calculate hash object")
}
assert.Equal(t, expectedHashObject, hashObject)
}
57 changes: 57 additions & 0 deletions pkg/utils/yamlhelper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package utils

import (
"encoding/json"
"fmt"
"github.com/pmezard/go-difflib/difflib"
"gopkg.in/yaml.v2"
)

// function to get the yaml output of an object
// marshal/unmarshal to and from json in order to maintain consistency between json and yaml
// currently k8s objects have json field tags, so this helps in skipping empty fields
// and give consistent output yaml similar to json
func getYamlOfObject(object interface{}) (string, error) {
if b, err := json.Marshal(object); err != nil {
return "", fmt.Errorf("error while encoding %+v into JSON: %s", object, err)
} else {
var v map[string]interface{}
if err := json.Unmarshal(b, &v); err != nil {
return "", fmt.Errorf("error while decoding JSON %s into an object: %s", v, err)
} else {
if y, err := yaml.Marshal(&v); err != nil {
return "", fmt.Errorf("error while encoding Map %s into YAML: %s", v, err)
} else {
return string(y), nil
}
}
}
}

func generateYamlDiff(yaml1 string, yaml2 string, context int) string {
diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
A: difflib.SplitLines(yaml1),
B: difflib.SplitLines(yaml2),
FromFile: "Old",
FromDate: "",
ToFile: "New",
ToDate: "",
Context: context,
})
return diff
}

// func to generate yaml diff of objects used for hashing
// context : number of context lines to use for generating diff
// for more reference on context : https://www.gnu.org/software/diffutils/manual/html_node/Unified-Format.html#Unified-Format
func GetYamlDiffForObjects(objectOld interface{}, objectNew interface{}, context int) (string, error) {
yamlOld, err := getYamlOfObject(objectOld)
if err != nil {
return "", err
}
yamlNew, err := getYamlOfObject(objectNew)
if err != nil {
return "", err
}
return generateYamlDiff(yamlOld, yamlNew, context), nil
}
97 changes: 97 additions & 0 deletions pkg/utils/yamlhelper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package utils

import (
assert "github.com/stretchr/testify/assert"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"testing"
)

func TestGetYamlDiffForObjects(t *testing.T) {
labels1 := map[string]string{
"yelp.com/rick": "andmortyadventures1",
"yelp.com/operator_config_hash": "somerandomhash1",
}
replicas1 := int32(2)
someStatefulSet1 := &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
APIVersion: "apps/v1",
Kind: "StatefulSet",
},
ObjectMeta: metav1.ObjectMeta{
Name: "morty-test-cluster1",
Namespace: "paasta-cassandra",
Labels: labels1,
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas1,
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{corev1.PersistentVolumeClaim{}},
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Volumes: []corev1.Volume{},
Containers: []corev1.Container{},
},
},
},
}

labels2 := map[string]string{
"yelp.com/rick": "andmortyadventures2",
"yelp.com/operator_config_hash": "somerandomhash2",
}
replicas2 := int32(2)
someStatefulSet2 := &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
APIVersion: "apps/v1",
Kind: "StatefulSet",
},
ObjectMeta: metav1.ObjectMeta{
Name: "morty-test-cluster2",
Namespace: "paasta-cassandra",
Labels: labels2,
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas2,
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{corev1.PersistentVolumeClaim{}},
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Volumes: []corev1.Volume{
{
Name: "volume1",
VolumeSource: corev1.VolumeSource{},
},
},
Containers: []corev1.Container{},
},
},
},
}
expectedDiff := `--- Old
+++ New
@@ -3,9 +3,9 @@
metadata:
creationTimestamp: null
labels:
- yelp.com/operator_config_hash: somerandomhash1
- yelp.com/rick: andmortyadventures1
- name: morty-test-cluster1
+ yelp.com/operator_config_hash: somerandomhash2
+ yelp.com/rick: andmortyadventures2
+ name: morty-test-cluster2
namespace: paasta-cassandra
spec:
replicas: 2
@@ -16,6 +16,8 @@
creationTimestamp: null
spec:
containers: []
+ volumes:
+ - name: volume1
updateStrategy: {}
volumeClaimTemplates:
- metadata:
`
actualDiff, _ := GetYamlDiffForObjects(someStatefulSet1, someStatefulSet2, 3)
assert.Equal(t, expectedDiff, actualDiff)
}

0 comments on commit a689c52

Please sign in to comment.