Repository: grafana/tanka
Pull request #65 (2019-08-26 15:30:53)

test(kubernetes): Reconciliation unit tests
Adds unit tests for reconciliation phase to make sure it all works as expected (it didn't lol).

**Bugfixes / Features**:
- using a flat manifest led to an error
- it was not possible to specify the namespace from Jsonnet. It was overwritten by `spec.json`

**BREAKING**: `metadata.namespace` can now be set from Jsonnet by explicitly assigning a value to this property (This was the ksonnet behaviour). If left blank, the one from `spec.json` is used. **Make sure Jsonnet is not setting wrong namespaces**.



Patch:
diff --git a/.drone/drone.yml b/.drone/drone.yml
index 7436338b..93f2dd5d 100644
--- a/.drone/drone.yml
+++ b/.drone/drone.yml
@@ -60,7 +60,7 @@ steps:
 
       # download the binary (adapt os and arch as needed)
 
-      $ curl -fSL -o "/usr/local/bin/tk" "https://github.com/sh0rez/tanka/releases/download/${DRONE_TAG}/img-linux-amd64"
+      $ curl -fSL -o "/usr/local/bin/tk" "https://github.com/sh0rez/tanka/releases/download/${DRONE_TAG}/tk-linux-amd64"
 
 
       # make it executable
diff --git a/.drone/release-note.md b/.drone/release-note.md
index 9ebae4ca..19cd74d9 100644
--- a/.drone/release-note.md
+++ b/.drone/release-note.md
@@ -4,7 +4,7 @@ This is release ${DRONE_TAG} of Tanka (`tk`). Check out the [CHANGELOG](CHANGELO
 #### Binary:
 ```bash
 # download the binary (adapt os and arch as needed)
-$ curl -fSL -o "/usr/local/bin/tk" "https://github.com/sh0rez/tanka/releases/download/${DRONE_TAG}/img-linux-amd64"
+$ curl -fSL -o "/usr/local/bin/tk" "https://github.com/sh0rez/tanka/releases/download/${DRONE_TAG}/tk-linux-amd64"
 
 # make it executable
 $ chmod a+x "/usr/local/bin/tk"
diff --git a/Makefile b/Makefile
index 49b65a7e..ca76047f 100644
--- a/Makefile
+++ b/Makefile
@@ -27,7 +27,7 @@ uninstall:
 $(GOX):
 	go get -u github.com/mitchellh/gox
 cross: $(GOX)
-	CGO_ENABLED=0 gox -output="dist/{{.Dir}}_{{.OS}}_{{.Arch}}" -ldflags=${LDFLAGS} -arch="amd64 arm64 arm" -os="linux" -osarch="darwin/amd64" ./cmd/tk
+	CGO_ENABLED=0 gox -output="dist/{{.Dir}}-{{.OS}}-{{.Arch}}" -ldflags=${LDFLAGS} -arch="amd64 arm64 arm" -os="linux" -osarch="darwin/amd64" ./cmd/tk
 
 # Docker container
 container: static
diff --git a/docs/design/tanka.md b/docs/design/tanka.md
index 85722b9c..7aadc22a 100644
--- a/docs/design/tanka.md
+++ b/docs/design/tanka.md
@@ -221,7 +221,7 @@ called `spec.json` alongside the `main.jsonnet`:
 | `metadata.name`   | automatically set to the directory name                             |
 | `metadata.labels` | descriptive `key:value` pairs                                       |
 | `spec.apiServer`  | The Kubernetes endpoint to use                                      |
-| `spec.namespace`  | All objects will be forced into this namespace                      |
+| `spec.namespace`  | Default namespace used if not set in jsonnet                        |
 
 The environment object is accessible from within `jsonnet`.
 
diff --git a/docs/environments.md b/docs/environments.md
index 7b154a8d..0865b412 100644
--- a/docs/environments.md
+++ b/docs/environments.md
@@ -36,7 +36,7 @@ about it. These are specified in a file called `spec.json` which is placed next
 | `metadata.name`      | *automatically set to the directory name*        |
 | `metadata.labels`    | descriptive `key:value` pairs                    |
 | **`spec.apiServer`** | The Kubernetes endpoint to use                   |
-| **`spec.namespace`** | All objects will be created in this namespace    |
+| **`spec.namespace`** | Default namespace used if not set in jsonnet     |
 
 Everything written in **bold** is required, the other fields may be omitted.
 
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 99dab47a..b12ef51f 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -65,7 +65,7 @@ func (k *Kubernetes) Reconcile(raw map[string]interface{}, objectspecs ...*regex
 	}
 	for _, d := range docs {
 		m := objx.New(d)
-		if k != nil {
+		if k != nil && !m.Has("metadata.namespace") {
 			m.Set("metadata.namespace", k.Spec.Namespace)
 		}
 		out = append(out, Manifest(m))
diff --git a/pkg/kubernetes/reconcile.go b/pkg/kubernetes/reconcile.go
index 8d0990e2..6bbf7545 100644
--- a/pkg/kubernetes/reconcile.go
+++ b/pkg/kubernetes/reconcile.go
@@ -21,7 +21,12 @@ func (e ErrorPrimitiveReached) Error() string {
 
 // walkJSON traverses deeply nested kubernetes manifest and extracts them into a flat []dict.
 func walkJSON(deep map[string]interface{}, path string) ([]map[string]interface{}, error) {
-	flat := []map[string]interface{}{}
+	r := objx.New(deep)
+	if r.Has("apiVersion") && r.Has("kind") {
+		return []map[string]interface{}{deep}, nil
+	}
+
+	flat := make([]map[string]interface{}, 0)
 
 	for n, d := range deep {
 		if n == "__ksonnet" {


Test patch:
diff --git a/pkg/kubernetes/data_test.go b/pkg/kubernetes/data_test.go
new file mode 100644
index 00000000..8aa07e47
--- /dev/null
+++ b/pkg/kubernetes/data_test.go
@@ -0,0 +1,215 @@
+package kubernetes
+
+// This file  contains data for testing
+
+// testData holds data for tests
+type testData struct {
+	deep, flat interface{}
+}
+
+// testDataRegular is a regular output of jsonnet without special things, but it
+// is nested.
+func testDataRegular() testData {
+	return (testData{
+		deep: map[string]interface{}{
+			"deployment": map[string]interface{}{
+				"apiVersion": "apps/v1",
+				"kind":       "Deployment",
+				"metadata": map[string]interface{}{
+					"name": "nginx",
+				},
+				"spec": map[string]interface{}{
+					"containers": []interface{}{
+						map[string]interface{}{
+							"name":  "nginx",
+							"image": "nginx",
+						},
+					},
+				},
+			},
+			"service": map[string]interface{}{
+				"apiVersion": "v1",
+				"kind":       "Service",
+				"metadata": map[string]interface{}{
+					"name": "nginx",
+				},
+				"spec": map[string]interface{}{
+					"selector": map[string]interface{}{
+						"app": "app",
+					},
+				},
+			},
+		},
+		flat: []map[string]interface{}{
+			{
+				"apiVersion": "apps/v1",
+				"kind":       "Deployment",
+				"metadata": map[string]interface{}{
+					"name": "nginx",
+				},
+				"spec": map[string]interface{}{
+					"containers": []interface{}{
+						map[string]interface{}{
+							"name":  "nginx",
+							"image": "nginx",
+						},
+					},
+				},
+			},
+			{
+				"apiVersion": "v1",
+				"kind":       "Service",
+				"metadata": map[string]interface{}{
+					"name": "nginx",
+				},
+				"spec": map[string]interface{}{
+					"selector": map[string]interface{}{
+						"app": "app",
+					},
+				},
+			},
+		},
+	})
+}
+
+// testDataFlat is a flat manifest that does not need reconciliation
+func testDataFlat() testData {
+	return testData{
+		deep: map[string]interface{}{
+			"apiVersion": "apps/v1",
+			"kind":       "Deployment",
+			"metadata": map[string]interface{}{
+				"name": "nginx",
+			},
+			"spec": map[string]interface{}{
+				"containers": []interface{}{
+					map[string]interface{}{
+						"name":  "nginx",
+						"image": "nginx",
+					},
+				},
+			},
+		},
+		flat: []map[string]interface{}{
+			{
+				"apiVersion": "apps/v1",
+				"kind":       "Deployment",
+				"metadata": map[string]interface{}{
+					"name": "nginx",
+				},
+				"spec": map[string]interface{}{
+					"containers": []interface{}{
+						map[string]interface{}{
+							"name":  "nginx",
+							"image": "nginx",
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+// testDataPrimitive is an invalid manifest, because it ends with a primitive
+// without including required fields
+func testDataPrimitive() testData {
+	return testData{
+		deep: map[string]interface{}{
+			"nginx": map[string]interface{}{
+				"deployment": map[string]interface{}{
+					"apiVersion": "apps/v1",
+					"kind":       "Deployment",
+					"metadata": map[string]interface{}{
+						"name": "nginx",
+					},
+				},
+				"service": map[string]interface{}{
+					"note": "invalid because apiVersion and kind are missing",
+				},
+			},
+		},
+		flat: []map[string]interface{}(nil),
+	}
+}
+
+// testDataDeep is super deeply nested on multiple levels
+func testDataDeep() testData {
+	return testData{
+		deep: map[string]interface{}{
+			"app": map[string]interface{}{
+				"web": map[string]interface{}{
+					"backend": map[string]interface{}{
+						"server": map[string]interface{}{
+							"nginx": map[string]interface{}{
+								"deployment": map[string]interface{}{
+									"kind":       "Deployment",
+									"apiVersion": "apps/v1",
+									"metadata": map[string]interface{}{
+										"name": "nginx",
+									},
+								},
+							},
+						},
+					},
+					"frontend": map[string]interface{}{
+						"nodejs": map[string]interface{}{
+							"express": map[string]interface{}{
+								"service": map[string]interface{}{
+									"kind":       "Service",
+									"apiVersion": "v1",
+									"metadata": map[string]interface{}{
+										"name": "frontend",
+									},
+								},
+								"deployment": map[string]interface{}{
+									"kind":       "Deployment",
+									"apiVersion": "apps/v1",
+									"metadata": map[string]interface{}{
+										"name": "frontend",
+									},
+								},
+							},
+						},
+					},
+				},
+				"namespace": map[string]interface{}{
+					"kind":       "Namespace",
+					"apiVersion": "v1",
+					"metadata": map[string]interface{}{
+						"name": "app",
+					},
+				},
+			},
+		},
+		flat: []map[string]interface{}{
+			{
+				"kind":       "Deployment",
+				"apiVersion": "apps/v1",
+				"metadata": map[string]interface{}{
+					"name": "nginx",
+				},
+			},
+			{
+				"kind":       "Service",
+				"apiVersion": "v1",
+				"metadata": map[string]interface{}{
+					"name": "frontend",
+				},
+			},
+			{
+				"kind":       "Deployment",
+				"apiVersion": "apps/v1",
+				"metadata": map[string]interface{}{
+					"name": "frontend",
+				},
+			},
+			{
+				"kind":       "Namespace",
+				"apiVersion": "v1",
+				"metadata": map[string]interface{}{
+					"name": "app",
+				},
+			},
+		},
+	}
+}
diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go
new file mode 100644
index 00000000..35c6e9e9
--- /dev/null
+++ b/pkg/kubernetes/kubernetes_test.go
@@ -0,0 +1,113 @@
+package kubernetes
+
+import (
+	"regexp"
+	"testing"
+
+	"github.com/grafana/tanka/pkg/config/v1alpha1"
+	"github.com/stretchr/objx"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestReconcile(t *testing.T) {
+	tests := []struct {
+		name string
+		k    *Kubernetes
+
+		data    testData
+		targets []*regexp.Regexp
+		err     error
+	}{
+		{
+			name: "regular",
+			data: testDataRegular(),
+		},
+		{
+			name: "targets",
+			data: testData{
+				deep: testDataDeep().deep,
+				flat: []map[string]interface{}{
+					testDataDeep().flat.([]map[string]interface{})[0], // deployment/nginx
+					testDataDeep().flat.([]map[string]interface{})[1], // service/frontend
+				},
+			},
+			targets: []*regexp.Regexp{
+				regexp.MustCompile("deployment/nginx"),
+				regexp.MustCompile("service/frontend"),
+			},
+		},
+		{
+			name: "targets-regex",
+			data: testData{
+				deep: testDataDeep().deep,
+				flat: []map[string]interface{}{
+					testDataDeep().flat.([]map[string]interface{})[2], // deployment/frontend
+					testDataDeep().flat.([]map[string]interface{})[0], // deployment/nginx
+				},
+			},
+			targets: []*regexp.Regexp{regexp.MustCompile("deployment/.*")},
+		},
+		{
+			name: "force-namespace",
+			k:    &Kubernetes{Spec: v1alpha1.Spec{Namespace: "tanka"}},
+			data: testData{
+				deep: testDataFlat().deep,
+				flat: func() []map[string]interface{} {
+					f := objx.New(testDataFlat().flat.([]map[string]interface{})[0])
+					f.Set("metadata.namespace", "tanka")
+					return []map[string]interface{}{f}
+				}(),
+			},
+		},
+		{
+			name: "custom-namespace",
+			k:    &Kubernetes{Spec: v1alpha1.Spec{Namespace: "tanka"}},
+			data: testData{
+				deep: func() map[string]interface{} {
+					d := objx.New(testDataFlat().deep)
+					d.Set("metadata.namespace", "custom")
+					return d
+				}(),
+				flat: func() []map[string]interface{} {
+					f := objx.New(testDataFlat().flat.([]map[string]interface{})[0])
+					f.Set("metadata.namespace", "custom")
+					return []map[string]interface{}{f}
+				}(),
+			},
+		},
+	}
+
+	for _, c := range tests {
+		t.Run(c.name, func(t *testing.T) {
+			got, err := c.k.Reconcile(c.data.deep.(map[string]interface{}), c.targets...)
+
+			require.Equal(t, c.err, err)
+
+			flat := c.data.flat.([]map[string]interface{})
+			assert.Equal(t, msisToManifests(flat), got)
+		})
+	}
+}
+
+func TestReconcileOrder(t *testing.T) {
+	got := make([][]Manifest, 10)
+	k := &Kubernetes{}
+	for i := 0; i < 10; i++ {
+		r, err := k.Reconcile(testDataDeep().deep.(map[string]interface{}))
+		require.NoError(t, err)
+		got[i] = r
+	}
+
+	for i := 1; i < 10; i++ {
+		require.Equal(t, got[0], got[i])
+	}
+}
+
+func msisToManifests(msis []map[string]interface{}) []Manifest {
+	ms := make([]Manifest, len(msis))
+	for i, msi := range msis {
+		ms[i] = Manifest(msi)
+	}
+	return ms
+}
diff --git a/pkg/kubernetes/reconcile_test.go b/pkg/kubernetes/reconcile_test.go
new file mode 100644
index 00000000..a52da6dd
--- /dev/null
+++ b/pkg/kubernetes/reconcile_test.go
@@ -0,0 +1,42 @@
+package kubernetes
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWalkJSON(t *testing.T) {
+	tests := []struct {
+		name string
+		data testData
+		err  error
+	}{
+		{
+			name: "regular",
+			data: testDataRegular(),
+		},
+		{
+			name: "flat",
+			data: testDataFlat(),
+		},
+		{
+			name: "primitive",
+			data: testDataPrimitive(),
+			err:  ErrorPrimitiveReached{path: ".nginx.service", key: "note", primitive: "invalid because apiVersion and kind are missing"},
+		},
+		{
+			name: "deep",
+			data: testDataDeep(),
+		},
+	}
+
+	for _, c := range tests {
+		t.Run(c.name, func(t *testing.T) {
+			got, err := walkJSON(c.data.deep.(map[string]interface{}), "")
+			require.Equal(t, c.err, err)
+			assert.ElementsMatch(t, c.data.flat, got)
+		})
+	}
+}