Skip to content

Commit 13f6fdd

Browse files
authored
feat(kubernetes): subset-diff (grafana#11)
So far, diffing was offloaded to kubectl. While this produces very nice results, it is only possible for kubernetes version 1.13+. Nevertheless, there are older cluster versions around, so these need to be supported as well. subset-diff addresses those cases in the hopefully best way possible. To reduce field bloat, it only diffes those fields, that are present in the local config. Kubernetes adds dynamic fields on the fly which we cannot know about, so this is required. Note: You WILL NOT see removed fields in the diff output. Upgrade your cluster version to 1.13+ and use native diffing.
1 parent aea3bdf commit 13f6fdd

File tree

10 files changed

+416
-20
lines changed

10 files changed

+416
-20
lines changed

cmd/tk/workflow.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"os"
77

88
"github.com/alecthomas/chroma/quick"
9+
"github.com/posener/complete"
10+
"github.com/sh0rez/tanka/pkg/cmp"
911
"github.com/spf13/cobra"
1012
"golang.org/x/crypto/ssh/terminal"
1113
)
@@ -38,12 +40,16 @@ func applyCmd() *cobra.Command {
3840
}
3941

4042
func diffCmd() *cobra.Command {
43+
// completion
44+
cmp.Handlers.Add("diffStrategy", complete.PredictSet("native", "subset"))
45+
4146
cmd := &cobra.Command{
4247
Use: "diff [directory]",
4348
Short: "differences between the configuration and the cluster",
4449
Args: cobra.ExactArgs(1),
4550
Annotations: map[string]string{
46-
"args": "baseDir",
51+
"args": "baseDir",
52+
"flags/diff-strategy": "diffStrategy",
4753
},
4854
}
4955
cmd.Run = func(cmd *cobra.Command, args []string) {
@@ -52,6 +58,10 @@ func diffCmd() *cobra.Command {
5258
log.Fatalln("Evaluating jsonnet:", err)
5359
}
5460

61+
if kube.Spec.DiffStrategy == "" {
62+
kube.Spec.DiffStrategy = cmd.Flag("diff-strategy").Value.String()
63+
}
64+
5565
desired, err := kube.Reconcile(raw)
5666
if err != nil {
5767
log.Fatalln("Reconciling:", err)
@@ -70,6 +80,7 @@ func diffCmd() *cobra.Command {
7080
fmt.Println(changes)
7181
}
7282
}
83+
cmd.Flags().String("diff-strategy", "", "force the diff-strategy to use. Automatically chosen if not set.")
7384
return cmd
7485
}
7586

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/spf13/pflag v1.0.3
1616
github.com/spf13/viper v1.3.2
1717
github.com/stretchr/objx v0.2.0
18+
github.com/stretchr/testify v1.3.0
1819
github.com/thoas/go-funk v0.4.0
1920
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
2021
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f // indirect

pkg/config/v1alpha1/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Metadata struct {
1616

1717
// Spec defines Kubernetes properties
1818
type Spec struct {
19-
APIServer string `json:"apiServer"`
20-
Namespace string `json:"namespace"`
19+
APIServer string `json:"apiServer"`
20+
Namespace string `json:"namespace"`
21+
DiffStrategy string `json:"diffStrategy"`
2122
}

pkg/kubernetes/errors.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package kubernetes
2+
3+
import "fmt"
4+
5+
type ErrorNotFound struct {
6+
resource string
7+
}
8+
9+
func (e ErrorNotFound) Error() string {
10+
return fmt.Sprintf(`error from server (NotFound): secrets "%s" not found`, e.resource)
11+
}

pkg/kubernetes/kubectl.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"os"
1010
"os/exec"
11+
"strings"
1112

1213
"github.com/Masterminds/semver"
1314
"github.com/fatih/color"
@@ -29,6 +30,9 @@ type Kubectl struct {
2930
// Version returns the version of kubectl and the Kubernetes api server
3031
func (k Kubectl) Version() (client, server semver.Version, err error) {
3132
zero := *semver.MustParse("0.0.0")
33+
if err := k.setupContext(); err != nil {
34+
return zero, zero, err
35+
}
3236
cmd := exec.Command("kubectl", "version",
3337
"-o", "json",
3438
"--context", k.context.Get("name").MustStr(),
@@ -46,6 +50,10 @@ func (k Kubectl) Version() (client, server semver.Version, err error) {
4650

4751
// setupContext uses `kubectl config view` to obtain the KUBECONFIG and extracts the correct context from it
4852
func (k *Kubectl) setupContext() error {
53+
if k.context != nil {
54+
return nil
55+
}
56+
4957
cmd := exec.Command("kubectl", "config", "view", "-o", "json")
5058
cfgJSON := bytes.Buffer{}
5159
cmd.Stdout = &cfgJSON
@@ -102,14 +110,18 @@ func (k Kubectl) Get(namespace, kind, name string) (map[string]interface{}, erro
102110
kind, name,
103111
}
104112
cmd := exec.Command("kubectl", argv...)
105-
raw := bytes.Buffer{}
106-
cmd.Stdout = &raw
107-
cmd.Stderr = os.Stderr
113+
var sout, serr bytes.Buffer
114+
cmd.Stdout = &sout
115+
cmd.Stderr = &serr
108116
if err := cmd.Run(); err != nil {
117+
if strings.HasPrefix(serr.String(), "Error from server (NotFound)") {
118+
return nil, ErrorNotFound{name}
119+
}
120+
fmt.Println(serr.String())
109121
return nil, err
110122
}
111123
var obj map[string]interface{}
112-
if err := json.Unmarshal(raw.Bytes(), &obj); err != nil {
124+
if err := json.Unmarshal(sout.Bytes(), &obj); err != nil {
113125
return nil, err
114126
}
115127
return obj, nil
@@ -164,14 +176,6 @@ func (k Kubectl) Diff(yaml string) (string, error) {
164176
return "", err
165177
}
166178

167-
client, server, err := k.Version()
168-
if !client.GreaterThan(semver.MustParse("1.13.0")) || !server.GreaterThan(semver.MustParse("1.13.0")) {
169-
return "", fmt.Errorf("The kubernetes diff feature requires at least version 1.13 on both, kubectl (is `%s`) and server (is `%s`)", client.String(), server.String())
170-
}
171-
if err != nil {
172-
return "", err
173-
}
174-
175179
argv := []string{"diff",
176180
"--context", k.context.Get("name").MustStr(),
177181
"-f", "-",

pkg/kubernetes/kubernetes.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kubernetes
33
import (
44
"bytes"
55

6+
"github.com/Masterminds/semver"
67
"github.com/pkg/errors"
78
"github.com/stretchr/objx"
89
yaml "gopkg.in/yaml.v2"
@@ -13,13 +14,24 @@ import (
1314
// Kubernetes bridges tanka to the Kubernetse orchestrator.
1415
type Kubernetes struct {
1516
client Kubectl
16-
spec v1alpha1.Spec
17+
Spec v1alpha1.Spec
18+
19+
// Diffing
20+
differs map[string]Differ // List of diff strategies
1721
}
1822

23+
type Differ func(yaml string) (string, error)
24+
1925
// New creates a new Kubernetes
2026
func New(s v1alpha1.Spec) *Kubernetes {
21-
k := Kubernetes{spec: s}
22-
k.client.APIServer = k.spec.APIServer
27+
k := Kubernetes{
28+
Spec: s,
29+
}
30+
k.client.APIServer = k.Spec.APIServer
31+
k.differs = map[string]Differ{
32+
"native": k.client.Diff,
33+
"subset": k.client.SubsetDiff,
34+
}
2335
return &k
2436
}
2537

@@ -36,7 +48,7 @@ func (k *Kubernetes) Reconcile(raw map[string]interface{}) (state []Manifest, er
3648
}
3749
for _, d := range docs {
3850
m := objx.New(d)
39-
m.Set("metadata.namespace", k.spec.Namespace)
51+
m.Set("metadata.namespace", k.Spec.Namespace)
4052
out = append(out, Manifest(m))
4153
}
4254
return out, nil
@@ -71,5 +83,15 @@ func (k *Kubernetes) Diff(state []Manifest) (string, error) {
7183
if err != nil {
7284
return "", err
7385
}
74-
return k.client.Diff(yaml)
86+
87+
if k.Spec.DiffStrategy == "" {
88+
k.Spec.DiffStrategy = "native"
89+
if _, server, err := k.client.Version(); err == nil {
90+
if !server.GreaterThan(semver.MustParse("0.13.0")) {
91+
k.Spec.DiffStrategy = "subset"
92+
}
93+
}
94+
}
95+
96+
return k.differs[k.Spec.DiffStrategy](yaml)
7597
}

pkg/kubernetes/subsetdiff.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package kubernetes
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
8+
"github.com/pkg/errors"
9+
"github.com/stretchr/objx"
10+
yaml "gopkg.in/yaml.v2"
11+
12+
"github.com/sh0rez/tanka/pkg/util"
13+
)
14+
15+
type difference struct {
16+
live, merged string
17+
}
18+
19+
func (k Kubectl) SubsetDiff(y string) (string, error) {
20+
docs := map[string]difference{}
21+
d := yaml.NewDecoder(strings.NewReader(y))
22+
for {
23+
24+
// jsonnet output -> desired state
25+
var rawShould map[interface{}]interface{}
26+
err := d.Decode(&rawShould)
27+
if err == io.EOF {
28+
break
29+
}
30+
31+
if err != nil {
32+
return "", errors.Wrap(err, "decoding yaml")
33+
}
34+
35+
// filename
36+
m := objx.New(util.CleanupInterfaceMap(rawShould))
37+
name := strings.Replace(fmt.Sprintf("%s.%s.%s.%s",
38+
m.Get("apiVersion").MustStr(),
39+
m.Get("kind").MustStr(),
40+
m.Get("metadata.namespace").MustStr(),
41+
m.Get("metadata.name").MustStr(),
42+
), "/", "-", -1)
43+
44+
// kubectl output -> current state
45+
rawIs, err := k.Get(
46+
m.Get("metadata.namespace").MustStr(),
47+
m.Get("kind").MustStr(),
48+
m.Get("metadata.name").MustStr(),
49+
)
50+
if err != nil {
51+
if _, ok := err.(ErrorNotFound); ok {
52+
rawIs = map[string]interface{}{}
53+
} else {
54+
return "", errors.Wrap(err, "getting state from cluster")
55+
}
56+
}
57+
58+
should, err := yaml.Marshal(rawShould)
59+
if err != nil {
60+
return "", err
61+
}
62+
63+
is, err := yaml.Marshal(subset(m, rawIs))
64+
if err != nil {
65+
return "", err
66+
}
67+
if string(is) == "{}\n" {
68+
is = []byte("")
69+
}
70+
docs[name] = difference{string(is), string(should)}
71+
}
72+
73+
s := ""
74+
for k, v := range docs {
75+
d, err := diff(k, v.live, v.merged)
76+
if err != nil {
77+
return "", errors.Wrap(err, "invoking diff")
78+
}
79+
if d != "" {
80+
d += "\n"
81+
}
82+
s += d
83+
}
84+
85+
return s, nil
86+
}
87+
88+
// subset removes all keys from is, that are not present in should.
89+
// It makes is a subset of should.
90+
// Kubernetes returns more keys than we can know about.
91+
// This means, we need to remove all keys from the kubectl output, that are not present locally.
92+
func subset(should, is map[string]interface{}) map[string]interface{} {
93+
if should["namespace"] != nil {
94+
is["namespace"] = should["namespace"]
95+
}
96+
for k, v := range is {
97+
if should[k] == nil {
98+
delete(is, k)
99+
continue
100+
}
101+
102+
switch b := v.(type) {
103+
case map[string]interface{}:
104+
if a, ok := should[k].(map[string]interface{}); ok {
105+
is[k] = subset(a, b)
106+
}
107+
case []map[string]interface{}:
108+
for i := range b {
109+
if a, ok := should[k].([]map[string]interface{}); ok {
110+
b[i] = subset(a[i], b[i])
111+
}
112+
}
113+
case []interface{}:
114+
for i := range b {
115+
if a, ok := should[k].([]interface{}); ok {
116+
aa, ok := a[i].(map[string]interface{})
117+
if !ok {
118+
continue
119+
}
120+
bb, ok := b[i].(map[string]interface{})
121+
if !ok {
122+
continue
123+
}
124+
b[i] = subset(aa, bb)
125+
}
126+
}
127+
}
128+
}
129+
return is
130+
}

0 commit comments

Comments
 (0)