Repository: grafana/tanka (go)

Issue:
Document expected structure of Jsonnet output
Hi team,

We're trying out tanka for managing our core kubernetes cluster configuration but have hit a few roadbumps.

Most of our problems appear to stem from lack of understanding about what type of object tanka is expecting your jsonnet to generate from the environments file.

From looking at your examples, it appears the implicit expectation is that tanka will recursively descend your jsonnet structure, assuming that your structure is a tree with kubernetes object leaf nodes. So that assumes that you have one global object which is a map of string to object, where that object is either a kubernetes resource or another tree.

```haskell
data Resource = Resource {
  apiVersion :: String, kind :: String, metadata :: Metadata
}
data Environment = Resource || [(string, Environment)]
```

If this is so, it would be great to document it. I suspect we might be missing something though, as tanka panics when we construct several otherwise valid configs, such as:

```
{
  a: {
    apiVersion: 'v1',
    kind: 'List',
    items: [/* valid k8s resources */],
  },
  b: self.a,
}
```

Lacking a clear explanation about what this format is makes it difficult to adopt tanka as a tool. All the advertising says tanka is a great `function`, but we don't know what the type signature is.

Can you help us out? Cheers.

Pull request:
feat(kubernetes): make reconcile support arrays
Adds array support for Reconcile.

Also documents the expected jsonnet structure. Fixes #110 

Patch:
diff --git a/docs/jsonnet/expected-structure.md b/docs/jsonnet/expected-structure.md
new file mode 100644
index 00000000..6723e1e5
--- /dev/null
+++ b/docs/jsonnet/expected-structure.md
@@ -0,0 +1,104 @@
+# Expected structure
+
+Tanka evaluates the `main.jsonnet` file of your [Environment](/environments) and
+filters the output (either Object or Array) for valid Kubernetes objects.  
+An object is considered valid if it has both, a `kind` and a `apiVersion` set.
+
+!!! warning
+    This behaviour is going to change in the future, `metadata.name` will
+    also become required.
+
+## Deeply nested object (Recommended)
+The most commonly used structure is a single big object that includes all of
+your configs to be applied to the cluster nested under keys.  
+How deeply encapsulated the actual object is does not matter, Tanka will
+traverse down until it finds something that has both, a `kind` and an
+`apiVersion`.  
+
+??? Example
+    ```json
+    {
+      "prometheus": {
+        "service": { // Service nested one level
+          "apiVersion": "v1",
+          "kind": "Service",
+          "metadata": {
+            "name": "promSvc"
+          }
+        },
+        "deployment": {
+          "apiVersion": "apps/v1", // apiVersion ..
+          "kind": "Deployment", // .. and kind are required
+                                // to identify an object.
+          "metadata": {
+            "name": "prom" 
+          }
+        }
+      },
+      "web": {
+        "nginx": {
+          "deployment": { // Deployment nested two levels
+            "apiVersion": "apps/v1",
+            "kind": "Deployment",
+            "metadata": {
+              "name": "nginx"
+            }
+          }
+        }
+      }
+    }
+    ```
+    
+Using this technique has the big benefit that it is self-documentary, as the
+nesting of keys can be used to logically group related manifests, for example by
+application.
+
+!!! info
+    It is also valid to use an encapsulation level of zero, which means
+    just a regular object like it could be obtained from `kubectl show -o json`:
+    ```json
+    {
+      "apiVersion": "v1",
+      "kind": "Service",
+      "metadata": {
+        "name": "foo"
+      }
+    }
+    ```
+
+
+## Array
+Using an array of objects is also fine:
+```json
+[
+  {
+    "apiVersion": "v1",
+    "kind": "Service",
+    "metadata": {
+      "name": "promSvc"
+    }
+  },
+  {
+    "apiVersion": "apps/v1",
+    "kind": "Deployment",
+    "metadata": {
+      "name": "prom"
+    }
+  }
+]
+```
+
+### `List` type
+Users of `kubectl` might have had contact with a type called `List`. It is not
+part of the official Kubernetes API but rather a pseudo-type introduced by
+`kubectl` for dealing with multiple objects at once. Thus, Tanka does not
+support it out of the box.
+
+To take full advantage of Tankas features, you can manually flatten it:
+
+```jsonnet
+local list = import "list.libsonnet";
+
+# expose the `items` array on the top level:
+list.items 
+```
diff --git a/pkg/kubernetes/reconcile.go b/pkg/kubernetes/reconcile.go
index 6bbf7545..3b99b9aa 100644
--- a/pkg/kubernetes/reconcile.go
+++ b/pkg/kubernetes/reconcile.go
@@ -20,14 +20,37 @@ 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) {
+func walkJSON(rawDeep interface{}, path string) ([]map[string]interface{}, error) {
+	flat := make([]map[string]interface{}, 0)
+
+	// array: walkJSON for each
+	if d, ok := rawDeep.([]map[string]interface{}); ok {
+		for i, j := range d {
+			out, err := walkJSON(j, fmt.Sprintf("%s[%v]", path, i))
+			if err != nil {
+				return nil, err
+			}
+			flat = append(flat, out...)
+		}
+		return flat, nil
+	}
+
+	// assert for map[string]interface{} (also aliased objx.Map)
+	if m, ok := rawDeep.(objx.Map); ok {
+		rawDeep = map[string]interface{}(m)
+	}
+	deep, ok := rawDeep.(map[string]interface{})
+	if !ok {
+		return nil, fmt.Errorf("deep has unexpected type %T @ %s", deep, path)
+	}
+
+	// already flat?
 	r := objx.New(deep)
 	if r.Has("apiVersion") && r.Has("kind") {
 		return []map[string]interface{}{deep}, nil
 	}
 
-	flat := make([]map[string]interface{}, 0)
-
+	// walk it
 	for n, d := range deep {
 		if n == "__ksonnet" {
 			continue


Test patch:
diff --git a/pkg/kubernetes/data_test.go b/pkg/kubernetes/data_test.go
index 8aa07e47..44da9d77 100644
--- a/pkg/kubernetes/data_test.go
+++ b/pkg/kubernetes/data_test.go
@@ -213,3 +213,15 @@ func testDataDeep() testData {
 		},
 	}
 }
+
+// testDataArray is an array of (deeply nested) dicts that should be fully
+// flattened
+func testDataArray() testData {
+	return testData{
+		deep: append([]map[string]interface{}{
+			testDataDeep().deep.(map[string]interface{}),
+		}, testDataFlat().deep.(map[string]interface{})),
+
+		flat: append(testDataDeep().flat.([]map[string]interface{}), testDataFlat().flat.([]map[string]interface{})...),
+	}
+}
diff --git a/pkg/kubernetes/reconcile_test.go b/pkg/kubernetes/reconcile_test.go
index a52da6dd..8bdfb388 100644
--- a/pkg/kubernetes/reconcile_test.go
+++ b/pkg/kubernetes/reconcile_test.go
@@ -30,11 +30,15 @@ func TestWalkJSON(t *testing.T) {
 			name: "deep",
 			data: testDataDeep(),
 		},
+		{
+			name: "array",
+			data: testDataArray(),
+		},
 	}
 
 	for _, c := range tests {
 		t.Run(c.name, func(t *testing.T) {
-			got, err := walkJSON(c.data.deep.(map[string]interface{}), "")
+			got, err := walkJSON(c.data.deep, "")
 			require.Equal(t, c.err, err)
 			assert.ElementsMatch(t, c.data.flat, got)
 		})