cluster/tools/kartongips: init

This forks bitnami/kubecfg into kartongips. The rationale is that we
want to implement hscloud-specific functionality that wouldn't really be
upstreamable into kubecfg (like secret support, mulit-cluster support).

We forked off from github.com/q3k/kubecfg at commit b6817a94492c561ed61a44eeea2d92dcf2e6b8c0.

Change-Id: If5ba513905e0a86f971576fe7061a471c1d8b398
diff --git a/cluster/tools/kartongips/cmd/BUILD.bazel b/cluster/tools/kartongips/cmd/BUILD.bazel
new file mode 100644
index 0000000..dee1b41
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/BUILD.bazel
@@ -0,0 +1,50 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "completion.go",
+        "delete.go",
+        "diff.go",
+        "root.go",
+        "show.go",
+        "update.go",
+        "validate.go",
+        "version.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/cluster/tools/kartongips/cmd",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//cluster/tools/kartongips/pkg/kubecfg:go_default_library",
+        "//cluster/tools/kartongips/utils:go_default_library",
+        "@com_github_genuinetools_reg//registry:go_default_library",
+        "@com_github_google_go_jsonnet//:go_default_library",
+        "@com_github_sirupsen_logrus//:go_default_library",
+        "@com_github_spf13_cobra//:go_default_library",
+        "@io_k8s_apimachinery//pkg/api/meta:go_default_library",
+        "@io_k8s_apimachinery//pkg/apis/meta/v1/unstructured:go_default_library",
+        "@io_k8s_client_go//discovery:go_default_library",
+        "@io_k8s_client_go//dynamic:go_default_library",
+        "@io_k8s_client_go//pkg/version:go_default_library",
+        "@io_k8s_client_go//plugin/pkg/client/auth:go_default_library",
+        "@io_k8s_client_go//restmapper:go_default_library",
+        "@io_k8s_client_go//tools/clientcmd:go_default_library",
+        "@io_k8s_klog//:go_default_library",
+        "@org_golang_x_crypto//ssh/terminal:go_default_library",
+    ],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = [
+        "completion_test.go",
+        "show_test.go",
+        "version_test.go",
+    ],
+    embed = [":go_default_library"],
+    deps = [
+        "@com_github_spf13_cobra//:go_default_library",
+        "@com_github_spf13_pflag//:go_default_library",
+        "@in_gopkg_yaml_v2//:go_default_library",
+    ],
+)
diff --git a/cluster/tools/kartongips/cmd/completion.go b/cluster/tools/kartongips/cmd/completion.go
new file mode 100644
index 0000000..eb0f3ce
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/completion.go
@@ -0,0 +1,75 @@
+// Copyright 2018 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"unicode"
+
+	"github.com/spf13/cobra"
+)
+
+const (
+	flagShell = "shell"
+)
+
+func guessShell(path string) string {
+	ret := filepath.Base(path)
+	ret = strings.TrimRightFunc(ret, unicode.IsNumber)
+	return ret
+}
+
+func init() {
+	RootCmd.AddCommand(completionCmd)
+	completionCmd.PersistentFlags().String(flagShell, "", "Shell variant for which to generate completions.  Supported values are bash,zsh")
+}
+
+var completionCmd = &cobra.Command{
+	Use:   "completion",
+	Short: "Generate shell completions for kubecfg",
+	Args:  cobra.NoArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+
+		shell, err := flags.GetString(flagShell)
+		if err != nil {
+			return err
+		}
+		if shell == "" {
+			shell = guessShell(os.Getenv("SHELL"))
+		}
+
+		out := cmd.OutOrStdout()
+
+		switch shell {
+		case "bash":
+			if err := RootCmd.GenBashCompletion(out); err != nil {
+				return err
+			}
+		case "zsh":
+			if err := RootCmd.GenZshCompletion(out); err != nil {
+				return err
+			}
+		default:
+			return fmt.Errorf("Unknown shell %q, try --%s", shell, flagShell)
+		}
+
+		return nil
+	},
+}
diff --git a/cluster/tools/kartongips/cmd/completion_test.go b/cluster/tools/kartongips/cmd/completion_test.go
new file mode 100644
index 0000000..1334aa2
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/completion_test.go
@@ -0,0 +1,19 @@
+package cmd
+
+import (
+	"testing"
+)
+
+func TestGuessShell(t *testing.T) {
+	t.Parallel()
+
+	for _, test := range [][]string{
+		{"/bin/bash", "bash"},
+		{"/usr/bin/zsh", "zsh"},
+		{"/usr/bin/zsh5", "zsh"},
+	} {
+		if result := guessShell(test[0]); result != test[1] {
+			t.Errorf("Guessed %q instead of %q from %q", result, test[1], test[0])
+		}
+	}
+}
diff --git a/cluster/tools/kartongips/cmd/delete.go b/cluster/tools/kartongips/cmd/delete.go
new file mode 100644
index 0000000..5cca212
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/delete.go
@@ -0,0 +1,65 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/pkg/kubecfg"
+)
+
+const (
+	flagGracePeriod = "grace-period"
+)
+
+func init() {
+	RootCmd.AddCommand(deleteCmd)
+	deleteCmd.PersistentFlags().Int64(flagGracePeriod, -1, "Number of seconds given to resources to terminate gracefully. A negative value is ignored")
+}
+
+var deleteCmd = &cobra.Command{
+	Use:   "delete",
+	Short: "Delete Kubernetes resources described in local config",
+	Args:  cobra.ArbitraryArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+		var err error
+
+		c := kubecfg.DeleteCmd{}
+
+		c.GracePeriod, err = flags.GetInt64(flagGracePeriod)
+		if err != nil {
+			return err
+		}
+
+		c.Client, c.Mapper, c.Discovery, err = getDynamicClients(cmd)
+		if err != nil {
+			return err
+		}
+
+		c.DefaultNamespace, err = defaultNamespace(clientConfig)
+		if err != nil {
+			return err
+		}
+
+		objs, err := readObjs(cmd, args)
+		if err != nil {
+			return err
+		}
+
+		return c.Run(cmd.Context(), objs)
+	},
+}
diff --git a/cluster/tools/kartongips/cmd/diff.go b/cluster/tools/kartongips/cmd/diff.go
new file mode 100644
index 0000000..47d92ab
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/diff.go
@@ -0,0 +1,72 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/pkg/kubecfg"
+)
+
+const (
+	flagDiffStrategy = "diff-strategy"
+	flagOmitSecrets  = "omit-secrets"
+)
+
+func init() {
+	diffCmd.PersistentFlags().String(flagDiffStrategy, "all", "Diff strategy, all or subset.")
+	diffCmd.PersistentFlags().Bool(flagOmitSecrets, false, "hide secret details when showing diff")
+	RootCmd.AddCommand(diffCmd)
+}
+
+var diffCmd = &cobra.Command{
+	Use:   "diff",
+	Short: "Display differences between server and local config",
+	Args:  cobra.ArbitraryArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+		var err error
+
+		c := kubecfg.DiffCmd{}
+
+		c.DiffStrategy, err = flags.GetString(flagDiffStrategy)
+		if err != nil {
+			return err
+		}
+
+		c.OmitSecrets, err = flags.GetBool(flagOmitSecrets)
+		if err != nil {
+			return err
+		}
+
+		c.Client, c.Mapper, _, err = getDynamicClients(cmd)
+		if err != nil {
+			return err
+		}
+
+		c.DefaultNamespace, err = defaultNamespace(clientConfig)
+		if err != nil {
+			return err
+		}
+
+		objs, err := readObjs(cmd, args)
+		if err != nil {
+			return err
+		}
+
+		return c.Run(cmd.Context(), objs, cmd.OutOrStdout())
+	},
+}
diff --git a/cluster/tools/kartongips/cmd/root.go b/cluster/tools/kartongips/cmd/root.go
new file mode 100644
index 0000000..b979907
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/root.go
@@ -0,0 +1,423 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"bytes"
+	"encoding/json"
+	goflag "flag"
+	"fmt"
+	"io"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/genuinetools/reg/registry"
+
+	jsonnet "github.com/google/go-jsonnet"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+	"golang.org/x/crypto/ssh/terminal"
+	"k8s.io/apimachinery/pkg/api/meta"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/client-go/discovery"
+	"k8s.io/client-go/dynamic"
+	"k8s.io/client-go/restmapper"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/klog"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/utils"
+
+	// Register auth plugins
+	_ "k8s.io/client-go/plugin/pkg/client/auth"
+)
+
+const (
+	flagVerbose     = "verbose"
+	flagJpath       = "jpath"
+	flagJUrl        = "jurl"
+	flagExtVar      = "ext-str"
+	flagExtVarFile  = "ext-str-file"
+	flagExtCode     = "ext-code"
+	flagExtCodeFile = "ext-code-file"
+	flagTLAVar      = "tla-str"
+	flagTLAVarFile  = "tla-str-file"
+	flagTLACode     = "tla-code"
+	flagTLACodeFile = "tla-code-file"
+	flagResolver    = "resolve-images"
+	flagResolvFail  = "resolve-images-error"
+)
+
+var clientConfig clientcmd.ClientConfig
+var overrides clientcmd.ConfigOverrides
+
+func init() {
+	RootCmd.PersistentFlags().CountP(flagVerbose, "v", "Increase verbosity. May be given multiple times.")
+	RootCmd.PersistentFlags().StringArrayP(flagJpath, "J", nil, "Additional Jsonnet library search path, appended to the ones in the KUBECFG_JPATH env var. May be repeated.")
+	RootCmd.MarkPersistentFlagFilename(flagJpath)
+	RootCmd.PersistentFlags().StringArrayP(flagJUrl, "U", nil, "Additional Jsonnet library search path given as a URL. May be repeated.")
+	RootCmd.PersistentFlags().StringArrayP(flagExtVar, "V", nil, "Values of external variables with string values")
+	RootCmd.PersistentFlags().StringArray(flagExtVarFile, nil, "Read external variables with string values from files")
+	RootCmd.MarkPersistentFlagFilename(flagExtVarFile)
+	RootCmd.PersistentFlags().StringArray(flagExtCode, nil, "Values of external variables with values supplied as Jsonnet code")
+	RootCmd.PersistentFlags().StringArray(flagExtCodeFile, nil, "Read external variables with values supplied as Jsonnet code from files")
+	RootCmd.MarkPersistentFlagFilename(flagExtCodeFile)
+	RootCmd.PersistentFlags().StringArrayP(flagTLAVar, "A", nil, "Values of top level arguments with string values")
+	RootCmd.PersistentFlags().StringArray(flagTLAVarFile, nil, "Read top level arguments with string values from files")
+	RootCmd.MarkPersistentFlagFilename(flagTLAVarFile)
+	RootCmd.PersistentFlags().StringArray(flagTLACode, nil, "Values of top level arguments with values supplied as Jsonnet code")
+	RootCmd.PersistentFlags().StringArray(flagTLACodeFile, nil, "Read top level arguments with values supplied as Jsonnet code from files")
+	RootCmd.MarkPersistentFlagFilename(flagTLACodeFile)
+	RootCmd.PersistentFlags().String(flagResolver, "noop", "Change implementation of resolveImage native function. One of: noop, registry")
+	RootCmd.PersistentFlags().String(flagResolvFail, "warn", "Action when resolveImage fails. One of ignore,warn,error")
+
+	// The "usual" clientcmd/kubectl flags
+	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
+	loadingRules.DefaultClientConfig = &clientcmd.DefaultClientConfig
+	kflags := clientcmd.RecommendedConfigOverrideFlags("")
+	RootCmd.PersistentFlags().StringVar(&loadingRules.ExplicitPath, "kubeconfig", "", "Path to a kube config. Only required if out-of-cluster")
+	RootCmd.MarkPersistentFlagFilename("kubeconfig")
+	clientcmd.BindOverrideFlags(&overrides, RootCmd.PersistentFlags(), kflags)
+	clientConfig = clientcmd.NewInteractiveDeferredLoadingClientConfig(loadingRules, &overrides, os.Stdin)
+}
+
+// RootCmd is the root of cobra subcommand tree
+var RootCmd = &cobra.Command{
+	Use:           "kubecfg",
+	Short:         "Synchronise Kubernetes resources with config files",
+	SilenceErrors: true,
+	SilenceUsage:  true,
+	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+		goflag.CommandLine.Parse([]string{})
+		flags := cmd.Flags()
+		out := cmd.OutOrStderr()
+		log.SetOutput(out)
+
+		logFmt := NewLogFormatter(out)
+		log.SetFormatter(logFmt)
+
+		verbosity, err := flags.GetCount(flagVerbose)
+		if err != nil {
+			return err
+		}
+		log.SetLevel(logLevel(verbosity))
+
+		// Ask me how much I love glog/klog's interface.
+		logflags := goflag.NewFlagSet(os.Args[0], goflag.ExitOnError)
+		klog.InitFlags(logflags)
+		logflags.Set("logtostderr", "true")
+		if verbosity >= 2 {
+			// Semi-arbitrary mapping to klog level.
+			logflags.Set("v", fmt.Sprintf("%d", verbosity*3))
+		}
+
+		return nil
+	},
+}
+
+// clientConfig.Namespace() is broken in client-go 3.0:
+// namespace in config erroneously overrides explicit --namespace
+func defaultNamespace(c clientcmd.ClientConfig) (string, error) {
+	if overrides.Context.Namespace != "" {
+		return overrides.Context.Namespace, nil
+	}
+	ns, _, err := c.Namespace()
+	return ns, err
+}
+
+func logLevel(verbosity int) log.Level {
+	switch verbosity {
+	case 0:
+		return log.InfoLevel
+	default:
+		return log.DebugLevel
+	}
+}
+
+type logFormatter struct {
+	escapes  *terminal.EscapeCodes
+	colorise bool
+}
+
+// NewLogFormatter creates a new log.Formatter customised for writer
+func NewLogFormatter(out io.Writer) log.Formatter {
+	var ret = logFormatter{}
+	if f, ok := out.(*os.File); ok {
+		ret.colorise = terminal.IsTerminal(int(f.Fd()))
+		ret.escapes = terminal.NewTerminal(f, "").Escape
+	}
+	return &ret
+}
+
+func (f *logFormatter) levelEsc(level log.Level) []byte {
+	switch level {
+	case log.DebugLevel:
+		return []byte{}
+	case log.WarnLevel:
+		return f.escapes.Yellow
+	case log.ErrorLevel, log.FatalLevel, log.PanicLevel:
+		return f.escapes.Red
+	default:
+		return f.escapes.Blue
+	}
+}
+
+func (f *logFormatter) Format(e *log.Entry) ([]byte, error) {
+	buf := bytes.Buffer{}
+	if f.colorise {
+		buf.Write(f.levelEsc(e.Level))
+		fmt.Fprintf(&buf, "%-5s ", strings.ToUpper(e.Level.String()))
+		buf.Write(f.escapes.Reset)
+	}
+
+	buf.WriteString(strings.TrimSpace(e.Message))
+	buf.WriteString("\n")
+
+	return buf.Bytes(), nil
+}
+
+// NB: `path` is assumed to be in native-OS path separator form
+func dirURL(path string) *url.URL {
+	path = filepath.ToSlash(path)
+	if path[len(path)-1] != '/' {
+		// trailing slash is important
+		path = path + "/"
+	}
+	return &url.URL{Scheme: "file", Path: path}
+}
+
+// JsonnetVM constructs a new jsonnet.VM, according to command line
+// flags
+func JsonnetVM(cmd *cobra.Command) (*jsonnet.VM, error) {
+	vm := jsonnet.MakeVM()
+	flags := cmd.Flags()
+
+	var searchUrls []*url.URL
+
+	jpath := filepath.SplitList(os.Getenv("KUBECFG_JPATH"))
+
+	jpathArgs, err := flags.GetStringArray(flagJpath)
+	if err != nil {
+		return nil, err
+	}
+	jpath = append(jpath, jpathArgs...)
+
+	for _, p := range jpath {
+		p, err := filepath.Abs(p)
+		if err != nil {
+			return nil, err
+		}
+		searchUrls = append(searchUrls, dirURL(p))
+	}
+
+	sURLs, err := flags.GetStringArray(flagJUrl)
+	if err != nil {
+		return nil, err
+	}
+
+	// Special URL scheme used to find embedded content
+	sURLs = append(sURLs, "internal:///")
+
+	for _, ustr := range sURLs {
+		u, err := url.Parse(ustr)
+		if err != nil {
+			return nil, err
+		}
+		if u.Path[len(u.Path)-1] != '/' {
+			u.Path = u.Path + "/"
+		}
+		searchUrls = append(searchUrls, u)
+	}
+
+	for _, u := range searchUrls {
+		log.Debugln("Jsonnet search path:", u)
+	}
+
+	cwd, err := os.Getwd()
+	if err != nil {
+		return nil, fmt.Errorf("Unable to determine current working directory: %v", err)
+	}
+
+	vm.Importer(utils.MakeUniversalImporter(searchUrls))
+
+	for _, spec := range []struct {
+		flagName string
+		inject   func(string, string)
+		isCode   bool
+		fromFile bool
+	}{
+		{flagExtVar, vm.ExtVar, false, false},
+		// Treat as code to evaluate "importstr":
+		{flagExtVarFile, vm.ExtCode, false, true},
+		{flagExtCode, vm.ExtCode, true, false},
+		{flagExtCodeFile, vm.ExtCode, true, true},
+		{flagTLAVar, vm.TLAVar, false, false},
+		// Treat as code to evaluate "importstr":
+		{flagTLAVarFile, vm.TLACode, false, true},
+		{flagTLACode, vm.TLACode, true, false},
+		{flagTLACodeFile, vm.TLACode, true, true},
+	} {
+		entries, err := flags.GetStringArray(spec.flagName)
+		if err != nil {
+			return nil, err
+		}
+		for _, entry := range entries {
+			kv := strings.SplitN(entry, "=", 2)
+			if spec.fromFile {
+				if len(kv) != 2 {
+					return nil, fmt.Errorf("Failed to parse %s: missing '=' in %s", spec.flagName, entry)
+				}
+				// Ensure that the import path we construct here is absolute, so that our Importer
+				// won't try to glean from an extVar or TLA reference the context necessary to
+				// resolve a relative path.
+				path := kv[1]
+				if !filepath.IsAbs(path) {
+					path = filepath.Join(cwd, path)
+				}
+				u := &url.URL{Scheme: "file", Path: path}
+				var imp string
+				if spec.isCode {
+					imp = "import"
+				} else {
+					imp = "importstr"
+				}
+				spec.inject(kv[0], fmt.Sprintf("%s @'%s'", imp, strings.ReplaceAll(u.String(), "'", "''")))
+			} else {
+				switch len(kv) {
+				case 1:
+					if v, present := os.LookupEnv(kv[0]); present {
+						spec.inject(kv[0], v)
+					} else {
+						return nil, fmt.Errorf("Missing environment variable: %s", kv[0])
+					}
+				case 2:
+					spec.inject(kv[0], kv[1])
+				}
+			}
+		}
+	}
+
+	resolver, err := buildResolver(cmd)
+	if err != nil {
+		return nil, err
+	}
+	utils.RegisterNativeFuncs(vm, resolver)
+
+	return vm, nil
+}
+
+func buildResolver(cmd *cobra.Command) (utils.Resolver, error) {
+	flags := cmd.Flags()
+	resolver, err := flags.GetString(flagResolver)
+	if err != nil {
+		return nil, err
+	}
+	failAction, err := flags.GetString(flagResolvFail)
+	if err != nil {
+		return nil, err
+	}
+
+	ret := resolverErrorWrapper{}
+
+	switch failAction {
+	case "ignore":
+		ret.OnErr = func(error) error { return nil }
+	case "warn":
+		ret.OnErr = func(err error) error {
+			log.Warning(err.Error())
+			return nil
+		}
+	case "error":
+		ret.OnErr = func(err error) error { return err }
+	default:
+		return nil, fmt.Errorf("Bad value for --%s: %s", flagResolvFail, failAction)
+	}
+
+	switch resolver {
+	case "noop":
+		ret.Inner = utils.NewIdentityResolver()
+	case "registry":
+		ret.Inner = utils.NewRegistryResolver(registry.Opt{})
+	default:
+		return nil, fmt.Errorf("Bad value for --%s: %s", flagResolver, resolver)
+	}
+
+	return &ret, nil
+}
+
+type resolverErrorWrapper struct {
+	Inner utils.Resolver
+	OnErr func(error) error
+}
+
+func (r *resolverErrorWrapper) Resolve(image *utils.ImageName) error {
+	err := r.Inner.Resolve(image)
+	if err != nil {
+		err = r.OnErr(err)
+	}
+	return err
+}
+
+func readObjs(cmd *cobra.Command, paths []string) ([]*unstructured.Unstructured, error) {
+	vm, err := JsonnetVM(cmd)
+	if err != nil {
+		return nil, err
+	}
+
+	res := []*unstructured.Unstructured{}
+	for _, path := range paths {
+		objs, err := utils.Read(vm, path)
+		if err != nil {
+			return nil, fmt.Errorf("Error reading %s: %v", path, err)
+		}
+		res = append(res, utils.FlattenToV1(objs)...)
+	}
+	return res, nil
+}
+
+// For debugging
+func dumpJSON(v interface{}) string {
+	buf := bytes.NewBuffer(nil)
+	enc := json.NewEncoder(buf)
+	enc.SetIndent("", "  ")
+	if err := enc.Encode(v); err != nil {
+		return err.Error()
+	}
+	return string(buf.Bytes())
+}
+
+func getDynamicClients(cmd *cobra.Command) (dynamic.Interface, meta.RESTMapper, discovery.DiscoveryInterface, error) {
+	conf, err := clientConfig.ClientConfig()
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("Unable to read kubectl config: %v", err)
+	}
+
+	disco, err := discovery.NewDiscoveryClientForConfig(conf)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	discoCache := utils.NewMemcachedDiscoveryClient(disco)
+
+	mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoCache)
+
+	cl, err := dynamic.NewForConfig(conf)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	return cl, mapper, discoCache, nil
+}
diff --git a/cluster/tools/kartongips/cmd/show.go b/cluster/tools/kartongips/cmd/show.go
new file mode 100644
index 0000000..28ce572
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/show.go
@@ -0,0 +1,55 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/pkg/kubecfg"
+)
+
+const (
+	flagFormat = "format"
+)
+
+func init() {
+	RootCmd.AddCommand(showCmd)
+	showCmd.PersistentFlags().StringP(flagFormat, "o", "yaml", "Output format.  Supported values are: json, yaml")
+}
+
+var showCmd = &cobra.Command{
+	Use:   "show",
+	Short: "Show expanded resource definitions",
+	Args:  cobra.ArbitraryArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+		var err error
+
+		c := kubecfg.ShowCmd{}
+
+		c.Format, err = flags.GetString(flagFormat)
+		if err != nil {
+			return err
+		}
+
+		objs, err := readObjs(cmd, args)
+		if err != nil {
+			return err
+		}
+
+		return c.Run(objs, cmd.OutOrStdout())
+	},
+}
diff --git a/cluster/tools/kartongips/cmd/show_test.go b/cluster/tools/kartongips/cmd/show_test.go
new file mode 100644
index 0000000..ab6d60c
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/show_test.go
@@ -0,0 +1,164 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"bytes"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+	"gopkg.in/yaml.v2"
+)
+
+func resetFlagsOf(cmd *cobra.Command) {
+	cmd.Flags().VisitAll(func(f *pflag.Flag) {
+		if sv, ok := f.Value.(pflag.SliceValue); ok {
+			sv.Replace(nil)
+		} else {
+			f.Value.Set(f.DefValue)
+		}
+	})
+}
+
+func cmdOutput(t *testing.T, args []string) string {
+	var buf bytes.Buffer
+	RootCmd.SetOutput(&buf)
+	defer RootCmd.SetOutput(nil)
+
+	t.Log("Running args", args)
+	RootCmd.SetArgs(args)
+	if err := RootCmd.Execute(); err != nil {
+		t.Fatal("command failed:", err)
+	}
+
+	return buf.String()
+}
+
+func TestShow(t *testing.T) {
+	formats := map[string]func(string) (interface{}, error){
+		"json": func(text string) (ret interface{}, err error) {
+			err = json.Unmarshal([]byte(text), &ret)
+			return
+		},
+		"yaml": func(text string) (ret interface{}, err error) {
+			err = yaml.Unmarshal([]byte(text), &ret)
+			return
+		},
+	}
+
+	// Use the fact that JSON is also valid YAML ..
+	expected := `
+{
+  "apiVersion": "v0alpha1",
+  "kind": "TestObject",
+  "nil": null,
+  "bool": true,
+  "number": 42,
+  "string": "bar",
+  "notAVal": "aVal",
+  "notAnotherVal": "aVal2",
+  "filevar": "foo\n",
+  "array": ["one", 2, [3]],
+  "object": {"foo": "bar"},
+  "extcode": {"foo": 1, "bar": "test"}
+}
+`
+
+	for format, parser := range formats {
+		expected, err := parser(expected)
+		if err != nil {
+			t.Fatalf("error parsing *expected* value: %v", err)
+		}
+
+		os.Setenv("anVar", "aVal2")
+		defer os.Unsetenv("anVar")
+
+		output := cmdOutput(t, []string{"show",
+			"-J", filepath.FromSlash("../testdata/lib"),
+			"-o", format,
+			filepath.FromSlash("../testdata/test.jsonnet"),
+			"-V", "aVar=aVal",
+			"-V", "anVar",
+			"--ext-str-file", "filevar=" + filepath.FromSlash("../testdata/extvar.file"),
+			"--ext-code", `extcode={foo: 1, bar: "test"}`,
+		})
+		defer resetFlagsOf(RootCmd)
+
+		t.Log("output is", output)
+		actual, err := parser(output)
+		if err != nil {
+			t.Errorf("error parsing output of format %s: %v", format, err)
+		} else if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("format %s expected != actual: %s != %s", format, expected, actual)
+		}
+	}
+}
+
+func TestShowUsingExtVarFiles(t *testing.T) {
+	expectedText := `
+{
+  "apiVersion": "v1",
+  "kind": "ConfigMap",
+  "metadata": {
+    "name": "sink"
+  },
+  "data": {
+    "input": {
+      "greeting": "Hello!",
+      "helper": true,
+      "top": true
+    },
+    "var": "I'm a var!"
+  }
+}
+`
+	var expected interface{}
+	if err := json.Unmarshal([]byte(expectedText), &expected); err != nil {
+		t.Fatalf("error parsing *expected* value: %v", err)
+	}
+
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("failed to get current working directory: %v", err)
+	}
+	if err := os.Chdir("../testdata/extvars/feed"); err != nil {
+		t.Fatalf("failed to change to target directory: %v", err)
+	}
+	defer os.Chdir(cwd)
+
+	output := cmdOutput(t, []string{"show",
+		"top.jsonnet",
+		"-o", "json",
+		"--tla-code-file", "input=input.jsonnet",
+		"--tla-code-file", "sink=sink.jsonnet",
+		"--ext-str-file", "filevar=var.txt",
+	})
+	defer resetFlagsOf(RootCmd)
+
+	t.Log("output is", output)
+	var actual interface{}
+	err = json.Unmarshal([]byte(output), &actual)
+	if err != nil {
+		t.Errorf("error parsing output: %v", err)
+	} else if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("expected != actual: %s != %s", expected, actual)
+	}
+}
diff --git a/cluster/tools/kartongips/cmd/update.go b/cluster/tools/kartongips/cmd/update.go
new file mode 100644
index 0000000..7a741a6
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/update.go
@@ -0,0 +1,109 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/pkg/kubecfg"
+)
+
+const (
+	flagCreate   = "create"
+	flagSkipGc   = "skip-gc"
+	flagGcTag    = "gc-tag"
+	flagDryRun   = "dry-run"
+	flagValidate = "validate"
+)
+
+func init() {
+	RootCmd.AddCommand(updateCmd)
+	updateCmd.PersistentFlags().Bool(flagCreate, true, "Create missing resources")
+	updateCmd.PersistentFlags().Bool(flagSkipGc, false, "Don't perform garbage collection, even with --"+flagGcTag)
+	updateCmd.PersistentFlags().String(flagGcTag, "", "Add this tag to updated objects, and garbage collect existing objects with this tag and not in config")
+	updateCmd.PersistentFlags().Bool(flagDryRun, false, "Perform only read-only operations")
+	updateCmd.PersistentFlags().Bool(flagValidate, true, "Validate input against server schema")
+	updateCmd.PersistentFlags().Bool(flagIgnoreUnknown, false, "Don't fail validation if the schema for a given resource type is not found")
+}
+
+var updateCmd = &cobra.Command{
+	Use:   "update",
+	Short: "Update Kubernetes resources with local config",
+	Args:  cobra.ArbitraryArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+		var err error
+		c := kubecfg.UpdateCmd{}
+
+		validate, err := flags.GetBool(flagValidate)
+		if err != nil {
+			return err
+		}
+
+		c.Create, err = flags.GetBool(flagCreate)
+		if err != nil {
+			return err
+		}
+
+		c.GcTag, err = flags.GetString(flagGcTag)
+		if err != nil {
+			return err
+		}
+
+		c.SkipGc, err = flags.GetBool(flagSkipGc)
+		if err != nil {
+			return err
+		}
+
+		c.DryRun, err = flags.GetBool(flagDryRun)
+		if err != nil {
+			return err
+		}
+
+		c.Client, c.Mapper, c.Discovery, err = getDynamicClients(cmd)
+		if err != nil {
+			return err
+		}
+
+		c.DefaultNamespace, err = defaultNamespace(clientConfig)
+		if err != nil {
+			return err
+		}
+
+		objs, err := readObjs(cmd, args)
+		if err != nil {
+			return err
+		}
+
+		if validate {
+			v := kubecfg.ValidateCmd{
+				Mapper:    c.Mapper,
+				Discovery: c.Discovery,
+			}
+
+			v.IgnoreUnknown, err = flags.GetBool(flagIgnoreUnknown)
+			if err != nil {
+				return err
+			}
+
+			if err := v.Run(objs, cmd.OutOrStdout()); err != nil {
+				return err
+			}
+		}
+
+		return c.Run(cmd.Context(), objs)
+	},
+}
diff --git a/cluster/tools/kartongips/cmd/validate.go b/cluster/tools/kartongips/cmd/validate.go
new file mode 100644
index 0000000..d68bbd9
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/validate.go
@@ -0,0 +1,60 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/pkg/kubecfg"
+)
+
+const (
+	flagIgnoreUnknown = "ignore-unknown"
+)
+
+func init() {
+	RootCmd.AddCommand(validateCmd)
+	validateCmd.PersistentFlags().Bool(flagIgnoreUnknown, true, "Don't fail if the schema for a given resource type is not found")
+}
+
+var validateCmd = &cobra.Command{
+	Use:   "validate",
+	Short: "Compare generated manifest against server OpenAPI spec",
+	Args:  cobra.ArbitraryArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+		var err error
+
+		c := kubecfg.ValidateCmd{}
+
+		_, c.Mapper, c.Discovery, err = getDynamicClients(cmd)
+		if err != nil {
+			return err
+		}
+
+		c.IgnoreUnknown, err = flags.GetBool(flagIgnoreUnknown)
+		if err != nil {
+			return err
+		}
+
+		objs, err := readObjs(cmd, args)
+		if err != nil {
+			return err
+		}
+
+		return c.Run(objs, cmd.OutOrStdout())
+	},
+}
diff --git a/cluster/tools/kartongips/cmd/version.go b/cluster/tools/kartongips/cmd/version.go
new file mode 100644
index 0000000..fb9b7eb
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/version.go
@@ -0,0 +1,43 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"fmt"
+
+	jsonnet "github.com/google/go-jsonnet"
+	"github.com/spf13/cobra"
+	"k8s.io/client-go/pkg/version"
+)
+
+func init() {
+	RootCmd.AddCommand(versionCmd)
+}
+
+// Version is overridden by main
+var Version = "(dev build)"
+
+var versionCmd = &cobra.Command{
+	Use:   "version",
+	Short: "Print version information",
+	Args:  cobra.NoArgs,
+	Run: func(cmd *cobra.Command, args []string) {
+		out := cmd.OutOrStdout()
+		fmt.Fprintln(out, "kubecfg version:", Version)
+		fmt.Fprintln(out, "jsonnet version:", jsonnet.Version())
+		fmt.Fprintln(out, "client-go version:", version.Get())
+	},
+}
diff --git a/cluster/tools/kartongips/cmd/version_test.go b/cluster/tools/kartongips/cmd/version_test.go
new file mode 100644
index 0000000..68f6208
--- /dev/null
+++ b/cluster/tools/kartongips/cmd/version_test.go
@@ -0,0 +1,30 @@
+// Copyright 2017 The kubecfg authors
+//
+//
+//    Licensed under the Apache License, Version 2.0 (the "License");
+//    you may not use this file except in compliance with the License.
+//    You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+//    Unless required by applicable law or agreed to in writing, software
+//    distributed under the License is distributed on an "AS IS" BASIS,
+//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+//    See the License for the specific language governing permissions and
+//    limitations under the License.
+
+package cmd
+
+import (
+	"regexp"
+	"testing"
+)
+
+func TestVersion(t *testing.T) {
+	output := cmdOutput(t, []string{"version"})
+
+	// Also a good smoke-test that libjsonnet linked successfully
+	if !regexp.MustCompile(`jsonnet version: v[\d.]+`).MatchString(output) {
+		t.Error("Failed to find jsonnet version in:", output)
+	}
+}