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/pkg/kubecfg/BUILD.bazel b/cluster/tools/kartongips/pkg/kubecfg/BUILD.bazel
new file mode 100644
index 0000000..6a112b3
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/BUILD.bazel
@@ -0,0 +1,63 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "delete.go",
+        "diff.go",
+        "show.go",
+        "update.go",
+        "validate.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/cluster/tools/kartongips/pkg/kubecfg",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//cluster/tools/kartongips/utils:go_default_library",
+        "@com_github_evanphx_json_patch//:go_default_library",
+        "@com_github_mattn_go_isatty//:go_default_library",
+        "@com_github_sergi_go_diff//diffmatchpatch:go_default_library",
+        "@com_github_sirupsen_logrus//:go_default_library",
+        "@in_gopkg_yaml_v2//:go_default_library",
+        "@io_k8s_apiextensions_apiserver//pkg/apis/apiextensions/v1beta1:go_default_library",
+        "@io_k8s_apimachinery//pkg/api/equality:go_default_library",
+        "@io_k8s_apimachinery//pkg/api/errors:go_default_library",
+        "@io_k8s_apimachinery//pkg/api/meta:go_default_library",
+        "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library",
+        "@io_k8s_apimachinery//pkg/apis/meta/v1/unstructured:go_default_library",
+        "@io_k8s_apimachinery//pkg/runtime:go_default_library",
+        "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library",
+        "@io_k8s_apimachinery//pkg/util/diff:go_default_library",
+        "@io_k8s_apimachinery//pkg/util/jsonmergepatch:go_default_library",
+        "@io_k8s_apimachinery//pkg/util/sets:go_default_library",
+        "@io_k8s_apimachinery//pkg/util/strategicpatch:go_default_library",
+        "@io_k8s_apimachinery//pkg/util/wait:go_default_library",
+        "@io_k8s_client_go//discovery:go_default_library",
+        "@io_k8s_client_go//dynamic:go_default_library",
+        "@io_k8s_client_go//util/retry:go_default_library",
+        "@io_k8s_kube_openapi//pkg/util/proto:go_default_library",
+        "@io_k8s_kubectl//pkg/util/openapi:go_default_library",
+    ],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = [
+        "diff_test.go",
+        "update_test.go",
+    ],
+    embed = [":go_default_library"],
+    deps = [
+        "//cluster/tools/kartongips/utils:go_default_library",
+        "@com_github_golang_protobuf//proto:go_default_library",
+        "@com_github_googleapis_gnostic//openapiv2:go_default_library",
+        "@com_github_stretchr_testify//require:go_default_library",
+        "@io_k8s_apimachinery//pkg/api/equality:go_default_library",
+        "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library",
+        "@io_k8s_apimachinery//pkg/apis/meta/v1/unstructured:go_default_library",
+        "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library",
+        "@io_k8s_apimachinery//pkg/util/diff:go_default_library",
+        "@io_k8s_apimachinery//pkg/util/strategicpatch:go_default_library",
+        "@io_k8s_kube_openapi//pkg/util/proto:go_default_library",
+        "@io_k8s_kubectl//pkg/util/openapi:go_default_library",
+    ],
+)
diff --git a/cluster/tools/kartongips/pkg/kubecfg/delete.go b/cluster/tools/kartongips/pkg/kubecfg/delete.go
new file mode 100644
index 0000000..f2d225d
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/delete.go
@@ -0,0 +1,90 @@
+// 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 kubecfg
+
+import (
+	"context"
+	"fmt"
+	"sort"
+
+	log "github.com/sirupsen/logrus"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/client-go/discovery"
+	"k8s.io/client-go/dynamic"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/utils"
+)
+
+// DeleteCmd represents the delete subcommand
+type DeleteCmd struct {
+	Client           dynamic.Interface
+	Mapper           meta.RESTMapper
+	Discovery        discovery.DiscoveryInterface
+	DefaultNamespace string
+
+	GracePeriod int64
+}
+
+func (c DeleteCmd) Run(ctx context.Context, apiObjects []*unstructured.Unstructured) error {
+	version, err := utils.FetchVersion(c.Discovery)
+	if err != nil {
+		version = utils.GetDefaultVersion()
+		log.Warnf("Unable to parse server version. Received %v. Using default %s", err, version.String())
+	}
+
+	log.Infof("Fetching schemas for %d resources", len(apiObjects))
+	depOrder, err := utils.DependencyOrder(c.Discovery, c.Mapper, apiObjects)
+	if err != nil {
+		return err
+	}
+	sort.Sort(sort.Reverse(depOrder))
+
+	deleteOpts := metav1.DeleteOptions{}
+	if version.Compare(1, 6) < 0 {
+		// 1.5.x option
+		boolFalse := false
+		deleteOpts.OrphanDependents = &boolFalse
+	} else {
+		// 1.6.x option (NB: Background is broken)
+		fg := metav1.DeletePropagationForeground
+		deleteOpts.PropagationPolicy = &fg
+	}
+	if c.GracePeriod >= 0 {
+		deleteOpts.GracePeriodSeconds = &c.GracePeriod
+	}
+
+	for _, obj := range apiObjects {
+		desc := fmt.Sprintf("%s %s", utils.ResourceNameFor(c.Mapper, obj), utils.FqName(obj))
+		log.Info("Deleting ", desc)
+
+		client, err := utils.ClientForResource(c.Client, c.Mapper, obj, c.DefaultNamespace)
+		if err != nil {
+			return err
+		}
+
+		err = client.Delete(ctx, obj.GetName(), deleteOpts)
+		if err != nil && !errors.IsNotFound(err) {
+			return fmt.Errorf("Error deleting %s: %s", desc, err)
+		}
+
+		log.Debug("Deleted object: ", obj)
+	}
+
+	return nil
+}
diff --git a/cluster/tools/kartongips/pkg/kubecfg/diff.go b/cluster/tools/kartongips/pkg/kubecfg/diff.go
new file mode 100644
index 0000000..f1136be
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/diff.go
@@ -0,0 +1,233 @@
+// 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 kubecfg
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"regexp"
+	"sort"
+
+	isatty "github.com/mattn/go-isatty"
+	"github.com/sergi/go-diff/diffmatchpatch"
+	log "github.com/sirupsen/logrus"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/client-go/dynamic"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/utils"
+)
+
+var ErrDiffFound = fmt.Errorf("Differences found.")
+
+// Matches all the line starts on a diff text, which is where we put diff markers and indent
+var DiffLineStart = regexp.MustCompile("(^|\n)(.)")
+
+var DiffKeyValue = regexp.MustCompile(`"([-._a-zA-Z0-9]+)":\s"([[:alnum:]=+]+)",?`)
+
+// DiffCmd represents the diff subcommand
+type DiffCmd struct {
+	Client           dynamic.Interface
+	Mapper           meta.RESTMapper
+	DefaultNamespace string
+	OmitSecrets      bool
+
+	DiffStrategy string
+}
+
+func (c DiffCmd) Run(ctx context.Context, apiObjects []*unstructured.Unstructured, out io.Writer) error {
+	sort.Sort(utils.AlphabeticalOrder(apiObjects))
+
+	dmp := diffmatchpatch.New()
+	diffFound := false
+	for _, obj := range apiObjects {
+		desc := fmt.Sprintf("%s %s", utils.ResourceNameFor(c.Mapper, obj), utils.FqName(obj))
+		log.Debug("Fetching ", desc)
+
+		client, err := utils.ClientForResource(c.Client, c.Mapper, obj, c.DefaultNamespace)
+		if err != nil {
+			return err
+		}
+
+		if obj.GetName() == "" {
+			return fmt.Errorf("Error fetching one of the %s: it does not have a name set", utils.ResourceNameFor(c.Mapper, obj))
+		}
+
+		liveObj, err := client.Get(ctx, obj.GetName(), metav1.GetOptions{})
+		if err != nil && errors.IsNotFound(err) {
+			log.Debugf("%s doesn't exist on the server", desc)
+			liveObj = nil
+		} else if err != nil {
+			return fmt.Errorf("Error fetching %s: %v", desc, err)
+		}
+
+		fmt.Fprintln(out, "---")
+		fmt.Fprintf(out, "- live %s\n+ config %s\n", desc, desc)
+		if liveObj == nil {
+			fmt.Fprintf(out, "%s doesn't exist on server\n", desc)
+			diffFound = true
+			continue
+		}
+
+		liveObjObject := liveObj.Object
+		if c.DiffStrategy == "subset" {
+			liveObjObject = removeMapFields(obj.Object, liveObjObject)
+		}
+
+		liveObjText, _ := json.MarshalIndent(liveObjObject, "", "  ")
+		objText, _ := json.MarshalIndent(obj.Object, "", "  ")
+
+		liveObjTextLines, objTextLines, lines := dmp.DiffLinesToChars(string(liveObjText), string(objText))
+
+		diff := dmp.DiffMain(
+			string(liveObjTextLines),
+			string(objTextLines),
+			false)
+
+		diff = dmp.DiffCharsToLines(diff, lines)
+		if (len(diff) == 1) && (diff[0].Type == diffmatchpatch.DiffEqual) {
+			fmt.Fprintf(out, "%s unchanged\n", desc)
+		} else {
+			diffFound = true
+			text := c.formatDiff(diff, isatty.IsTerminal(os.Stdout.Fd()), c.OmitSecrets && obj.GetKind() == "Secret")
+			fmt.Fprintf(out, "%s\n", text)
+		}
+	}
+
+	if diffFound {
+		return ErrDiffFound
+	}
+	return nil
+}
+
+// Formats the supplied Diff as a unified-diff-like text with infinite context and optionally colorizes it.
+func (c DiffCmd) formatDiff(diffs []diffmatchpatch.Diff, color bool, omitchanges bool) string {
+	var buff bytes.Buffer
+
+	for _, diff := range diffs {
+		text := diff.Text
+
+		if omitchanges {
+			text = DiffKeyValue.ReplaceAllString(text, "$1: <omitted>")
+		}
+		switch diff.Type {
+		case diffmatchpatch.DiffInsert:
+			if color {
+				_, _ = buff.WriteString("\x1b[32m")
+			}
+			_, _ = buff.WriteString(DiffLineStart.ReplaceAllString(text, "$1+ $2"))
+			if color {
+				_, _ = buff.WriteString("\x1b[0m")
+			}
+		case diffmatchpatch.DiffDelete:
+			if color {
+				_, _ = buff.WriteString("\x1b[31m")
+			}
+			_, _ = buff.WriteString(DiffLineStart.ReplaceAllString(text, "$1- $2"))
+			if color {
+				_, _ = buff.WriteString("\x1b[0m")
+			}
+		case diffmatchpatch.DiffEqual:
+			if !omitchanges {
+				_, _ = buff.WriteString(DiffLineStart.ReplaceAllString(text, "$1  $2"))
+			}
+		}
+	}
+
+	return buff.String()
+}
+
+// See also feature request for golang reflect pkg at
+func isEmptyValue(i interface{}) bool {
+	switch v := i.(type) {
+	case []interface{}:
+		return len(v) == 0
+	case []string:
+		return len(v) == 0
+	case map[string]interface{}:
+		return len(v) == 0
+	case bool:
+		return !v
+	case float64:
+		return v == 0
+	case int64:
+		return v == 0
+	case string:
+		return v == ""
+	case nil:
+		return true
+	default:
+		panic(fmt.Sprintf("Found unexpected type %T in json unmarshal (value=%v)", i, i))
+	}
+}
+
+func removeFields(config, live interface{}) interface{} {
+	switch c := config.(type) {
+	case map[string]interface{}:
+		if live, ok := live.(map[string]interface{}); ok {
+			return removeMapFields(c, live)
+		}
+	case []interface{}:
+		if live, ok := live.([]interface{}); ok {
+			return removeListFields(c, live)
+		}
+	}
+	return live
+}
+
+func removeMapFields(config, live map[string]interface{}) map[string]interface{} {
+	result := map[string]interface{}{}
+	for k, v1 := range config {
+		v2, ok := live[k]
+		if !ok {
+			// Copy empty value from config, as API won't return them,
+			// see https://github.com/bitnami/kubecfg/issues/179
+			if isEmptyValue(v1) {
+				result[k] = v1
+			}
+			continue
+		}
+		result[k] = removeFields(v1, v2)
+	}
+	return result
+}
+
+func removeListFields(config, live []interface{}) []interface{} {
+	// If live is longer than config, then the extra elements at the end of the
+	// list will be returned as is so they appear in the diff.
+	result := make([]interface{}, 0, len(live))
+	for i, v2 := range live {
+		if len(config) > i {
+			result = append(result, removeFields(config[i], v2))
+		} else {
+			result = append(result, v2)
+		}
+	}
+	return result
+}
+
+func istty(w io.Writer) bool {
+	if f, ok := w.(*os.File); ok {
+		return isatty.IsTerminal(f.Fd())
+	}
+	return false
+}
diff --git a/cluster/tools/kartongips/pkg/kubecfg/diff_test.go b/cluster/tools/kartongips/pkg/kubecfg/diff_test.go
new file mode 100644
index 0000000..cb95123
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/diff_test.go
@@ -0,0 +1,198 @@
+// 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 kubecfg
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestRemoveListFields(t *testing.T) {
+	for _, tc := range []struct {
+		config, live, expected []interface{}
+	}{
+		{
+			config:   []interface{}{"a"},
+			live:     []interface{}{"a"},
+			expected: []interface{}{"a"},
+		},
+
+		// Check that extra fields in config are not propagated.
+		{
+			config:   []interface{}{"a", "b"},
+			live:     []interface{}{"a"},
+			expected: []interface{}{"a"},
+		},
+
+		// Check that extra entries in live are propagated.
+		{
+			config:   []interface{}{"a"},
+			live:     []interface{}{"a", "b"},
+			expected: []interface{}{"a", "b"},
+		},
+	} {
+		require.EqualValues(t, tc.expected, removeListFields(tc.config, tc.live))
+	}
+}
+
+func TestRemoveMapFields(t *testing.T) {
+	for _, tc := range []struct {
+		config, live, expected map[string]interface{}
+	}{
+		{
+			config:   map[string]interface{}{"foo": "bar"},
+			live:     map[string]interface{}{"foo": "bar"},
+			expected: map[string]interface{}{"foo": "bar"},
+		},
+
+		{
+			config:   map[string]interface{}{"foo": "bar", "bar": "baz"},
+			live:     map[string]interface{}{"foo": "bar"},
+			expected: map[string]interface{}{"foo": "bar"},
+		},
+
+		{
+			config:   map[string]interface{}{"foo": "bar"},
+			live:     map[string]interface{}{"foo": "bar", "bar": "baz"},
+			expected: map[string]interface{}{"foo": "bar"},
+		},
+	} {
+		require.Equal(t, tc.expected, removeMapFields(tc.config, tc.live))
+	}
+}
+
+func TestRemoveFields(t *testing.T) {
+	emptyVal := map[string]interface{}{
+		"args":    map[string]interface{}{},
+		"volumes": []string{},
+		"stdin":   false,
+	}
+	for _, tc := range []struct {
+		config, live, expected interface{}
+	}{
+		// Check we can handle embedded structs.
+		{
+			config:   map[string]interface{}{"foo": "bar", "bar": "baz"},
+			live:     map[string]interface{}{"foo": "bar"},
+			expected: map[string]interface{}{"foo": "bar"},
+		},
+		// JSON unmarshalling can return int64 for numbers
+		// https://golang.org/pkg/encoding/json/#Number
+		{
+			config:   map[string]interface{}{"foo": (int64)(10)},
+			live:     map[string]interface{}{},
+			expected: map[string]interface{}{},
+		},
+
+		// Check we can handle embedded lists.
+		{
+			config:   []interface{}{"a", "b"},
+			live:     []interface{}{"a"},
+			expected: []interface{}{"a"},
+		},
+
+		// Check we can handle arbitrary types.
+		{
+			config:   "a",
+			live:     "b",
+			expected: "b",
+		},
+		// Check we can handle mismatched types.
+		{
+			config:   map[string]interface{}{"foo": "bar"},
+			live:     []interface{}{"foo", "bar"},
+			expected: []interface{}{"foo", "bar"},
+		},
+		{
+			config:   []interface{}{"foo", "bar"},
+			live:     map[string]interface{}{"foo": "bar"},
+			expected: map[string]interface{}{"foo": "bar"},
+		},
+		// Check we handle empty configs by copying them as if were live
+		// (API won't return them)
+		{
+			config:   emptyVal,
+			live:     map[string]interface{}{},
+			expected: emptyVal,
+		},
+
+		// Check we can handle combinations.
+		{
+			config: map[string]interface{}{
+				"apiVersion": "v1",
+				"kind":       "Service",
+				"metadata": map[string]interface{}{
+					"name":      "foo",
+					"namespace": "default",
+				},
+				"spec": map[string]interface{}{
+					"selector": map[string]interface{}{
+						"name": "foo",
+					},
+					"ports": []interface{}{
+						map[string]interface{}{
+							"name": "http",
+							"port": 80,
+						},
+						map[string]interface{}{
+							"name": "https",
+							"port": 443,
+						},
+					},
+				},
+			},
+			live: map[string]interface{}{
+				"apiVersion": "v1",
+				"kind":       "Service",
+				"metadata": map[string]interface{}{
+					"name": "foo",
+					// NB Namespace missing.
+				},
+				"spec": map[string]interface{}{
+					"selector": map[string]interface{}{
+						"bar": "foo",
+					},
+					"ports": []interface{}{
+						// NB HTTP port missing.
+						map[string]interface{}{
+							"name": "https",
+							"port": 443,
+						},
+					},
+				},
+			},
+			expected: map[string]interface{}{
+				"apiVersion": "v1",
+				"kind":       "Service",
+				"metadata": map[string]interface{}{
+					"name": "foo",
+				},
+				"spec": map[string]interface{}{
+					"selector": map[string]interface{}{},
+					"ports": []interface{}{
+						map[string]interface{}{
+							"name": "https",
+							"port": 443,
+						},
+					},
+				},
+			},
+		},
+	} {
+		require.Equal(t, tc.expected, removeFields(tc.config, tc.live))
+	}
+}
diff --git a/cluster/tools/kartongips/pkg/kubecfg/show.go b/cluster/tools/kartongips/pkg/kubecfg/show.go
new file mode 100644
index 0000000..5379d68
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/show.go
@@ -0,0 +1,71 @@
+// 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 kubecfg
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+
+	yaml "gopkg.in/yaml.v2"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// ShowCmd represents the show subcommand
+type ShowCmd struct {
+	Format string
+}
+
+func (c ShowCmd) Run(apiObjects []*unstructured.Unstructured, out io.Writer) error {
+	switch c.Format {
+	case "yaml":
+		for _, obj := range apiObjects {
+			fmt.Fprintln(out, "---")
+			// Urgh.  Go via json because we need
+			// to trigger the custom scheme
+			// encoding.
+			buf, err := json.Marshal(obj)
+			if err != nil {
+				return err
+			}
+			o := map[string]interface{}{}
+			if err := json.Unmarshal(buf, &o); err != nil {
+				return err
+			}
+			buf, err = yaml.Marshal(o)
+			if err != nil {
+				return err
+			}
+			out.Write(buf)
+		}
+	case "json":
+		enc := json.NewEncoder(out)
+		enc.SetIndent("", "  ")
+		for _, obj := range apiObjects {
+			// TODO: this is not valid framing for JSON
+			if len(apiObjects) > 1 {
+				fmt.Fprintln(out, "---")
+			}
+			if err := enc.Encode(obj); err != nil {
+				return err
+			}
+		}
+	default:
+		return fmt.Errorf("Unknown --format: %s", c.Format)
+	}
+
+	return nil
+}
diff --git a/cluster/tools/kartongips/pkg/kubecfg/update.go b/cluster/tools/kartongips/pkg/kubecfg/update.go
new file mode 100644
index 0000000..928104b
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/update.go
@@ -0,0 +1,489 @@
+package kubecfg
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"time"
+
+	jsonpatch "github.com/evanphx/json-patch"
+	log "github.com/sirupsen/logrus"
+	apiext_v1b1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
+	apiequality "k8s.io/apimachinery/pkg/api/equality"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/diff"
+	"k8s.io/apimachinery/pkg/util/jsonmergepatch"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/apimachinery/pkg/util/strategicpatch"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/discovery"
+	"k8s.io/client-go/dynamic"
+	"k8s.io/client-go/util/retry"
+	"k8s.io/kube-openapi/pkg/util/proto"
+	"k8s.io/kubectl/pkg/util/openapi"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/utils"
+)
+
+const (
+	// AnnotationOrigObject annotation records the resource as it
+	// was most recently specified by kubecfg (serialised to
+	// JSON).  This is used as input to the strategic-merge-patch
+	// 3-way merge when performing updates.
+	AnnotationOrigObject = "kubecfg.ksonnet.io/last-applied-configuration"
+
+	// AnnotationGcTag annotation that triggers
+	// garbage collection. Objects with value equal to
+	// command-line flag that are *not* in config will be deleted.
+	//
+	// NB: this is in phase1 of a migration to use a label instead.
+	// At this stage, both label+migration are written, but the
+	// annotation (only) is still used to trigger GC. [gctag-migration]
+	AnnotationGcTag = LabelGcTag
+
+	// LabelGcTag label that triggers garbage collection. Objects
+	// with value equal to command-line flag that are *not* in
+	// config will be deleted.
+	//
+	// NB: this is in phase1 of a migration from an annotation.
+	// At this stage, both label+migration are written, but the
+	// annotation (only) is still used to trigger GC. [gctag-migration]
+	LabelGcTag = "kubecfg.ksonnet.io/garbage-collect-tag"
+
+	// AnnotationGcStrategy controls gc logic.  Current values:
+	// `auto` (default if absent) - do garbage collection
+	// `ignore` - never garbage collect this object
+	AnnotationGcStrategy = "kubecfg.ksonnet.io/garbage-collect-strategy"
+
+	// GcStrategyAuto is the default automatic gc logic
+	GcStrategyAuto = "auto"
+	// GcStrategyIgnore means this object should be ignored by garbage collection
+	GcStrategyIgnore = "ignore"
+)
+
+var (
+	gkCRD = schema.GroupKind{Group: "apiextensions.k8s.io", Kind: "CustomResourceDefinition"}
+)
+
+// UpdateCmd represents the update subcommand
+type UpdateCmd struct {
+	Client           dynamic.Interface
+	Mapper           meta.RESTMapper
+	Discovery        discovery.DiscoveryInterface
+	DefaultNamespace string
+
+	Create bool
+	GcTag  string
+	SkipGc bool
+	DryRun bool
+}
+
+func isValidKindSchema(schema proto.Schema) bool {
+	if schema == nil {
+		return false
+	}
+	patchMeta := strategicpatch.NewPatchMetaFromOpenAPI(schema)
+	_, _, err := patchMeta.LookupPatchMetadataForStruct("metadata")
+	if err != nil {
+		log.Debugf("Rejecting schema due to missing 'metadata' property (encountered %q)", err)
+	}
+	return err == nil
+}
+
+func patch(existing, new *unstructured.Unstructured, schema proto.Schema) (*unstructured.Unstructured, error) {
+	annos := existing.GetAnnotations()
+	var origData []byte
+	if data := annos[AnnotationOrigObject]; data != "" {
+		tmp := unstructured.Unstructured{}
+		err := utils.CompactDecodeObject(data, &tmp)
+		if err != nil {
+			return nil, err
+		}
+		origData, err = tmp.MarshalJSON()
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	log.Debugf("origData: %s", origData)
+
+	new = new.DeepCopy()
+	utils.DeleteMetaDataAnnotation(new, AnnotationOrigObject)
+	data, err := utils.CompactEncodeObject(new)
+	if err != nil {
+		return nil, err
+	}
+	utils.SetMetaDataAnnotation(new, AnnotationOrigObject, data)
+
+	// Note origData may be empty if last-applied annotation didn't exist
+
+	newData, err := new.MarshalJSON()
+	if err != nil {
+		return nil, err
+	}
+
+	existingData, err := existing.MarshalJSON()
+	if err != nil {
+		return nil, err
+	}
+
+	var resData []byte
+	if schema == nil {
+		// No schema information - fallback to JSON merge patch
+		patch, err := jsonmergepatch.CreateThreeWayJSONMergePatch(origData, newData, existingData)
+		if err != nil {
+			return nil, err
+		}
+		resData, err = jsonpatch.MergePatch(existingData, patch)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		patchMeta := strategicpatch.NewPatchMetaFromOpenAPI(schema)
+
+		patch, err := strategicpatch.CreateThreeWayMergePatch(origData, newData, existingData, patchMeta, true)
+		if err != nil {
+			return nil, err
+		}
+		resData, err = strategicpatch.StrategicMergePatchUsingLookupPatchMeta(existingData, patch, patchMeta)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	result, _, err := unstructured.UnstructuredJSONScheme.Decode(resData, nil, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return result.(*unstructured.Unstructured), nil
+}
+
+func createOrUpdate(ctx context.Context, rc dynamic.ResourceInterface, obj *unstructured.Unstructured, create bool, dryRun bool, schema proto.Schema, desc, dryRunText string) (*unstructured.Unstructured, error) {
+	existing, err := rc.Get(ctx, obj.GetName(), metav1.GetOptions{})
+	if create && errors.IsNotFound(err) {
+		log.Info("Creating ", desc, dryRunText)
+
+		data, err := utils.CompactEncodeObject(obj)
+		if err != nil {
+			return nil, err
+		}
+		utils.SetMetaDataAnnotation(obj, AnnotationOrigObject, data)
+
+		if dryRun {
+			return obj, nil
+		}
+		newobj, err := rc.Create(ctx, obj, metav1.CreateOptions{})
+		log.Debugf("Create(%s) returned (%v, %v)", obj.GetName(), newobj, err)
+		return newobj, err
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	mergedObj, err := patch(existing, obj, schema)
+	if err != nil {
+		return nil, err
+	}
+
+	// Kubernetes is a bit odd when/how it reports
+	// metadata.creationTimestamp.  Here, patch() gets confused by
+	// the explicit creationTimestamp=null (it's not omitEmpty).
+	// It's easiest here to just nuke any existing timestamp,
+	// since we don't care.
+	if ts := mergedObj.GetCreationTimestamp(); ts.IsZero() {
+		existing.SetCreationTimestamp(metav1.Time{})
+	}
+	if apiequality.Semantic.DeepEqual(existing, mergedObj) {
+		log.Debugf("Not updating %s - unchanged", desc)
+		return mergedObj, nil
+	}
+
+	log.Debug("About to make change: ", diff.ObjectDiff(existing, mergedObj))
+	log.Info("Updating ", desc, dryRunText)
+	if dryRun {
+		return mergedObj, nil
+	}
+	newobj, err := rc.Update(ctx, mergedObj, metav1.UpdateOptions{})
+	log.Debugf("Update(%s) returned (%v, %v)", mergedObj.GetName(), newobj, err)
+	if err != nil {
+		log.Debug("Updated object: ", diff.ObjectDiff(existing, newobj))
+	}
+	return newobj, err
+}
+
+// CustomResourceDefinitions modify the discovery metadata, so need
+// some extra help.  NB: This is also true of other things like
+// APIService registrations - we don't handle those automatically yet
+// (and perhaps never will in the full general case).
+func isSchemaEstablished(obj *unstructured.Unstructured) bool {
+	if obj.GroupVersionKind().GroupKind() != gkCRD {
+		// Not a CRD
+		return true
+	}
+
+	crd := apiext_v1b1.CustomResourceDefinition{}
+	converter := runtime.DefaultUnstructuredConverter
+	if err := converter.FromUnstructured(obj.UnstructuredContent(), &crd); err != nil {
+		log.Warnf("failed to parse CustomResourceDefinition: %v", err)
+		return false // retry
+	}
+
+	for _, cond := range crd.Status.Conditions {
+		if cond.Type == apiext_v1b1.Established && cond.Status == apiext_v1b1.ConditionTrue {
+			return true
+		}
+	}
+	return false
+}
+
+func waitForSchemaChange(ctx context.Context, disco discovery.DiscoveryInterface, rc dynamic.ResourceInterface, obj *unstructured.Unstructured) {
+	if isSchemaEstablished(obj) {
+		return
+	}
+	log.Debugf("Waiting for schema change from %v to become established", obj.GetName())
+	err := wait.Poll(100*time.Millisecond, 30*time.Minute, func() (bool, error) {
+		// Re-fetch discovery metadata
+		utils.MaybeMarkStale(disco)
+
+		var err error
+		obj, err = rc.Get(ctx, obj.GetName(), metav1.GetOptions{})
+		if err != nil {
+			if errors.IsNotFound(err) {
+				// continue polling
+				return false, nil
+			}
+			return false, err
+		}
+
+		return isSchemaEstablished(obj), nil
+	})
+	if err != nil {
+		log.Warnf("Encountered an error while waiting for new schema change to propagate (%v).  Ignoring and continuing, which may lead to further errors.", err)
+	}
+}
+
+// Run executes the update command
+func (c UpdateCmd) Run(ctx context.Context, apiObjects []*unstructured.Unstructured) error {
+	dryRunText := ""
+	if c.DryRun {
+		dryRunText = " (dry-run)"
+	}
+
+	log.Infof("Fetching schemas for %d resources", len(apiObjects))
+	depOrder, err := utils.DependencyOrder(c.Discovery, c.Mapper, apiObjects)
+	if err != nil {
+		return err
+	}
+	sort.Sort(depOrder)
+
+	seenUids := sets.NewString()
+
+	schemaDoc, err := c.Discovery.OpenAPISchema()
+	if err != nil {
+		return err
+	}
+	schemaResources, err := openapi.NewOpenAPIData(schemaDoc)
+	if err != nil {
+		return err
+	}
+
+	for _, obj := range apiObjects {
+		log.Debugf("Starting update of %s", utils.FqName(obj))
+
+		if c.GcTag != "" {
+			// [gctag-migration]: Remove annotation in phase2
+			utils.SetMetaDataAnnotation(obj, AnnotationGcTag, c.GcTag)
+			utils.SetMetaDataLabel(obj, LabelGcTag, c.GcTag)
+		}
+
+		desc := fmt.Sprintf("%s %s", utils.ResourceNameFor(c.Mapper, obj), utils.FqName(obj))
+
+		rc, err := utils.ClientForResource(c.Client, c.Mapper, obj, c.DefaultNamespace)
+		if err != nil {
+			return err
+		}
+
+		schema := schemaResources.LookupResource(obj.GroupVersionKind())
+		if !isValidKindSchema(schema) {
+			// Invalid schema (eg: custom resource without
+			// schema returns trivial type:object with k8s >=1.15)
+			log.Debugf("Ignoring invalid schema for %s", obj.GroupVersionKind())
+			schema = nil
+		}
+
+		var newobj *unstructured.Unstructured
+		err = retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) {
+			newobj, err = createOrUpdate(ctx, rc, obj, c.Create, c.DryRun, schema, desc, dryRunText)
+			return
+		})
+		if err != nil {
+			return fmt.Errorf("Error updating %s: %s", desc, err)
+		}
+
+		// Some objects appear under multiple kinds
+		// (eg: Deployment is both extensions/v1beta1
+		// and apps/v1beta1).  UID is the only stable
+		// identifier that links these two views of
+		// the same object.
+		seenUids.Insert(string(newobj.GetUID()))
+
+		// Don't wait for CRDs to settle schema under DryRun
+		if !c.DryRun {
+			waitForSchemaChange(ctx, c.Discovery, rc, newobj)
+		}
+	}
+
+	if c.GcTag != "" && !c.SkipGc {
+		version, err := utils.FetchVersion(c.Discovery)
+		if err != nil {
+			version = utils.GetDefaultVersion()
+			log.Warnf("Unable to parse server version. Received %v. Using default %s", err, version.String())
+		}
+
+		// [gctag-migration]: Add LabelGcTag==c.GcTag to ListOptions.LabelSelector in phase2
+		err = walkObjects(ctx, c.Client, c.Discovery, metav1.ListOptions{}, func(o runtime.Object) error {
+			meta, err := meta.Accessor(o)
+			if err != nil {
+				return err
+			}
+			gvk := o.GetObjectKind().GroupVersionKind()
+			desc := fmt.Sprintf("%s %s (%s)", utils.ResourceNameFor(c.Mapper, o), utils.FqName(meta), gvk.GroupVersion())
+			log.Debugf("Considering %v for gc", desc)
+			if eligibleForGc(meta, c.GcTag) && !seenUids.Has(string(meta.GetUID())) {
+				log.Info("Garbage collecting ", desc, dryRunText)
+				if !c.DryRun {
+					err := gcDelete(ctx, c.Client, c.Mapper, &version, o)
+					if err != nil {
+						return err
+					}
+				}
+			}
+			return nil
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func stringListContains(list []string, value string) bool {
+	for _, item := range list {
+		if item == value {
+			return true
+		}
+	}
+	return false
+}
+
+func gcDelete(ctx context.Context, client dynamic.Interface, mapper meta.RESTMapper, version *utils.ServerVersion, o runtime.Object) error {
+	obj, err := meta.Accessor(o)
+	if err != nil {
+		return fmt.Errorf("Unexpected object type: %s", err)
+	}
+
+	uid := obj.GetUID()
+	desc := fmt.Sprintf("%s %s", utils.ResourceNameFor(mapper, o), utils.FqName(obj))
+
+	deleteOpts := metav1.DeleteOptions{
+		Preconditions: &metav1.Preconditions{UID: &uid},
+	}
+	if version.Compare(1, 6) < 0 {
+		// 1.5.x option
+		boolFalse := false
+		deleteOpts.OrphanDependents = &boolFalse
+	} else {
+		// 1.6.x option (NB: Background is broken)
+		fg := metav1.DeletePropagationForeground
+		deleteOpts.PropagationPolicy = &fg
+	}
+
+	c, err := utils.ClientForResource(client, mapper, o, metav1.NamespaceNone)
+	if err != nil {
+		return err
+	}
+
+	err = c.Delete(ctx, obj.GetName(), deleteOpts)
+	if err != nil && (errors.IsNotFound(err) || errors.IsConflict(err)) {
+		// We lost a race with something else changing the object
+		log.Debugf("Ignoring error while deleting %s: %s", desc, err)
+		err = nil
+	}
+	if err != nil {
+		return fmt.Errorf("Error deleting %s: %s", desc, err)
+	}
+
+	return nil
+}
+
+func walkObjects(ctx context.Context, client dynamic.Interface, disco discovery.DiscoveryInterface, listopts metav1.ListOptions, callback func(runtime.Object) error) error {
+	rsrclists, err := disco.ServerResources()
+	if err != nil {
+		return err
+	}
+	for _, rsrclist := range rsrclists {
+		gv, err := schema.ParseGroupVersion(rsrclist.GroupVersion)
+		if err != nil {
+			return err
+		}
+
+		for _, rsrc := range rsrclist.APIResources {
+			if !stringListContains(rsrc.Verbs, "list") {
+				log.Debugf("Don't know how to list %#v, skipping", rsrc)
+				continue
+			}
+
+			gvr := gv.WithResource(rsrc.Name)
+			if rsrc.Group != "" {
+				gvr.Group = rsrc.Group
+			}
+			if rsrc.Version != "" {
+				gvr.Version = rsrc.Version
+			}
+
+			var rc dynamic.ResourceInterface
+			if rsrc.Namespaced {
+				rc = client.Resource(gvr).Namespace(metav1.NamespaceAll)
+			} else {
+				rc = client.Resource(gvr)
+			}
+
+			log.Debugf("Listing %s", gvr)
+			obj, err := rc.List(ctx, listopts)
+			if err != nil {
+				return err
+			}
+			if err = meta.EachListItem(obj, callback); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func eligibleForGc(obj metav1.Object, gcTag string) bool {
+	for _, ref := range obj.GetOwnerReferences() {
+		if ref.Controller != nil && *ref.Controller {
+			// Has a controller ref
+			return false
+		}
+	}
+
+	a := obj.GetAnnotations()
+
+	strategy, ok := a[AnnotationGcStrategy]
+	if !ok {
+		strategy = GcStrategyAuto
+	}
+
+	// [gctag-migration]: Check *label* == tag instead in phase2
+	return a[AnnotationGcTag] == gcTag &&
+		strategy == GcStrategyAuto
+}
diff --git a/cluster/tools/kartongips/pkg/kubecfg/update_test.go b/cluster/tools/kartongips/pkg/kubecfg/update_test.go
new file mode 100644
index 0000000..47c8389
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/update_test.go
@@ -0,0 +1,287 @@
+package kubecfg
+
+import (
+	"fmt"
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+
+	pb_proto "github.com/golang/protobuf/proto"
+	openapi_v2 "github.com/googleapis/gnostic/openapiv2"
+	apiequality "k8s.io/apimachinery/pkg/api/equality"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/diff"
+	"k8s.io/apimachinery/pkg/util/strategicpatch"
+	"k8s.io/kube-openapi/pkg/util/proto"
+	"k8s.io/kubectl/pkg/util/openapi"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/utils"
+)
+
+func TestStringListContains(t *testing.T) {
+	t.Parallel()
+	foobar := []string{"foo", "bar"}
+	if stringListContains([]string{}, "") {
+		t.Error("Empty list was not empty")
+	}
+	if !stringListContains(foobar, "foo") {
+		t.Error("Failed to find foo")
+	}
+	if stringListContains(foobar, "baz") {
+		t.Error("Should not contain baz")
+	}
+}
+
+func TestIsValidKindSchema(t *testing.T) {
+	t.Parallel()
+	schemaResources := readSchemaOrDie(filepath.FromSlash("../../testdata/schema.pb"))
+
+	cmgvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}
+	if !isValidKindSchema(schemaResources.LookupResource(cmgvk)) {
+		t.Errorf("%s should have a valid schema", cmgvk)
+	}
+
+	if isValidKindSchema(nil) {
+		t.Error("nil should not be a valid schema")
+	}
+
+	// This is what a schema-less CRD appears as in k8s >= 1.15
+	mapSchema := &proto.Map{
+		BaseSchema: proto.BaseSchema{
+			Extensions: map[string]interface{}{
+				"x-kubernetes-group-version-kind": []interface{}{
+					map[interface{}]interface{}{"group": "bitnami.com", "kind": "SealedSecret", "version": "v1alpha1"},
+				},
+			},
+		},
+		SubType: &proto.Arbitrary{},
+	}
+	if isValidKindSchema(mapSchema) {
+		t.Error("Trivial type:object schema should be invalid")
+	}
+}
+
+func TestEligibleForGc(t *testing.T) {
+	t.Parallel()
+	const myTag = "my-gctag"
+	boolTrue := true
+	o := &unstructured.Unstructured{
+		Object: map[string]interface{}{
+			"apiVersion": "tests/v1alpha1",
+			"kind":       "Dummy",
+		},
+	}
+
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (no tag)", o)
+	}
+
+	// [gctag-migration]: Remove annotation in phase2
+	utils.SetMetaDataAnnotation(o, AnnotationGcTag, "unknowntag")
+	utils.SetMetaDataLabel(o, LabelGcTag, "unknowntag")
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (wrong tag)", o)
+	}
+
+	// [gctag-migration]: Remove annotation in phase2
+	utils.SetMetaDataAnnotation(o, AnnotationGcTag, myTag)
+	utils.SetMetaDataLabel(o, LabelGcTag, myTag)
+	if !eligibleForGc(o, myTag) {
+		t.Errorf("%v should be eligible", o)
+	}
+
+	// [gctag-migration]: Remove testcase in phase2
+	utils.SetMetaDataAnnotation(o, AnnotationGcTag, myTag)
+	utils.DeleteMetaDataLabel(o, LabelGcTag) // no label. ie: pre-migration
+	if !eligibleForGc(o, myTag) {
+		t.Errorf("%v should be eligible (gctag-migration phase1)", o)
+	}
+
+	utils.SetMetaDataAnnotation(o, AnnotationGcStrategy, GcStrategyIgnore)
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (strategy=ignore)", o)
+	}
+
+	utils.SetMetaDataAnnotation(o, AnnotationGcStrategy, GcStrategyAuto)
+	if !eligibleForGc(o, myTag) {
+		t.Errorf("%v should be eligible (strategy=auto)", o)
+	}
+
+	// Unstructured.SetOwnerReferences is broken in apimachinery release-1.6
+	// See kubernetes/kubernetes#46817
+	setOwnerRef := func(u *unstructured.Unstructured, ref metav1.OwnerReference) {
+		// This is not a complete nor robust reimplementation
+		c := map[string]interface{}{
+			"kind": ref.Kind,
+			"name": ref.Name,
+		}
+		if ref.Controller != nil {
+			c["controller"] = *ref.Controller
+		}
+		u.Object["metadata"].(map[string]interface{})["ownerReferences"] = []interface{}{c}
+	}
+	setOwnerRef(o, metav1.OwnerReference{Kind: "foo", Name: "bar"})
+	if !eligibleForGc(o, myTag) {
+		t.Errorf("%v should be eligible (non-controller ownerref)", o)
+	}
+
+	setOwnerRef(o, metav1.OwnerReference{Kind: "foo", Name: "bar", Controller: &boolTrue})
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (controller ownerref)", o)
+	}
+}
+
+func exampleConfigMap() *unstructured.Unstructured {
+	result := &unstructured.Unstructured{
+		Object: map[string]interface{}{
+			"apiVersion": "v1",
+			"kind":       "ConfigMap",
+			"metadata": map[string]interface{}{
+				"name":      "myname",
+				"namespace": "mynamespace",
+				"annotations": map[string]interface{}{
+					"myannotation": "somevalue",
+				},
+			},
+			"data": map[string]interface{}{
+				"foo": "bar",
+			},
+		},
+	}
+
+	return result
+}
+
+func addOrigAnnotation(obj *unstructured.Unstructured) {
+	data, err := utils.CompactEncodeObject(obj)
+	if err != nil {
+		panic(fmt.Sprintf("Failed to serialise object: %v", err))
+	}
+	utils.SetMetaDataAnnotation(obj, AnnotationOrigObject, data)
+}
+
+func newPatchMetaFromStructOrDie(dataStruct interface{}) strategicpatch.PatchMetaFromStruct {
+	t, err := strategicpatch.NewPatchMetaFromStruct(dataStruct)
+	if err != nil {
+		panic(fmt.Sprintf("NewPatchMetaFromStruct(%t) failed: %v", dataStruct, err))
+	}
+	return t
+}
+
+func readSchemaOrDie(path string) openapi.Resources {
+	var doc openapi_v2.Document
+	b, err := ioutil.ReadFile(path)
+	if err != nil {
+		panic(fmt.Sprintf("Unable to read %s: %v", path, err))
+	}
+	if err := pb_proto.Unmarshal(b, &doc); err != nil {
+		panic(fmt.Sprintf("Unable to unmarshal %s: %v", path, err))
+	}
+	schemaResources, err := openapi.NewOpenAPIData(&doc)
+	if err != nil {
+		panic(fmt.Sprintf("Unable to parse openapi doc: %v", err))
+	}
+	return schemaResources
+}
+
+func TestPatchNoop(t *testing.T) {
+	t.Parallel()
+	schemaResources := readSchemaOrDie(filepath.FromSlash("../../testdata/schema.pb"))
+
+	existing := exampleConfigMap()
+	new := existing.DeepCopy()
+	addOrigAnnotation(existing)
+
+	result, err := patch(existing, new, schemaResources.LookupResource(existing.GroupVersionKind()))
+	if err != nil {
+		t.Errorf("patch() returned error: %v", err)
+	}
+
+	t.Logf("existing: %#v", existing)
+	t.Logf("result: %#v", result)
+	if !apiequality.Semantic.DeepEqual(existing, result) {
+		t.Error("Objects differed: ", diff.ObjectDiff(existing, result))
+	}
+}
+
+func TestPatchNoopNoAnnotation(t *testing.T) {
+	t.Parallel()
+	schemaResources := readSchemaOrDie(filepath.FromSlash("../../testdata/schema.pb"))
+
+	existing := exampleConfigMap()
+	new := existing.DeepCopy()
+	// Note: no addOrigAnnotation(existing)
+
+	result, err := patch(existing, new, schemaResources.LookupResource(existing.GroupVersionKind()))
+	if err != nil {
+		t.Errorf("patch() returned error: %v", err)
+	}
+
+	// result should == existing, except for annotation
+
+	if result.GetAnnotations()[AnnotationOrigObject] == "" {
+		t.Errorf("result lacks last-applied annotation")
+	}
+
+	utils.DeleteMetaDataAnnotation(result, AnnotationOrigObject)
+	if !apiequality.Semantic.DeepEqual(existing, result) {
+		t.Error("Objects differed: ", diff.ObjectDiff(existing, result))
+	}
+}
+
+func TestPatchNoConflict(t *testing.T) {
+	t.Parallel()
+	schemaResources := readSchemaOrDie(filepath.FromSlash("../../testdata/schema.pb"))
+
+	existing := exampleConfigMap()
+	utils.SetMetaDataAnnotation(existing, "someanno", "origvalue")
+	addOrigAnnotation(existing)
+	utils.SetMetaDataAnnotation(existing, "otheranno", "existingvalue")
+	new := exampleConfigMap()
+	utils.SetMetaDataAnnotation(new, "someanno", "newvalue")
+
+	result, err := patch(existing, new, schemaResources.LookupResource(existing.GroupVersionKind()))
+	if err != nil {
+		t.Errorf("patch() returned error: %v", err)
+	}
+
+	t.Logf("existing: %#v", existing)
+	t.Logf("result: %#v", result)
+	someanno := result.GetAnnotations()["someanno"]
+	if someanno != "newvalue" {
+		t.Errorf("someanno was %q", someanno)
+	}
+
+	otheranno := result.GetAnnotations()["otheranno"]
+	if otheranno != "existingvalue" {
+		t.Errorf("otheranno was %q", otheranno)
+	}
+}
+
+func TestPatchConflict(t *testing.T) {
+	t.Parallel()
+	schemaResources := readSchemaOrDie(filepath.FromSlash("../../testdata/schema.pb"))
+
+	existing := exampleConfigMap()
+	utils.SetMetaDataAnnotation(existing, "someanno", "origvalue")
+	addOrigAnnotation(existing)
+	utils.SetMetaDataAnnotation(existing, "someanno", "existingvalue")
+	new := exampleConfigMap()
+	utils.SetMetaDataAnnotation(new, "someanno", "newvalue")
+
+	result, err := patch(existing, new, schemaResources.LookupResource(existing.GroupVersionKind()))
+	if err != nil {
+		t.Errorf("patch() returned error: %v", err)
+	}
+
+	// `new` should win conflicts
+
+	t.Logf("existing: %#v", existing)
+	t.Logf("result: %#v", result)
+	value := result.GetAnnotations()["someanno"]
+	if value != "newvalue" {
+		t.Errorf("annotation was %q", value)
+	}
+}
diff --git a/cluster/tools/kartongips/pkg/kubecfg/validate.go b/cluster/tools/kartongips/pkg/kubecfg/validate.go
new file mode 100644
index 0000000..88a1ace
--- /dev/null
+++ b/cluster/tools/kartongips/pkg/kubecfg/validate.go
@@ -0,0 +1,101 @@
+// 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 kubecfg
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/discovery"
+
+	"code.hackerspace.pl/hscloud/cluster/tools/kartongips/utils"
+)
+
+// ValidateCmd represents the validate subcommand
+type ValidateCmd struct {
+	Mapper        meta.RESTMapper
+	Discovery     discovery.DiscoveryInterface
+	IgnoreUnknown bool
+}
+
+func (c ValidateCmd) Run(apiObjects []*unstructured.Unstructured, out io.Writer) error {
+	knownGVKs := sets.NewString()
+	gvkExists := func(gvk schema.GroupVersionKind) bool {
+		if knownGVKs.Has(gvk.String()) {
+			return true
+		}
+		gv := gvk.GroupVersion()
+		rls, err := c.Discovery.ServerResourcesForGroupVersion(gv.String())
+		if err != nil {
+			if !errors.IsNotFound(err) {
+				log.Debugf("ServerResourcesForGroupVersion(%q) returned unexpected error %v", gv, err)
+			}
+			return false
+		}
+		for _, rl := range rls.APIResources {
+			knownGVKs.Insert(gv.WithKind(rl.Kind).String())
+		}
+		return knownGVKs.Has(gvk.String())
+	}
+
+	hasError := false
+
+	for _, obj := range apiObjects {
+		desc := fmt.Sprintf("%s %s", utils.ResourceNameFor(c.Mapper, obj), utils.FqName(obj))
+		log.Info("Validating ", desc)
+
+		gvk := obj.GroupVersionKind()
+
+		var allErrs []error
+
+		schema, err := utils.NewOpenAPISchemaFor(c.Discovery, gvk)
+		if err != nil {
+			isNotFound := errors.IsNotFound(err) ||
+				strings.Contains(err.Error(), "is not supported by the server")
+			if isNotFound && (c.IgnoreUnknown || gvkExists(gvk)) {
+				log.Infof(" No schema found for %s, skipping validation", gvk)
+				continue
+			}
+			allErrs = append(allErrs, fmt.Errorf("Unable to fetch schema: %v", err))
+		} else {
+			// Validate obj
+			for _, err := range schema.Validate(obj) {
+				allErrs = append(allErrs, err)
+			}
+			if obj.GetName() == "" {
+				allErrs = append(allErrs, fmt.Errorf("An Object does not have a name set"))
+			}
+		}
+
+		for _, err := range allErrs {
+			log.Errorf("Error in %s: %v", desc, err)
+			hasError = true
+		}
+	}
+
+	if hasError {
+		return fmt.Errorf("Validation failed")
+	}
+
+	return nil
+}