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
+}