Repository: grafana/tanka
Pull request #145 (2020-01-08 20:40:02)

feat(cli): set env server from context
Allows to set the `spec.apiServer` of an `Environment` using an already known
context:

```bash
$ tk env set environments/default --server-from-context eu-west1
```

The `--server-from-context` flag is autocompleted using data from `kubectl
config get-contexts`


Patch:
diff --git a/cmd/tk/env.go b/cmd/tk/env.go
index f1b0acbd..fe8bf016 100644
--- a/cmd/tk/env.go
+++ b/cmd/tk/env.go
@@ -9,11 +9,14 @@ import (
 	"text/tabwriter"
 
 	"github.com/pkg/errors"
+	"github.com/posener/complete"
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
 
 	"github.com/grafana/tanka/pkg/cli"
+	"github.com/grafana/tanka/pkg/cli/cmp"
+	"github.com/grafana/tanka/pkg/kubernetes/client"
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
@@ -34,6 +37,7 @@ func envCmd() *cobra.Command {
 
 func envSettingsFlags(env *v1alpha1.Config, fs *pflag.FlagSet) {
 	fs.StringVar(&env.Spec.APIServer, "server", env.Spec.APIServer, "endpoint of the Kubernetes API")
+	fs.StringVar(&env.Spec.APIServer, "server-from-context", env.Spec.APIServer, "set the server to a known one from $KUBECONFIG")
 	fs.StringVar(&env.Spec.Namespace, "namespace", env.Spec.Namespace, "namespace to create objects in")
 	fs.StringVar(&env.Spec.DiffStrategy, "diff-strategy", env.Spec.DiffStrategy, "specify diff-strategy. Automatically detected otherwise.")
 }
@@ -44,12 +48,24 @@ func envSetCmd() *cobra.Command {
 		Short: "update properties of an environment",
 		Args:  cobra.ExactArgs(1),
 		Annotations: map[string]string{
-			"args": "baseDir",
+			"args":                      "baseDir",
+			"flags/server-from-context": "kubectlContexts",
 		},
 	}
+
+	// handler for server-from-context
+	cmp.Handlers.Add("kubectlContexts", complete.PredictFunc(
+		func(complete.Args) []string {
+			c, _ := client.Contexts()
+			return c
+		},
+	))
+
+	// flags
 	tmp := v1alpha1.Config{}
 	envSettingsFlags(&tmp, cmd.Flags())
 
+	// removed name flag
 	name := cmd.Flags().String("name", "", "")
 	_ = cmd.Flags().MarkHidden("name")
 
@@ -63,6 +79,14 @@ func envSetCmd() *cobra.Command {
 			log.Fatalln(err)
 		}
 
+		if cmd.Flags().Changed("server-from-context") {
+			server, err := client.IPFromContext(tmp.Spec.APIServer)
+			if err != nil {
+				log.Fatalf("Resolving IP from context: %s", err)
+			}
+			tmp.Spec.APIServer = server
+		}
+
 		viper.Reset()
 		cfg := setupConfiguration(path)
 		if cfg == nil {
diff --git a/docs/docs/jsonnet/main.md b/docs/docs/jsonnet/main.md
index e5da5adc..846e33de 100644
--- a/docs/docs/jsonnet/main.md
+++ b/docs/docs/jsonnet/main.md
@@ -18,9 +18,6 @@ extract your objects first. To do this, it traverses the tree until it finds
 something that looks like a Kubernetes manifest. An object is considered valid
 when it has both, `kind` and `apiVersion` set.
 
-> This behaviour is going to change in the future, `metadata.name` will also
-> become required.
-
 To ensure Tanka can find your manifests, the output of your Jsonnet needs to
 have one of the following structures:
 
diff --git a/docs/docs/known-issues.md b/docs/docs/known-issues.md
index a2204c50..07f624d2 100644
--- a/docs/docs/known-issues.md
+++ b/docs/docs/known-issues.md
@@ -23,16 +23,21 @@ import paths. While `ksonnet` used to magically include them, Tanka follows a
 more explicit approach and requires you to install them using `jb`:
 
 ```bash
-$ jb install github.com/ksonnet/ksonnet-lib/ksonnet.beta.4/k.libsonnet
-$ jb install github.com/ksonnet/ksonnet-lib/ksonnet.beta.4/k8s.libsonnet
+$ jb install github.com/ksonnet/ksonnet-lib/ksonnet.beta.4
+$ echo 'import "ksonnet.beta.4/k.libsonnet"' > lib/k.libsonnet
 ```
 
-This installs version `beta.3` of the libraries, matching Kubernetes version
-`1.8.0`. If you need another version, take a look at
+This does 2 things:
+
+1) It installs the ksonnet library (in `vendor/ksonnet.beta.4`).
+If you need a specific version, take a look at
 https://github.com/ksonnet/ksonnet-lib. When a pre-compiled version is
 available, install it using `jb`, otherwise compile it yourself and place it
 under `lib/`.
 
+2) It makes an alias for libraries importing `k.libsonnet` directly. See
+https://tanka.dev/tutorial/k-lib#aliasing for the alias rationale.
+
 ### Unexpected diff if the same port number is used for UDP and TCP
 
 A
diff --git a/docs/docs/tutorial/abstraction.mdx b/docs/docs/tutorial/abstraction.mdx
index da446347..ac232680 100644
--- a/docs/docs/tutorial/abstraction.mdx
+++ b/docs/docs/tutorial/abstraction.mdx
@@ -70,7 +70,7 @@ Let's start cleaning this up by separating logical pieces into distinct files:
       },
       spec: {
         ports: [{
-            name: '%s-ui' % $._config.grafana.port,
+            name: '%s-ui' % $._config.grafana.name,
             port: $._config.grafana.port,
             targetPort: $._config.grafana.port,
         }],
diff --git a/docs/docs/tutorial/jsonnet.mdx b/docs/docs/tutorial/jsonnet.mdx
index 49bb9e28..09cdac51 100644
--- a/docs/docs/tutorial/jsonnet.mdx
+++ b/docs/docs/tutorial/jsonnet.mdx
@@ -88,7 +88,7 @@ Similar to JSON, each `.jsonnet` file holds a single object. The one returned by
 ```
 
 They may be deeply nested, Tanka extracts everything that looks like a
-Kubebernetes resource automatically.
+Kubernetes resource automatically.
 
 So let's rewrite the [previous `.yaml`](/tutorial/refresher#writing-the-yaml) to
 very basic `.jsonnet`:
diff --git a/docs/docs/tutorial/k-lib.mdx b/docs/docs/tutorial/k-lib.mdx
index d6d6c40a..d1b621e8 100644
--- a/docs/docs/tutorial/k-lib.mdx
+++ b/docs/docs/tutorial/k-lib.mdx
@@ -29,7 +29,7 @@ available at https://github.com/ksonnet/ksonnet-lib.
 However while using it internally we have discovered that the exposed API has
 several annoyances. To address them, we developed another library that builds on
 top of the generated one but improves the developer experience:
-https://github.com/grafana/jsonnet-libs/ksonnet-util
+https://github.com/grafana/jsonnet-libs/tree/master/ksonnet-util
 
 If you do not have any strong reasons against it, just adopt the wrapper as
 well, it will ease your work. Ultimately, we hope to integrate our enhancements
diff --git a/docs/docs/tutorial/parameterizing.mdx b/docs/docs/tutorial/parameterizing.mdx
index fff55854..3fa6d7c0 100644
--- a/docs/docs/tutorial/parameterizing.mdx
+++ b/docs/docs/tutorial/parameterizing.mdx
@@ -56,7 +56,7 @@ We can then replace hardcoded values with a reference to this object:
       spec: {
         ports: [{
 -           name: 'grafana-ui',
-+           name: '%s-ui' % $._config.grafana.port, // printf-style formatting
++           name: '%s-ui' % $._config.grafana.name, // printf-style formatting
 -           port: 3000,
 +           port: $._config.grafana.port,
 -           targetPort: 3000,
diff --git a/docs/docs/tutorial/refresher.mdx b/docs/docs/tutorial/refresher.mdx
index 13b8c91a..42535014 100644
--- a/docs/docs/tutorial/refresher.mdx
+++ b/docs/docs/tutorial/refresher.mdx
@@ -136,7 +136,7 @@ Grafana can connect to Prometheus!
 
 ```bash
 # Temporarily forward Grafana to localhost
-kubectl port-forward deployments/grafana 3000:8080
+kubectl port-forward deployments/grafana 8080:3000
 ```
 
 Now go to http://localhost:8080 in your browser and login using `admin:admin`.
diff --git a/pkg/kubernetes/client/context.go b/pkg/kubernetes/client/context.go
index 3b3d1d84..b13922de 100644
--- a/pkg/kubernetes/client/context.go
+++ b/pkg/kubernetes/client/context.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"strings"
 
 	"github.com/stretchr/objx"
 	funk "github.com/thoas/go-funk"
@@ -17,47 +18,90 @@ func (k *Kubectl) setupContext() error {
 		return nil
 	}
 
+	var err error
+	k.cluster, k.context, err = ContextFromIP(k.APIServer)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// Kubeconfig returns the merged $KUBECONFIG of the host
+func Kubeconfig() (map[string]interface{}, error) {
 	cmd := exec.Command("kubectl", "config", "view", "-o", "json")
 	cfgJSON := bytes.Buffer{}
 	cmd.Stdout = &cfgJSON
 	cmd.Stderr = os.Stderr
 	if err := cmd.Run(); err != nil {
-		return err
+		return nil, err
 	}
 	var cfg map[string]interface{}
 	if err := json.Unmarshal(cfgJSON.Bytes(), &cfg); err != nil {
-		return err
+		return nil, err
 	}
+	return cfg, nil
+}
 
-	var err error
-	k.cluster, k.context, err = contextFromKubeconfig(cfg, k.APIServer)
-	if err != nil {
-		return err
+func Contexts() ([]string, error) {
+	cmd := exec.Command("kubectl", "config", "get-contexts", "-o=name")
+	buf := bytes.Buffer{}
+	cmd.Stdout = &buf
+	cmd.Stderr = os.Stderr
+	if err := cmd.Run(); err != nil {
+		return nil, err
 	}
-	return nil
+
+	return strings.Split(buf.String(), "\n"), nil
 }
 
-// contextFromKubeconfig searches a kubeconfig for a context of a cluster that matches the apiServer
-func contextFromKubeconfig(kubeconfig map[string]interface{}, apiServer string) (cluster, context objx.Map, err error) {
+// ContextFromIP searches the $KUBECONFIG for a context of a cluster that matches the apiServer
+func ContextFromIP(apiServer string) (cluster, context objx.Map, err error) {
+	kubeconfig, err := Kubeconfig()
+	if err != nil {
+		return nil, nil, err
+	}
 	cfg := objx.New(kubeconfig)
 
 	// find the correct cluster
-	cluster = objx.New(funk.Find(cfg.Get("clusters").MustMSISlice(), func(x map[string]interface{}) bool {
-		host := objx.New(x).Get("cluster.server").MustStr()
-		return host == apiServer
-	}))
-	if len(cluster) == 0 { // empty map means no result
+	cluster = find(cfg.Get("clusters").MustMSISlice(), "cluster.server", apiServer)
+	if cluster == nil { // empty map means no result
 		return nil, nil, fmt.Errorf("no cluster that matches the apiServer `%s` was found. Please check your $KUBECONFIG", apiServer)
 	}
 
 	// find a context that uses the cluster
-	context = objx.New(funk.Find(cfg.Get("contexts").MustMSISlice(), func(x map[string]interface{}) bool {
-		c := objx.New(x)
-		return c.Get("context.cluster").MustStr() == cluster.Get("name").MustStr()
-	}))
-	if !(len(context) > 0) {
+	context = find(cfg.Get("contexts").MustMSISlice(), "context.cluster", cluster.Get("name").MustStr())
+	if context == nil {
 		return nil, nil, fmt.Errorf("no context that matches the cluster `%s` was found. Please check your $KUBECONFIG", cluster.Get("name").MustStr())
 	}
 
 	return cluster, context, nil
 }
+
+func IPFromContext(name string) (ip string, err error) {
+	kubeconfig, err := Kubeconfig()
+	if err != nil {
+		return "", err
+	}
+	cfg := objx.New(kubeconfig)
+
+	// find the context
+	context := find(cfg.Get("contexts").MustMSISlice(), "name", name)
+	if context == nil {
+		return "", fmt.Errorf("no context named `%s` was found. Please check your $KUBECONFIG", name)
+	}
+
+	clusterName := context.Get("context.cluster").MustStr()
+	cluster := find(cfg.Get("clusters").MustMSISlice(), "name", clusterName)
+	if cluster == nil { // empty map means no result
+		return "", fmt.Errorf("no cluster named `%s` as required by context `%s` was found. Please check your $KUBECONFIG", clusterName, name)
+	}
+
+	return cluster.Get("cluster.server").MustStr(), nil
+}
+
+func find(list []map[string]interface{}, prop string, expected string) objx.Map {
+	return objx.New(funk.Find(list, func(x map[string]interface{}) bool {
+		got := objx.New(x).Get(prop).MustStr()
+		return got == expected
+	}))
+}
diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go
index 19cab78e..64194402 100644
--- a/pkg/kubernetes/manifest/manifest.go
+++ b/pkg/kubernetes/manifest/manifest.go
@@ -3,6 +3,7 @@ package manifest
 import (
 	"bytes"
 	"encoding/json"
+	"strings"
 
 	"github.com/pkg/errors"
 	"github.com/stretchr/objx"
@@ -48,11 +49,15 @@ func (m Manifest) Verify() error {
 	if !o.Get("apiVersion").IsStr() {
 		err.add("apiVersion")
 	}
-	if !o.Get("metadata").IsMSI() {
-		err.add("metadata")
-	}
-	if !o.Get("metadata.name").IsStr() && m.Kind() != "List" {
-		err.add("metadata.name")
+
+	// Lists don't have `metadata`
+	if !strings.HasSuffix(m.Kind(), "List") {
+		if !o.Get("metadata").IsMSI() {
+			err.add("metadata")
+		}
+		if !o.Get("metadata.name").IsStr() {
+			err.add("metadata.name")
+		}
 	}
 
 	if len(err.fields) == 0 {
@@ -74,6 +79,9 @@ func (m Manifest) APIVersion() string {
 
 // Metadata returns the metadata of this object
 func (m Manifest) Metadata() Metadata {
+	if m["metadata"] == nil {
+		m["metadata"] = make(map[string]interface{})
+	}
 	return Metadata(m["metadata"].(map[string]interface{}))
 }
 
@@ -104,7 +112,11 @@ type Metadata map[string]interface{}
 
 // Name of the manifest
 func (m Metadata) Name() string {
-	return m["name"].(string)
+	name, ok := m["name"]
+	if !ok {
+		return ""
+	}
+	return name.(string)
 }
 
 // HasNamespace returns whether the manifest has a namespace set
diff --git a/pkg/kubernetes/reconcile.go b/pkg/kubernetes/reconcile.go
index 78d1a8cc..b8ecf551 100644
--- a/pkg/kubernetes/reconcile.go
+++ b/pkg/kubernetes/reconcile.go
@@ -99,9 +99,13 @@ func walkJSON(deep interface{}, extracted map[string]manifest.Manifest, path tra
 
 	// walk it
 	for key, d := range deep.(map[string]interface{}) {
-		if key == "__ksonnet" {
+		switch {
+		case key == "__ksonnet": // exclude ksonnet object
+			continue
+		case d == nil: // result from false if condition in Jsonnet
 			continue
 		}
+
 		path := append(path, key)
 
 		if _, ok := d.(map[string]interface{}); !ok {


Test patch:
diff --git a/pkg/kubernetes/reconcile_test.go b/pkg/kubernetes/reconcile_test.go
index fdecf160..d16c13d3 100644
--- a/pkg/kubernetes/reconcile_test.go
+++ b/pkg/kubernetes/reconcile_test.go
@@ -34,6 +34,15 @@ func TestExtract(t *testing.T) {
 			name: "array",
 			data: testDataArray(),
 		},
+		{
+			name: "nil",
+			data: func() testData {
+				d := testDataRegular()
+				d.deep.(map[string]interface{})["disabledObject"] = nil
+				return d
+			}(),
+			err: nil, // we expect no error, just the result of testDataRegular
+		},
 	}
 
 	for _, c := range tests {