Skip to content

Commit

Permalink
fix(kubernetes): let kubectl handle namespaces (grafana#208)
Browse files Browse the repository at this point in the history
* fix(kubernetes): let kubectl handle namespaces

Instead of naively setting the default namespaces on objects that don't
have one, we now overlay `$KUBECONFIG` with a patch to set the default
namespace on the context, so that `kubectl` will do the job for us.

`kubectl` does this far more intelligent than we did, especially it does
not inject the namespace on objects that don't take one anymore.

* fix(kubernetes): nativeDiff default namespace

Properly handles the implicit default namespace (`""`, missing field) in
`client.DiffServerSide`.

Before, these objects were flagged as non-existent and always displayed
a diff, which was incorrect.

Adds a test to ensure this for the future

* fix(kubernetes): handle missing $KUBECONFIG

* test(kubernetes): $KUBECONFIG patching

* fix(kubernetes): manually expand ~ (homeDir)
  • Loading branch information
sh0rez authored Feb 12, 2020
1 parent 5dc82ec commit 499e102
Show file tree
Hide file tree
Showing 12 changed files with 251 additions and 67 deletions.
8 changes: 2 additions & 6 deletions pkg/kubernetes/client/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package client

import (
"os"
"os/exec"
"strings"

"github.com/grafana/tanka/pkg/kubernetes/manifest"
Expand All @@ -23,10 +22,7 @@ func (k Kubectl) Apply(data manifest.List, opts ApplyOpts) error {
}

func (k Kubectl) apply(data manifest.List, opts ApplyOpts) error {
argv := []string{"apply",
"--context", k.context.Get("name").MustStr(),
"-f", "-",
}
argv := []string{"-f", "-"}
if opts.Force {
argv = append(argv, "--force")
}
Expand All @@ -35,7 +31,7 @@ func (k Kubectl) apply(data manifest.List, opts ApplyOpts) error {
argv = append(argv, "--validate=false")
}

cmd := exec.Command("kubectl", argv...)
cmd := k.ctl("apply", argv...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

Expand Down
36 changes: 34 additions & 2 deletions pkg/kubernetes/client/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/pkg/errors"
"github.com/stretchr/objx"
funk "github.com/thoas/go-funk"
)

// setupContext uses `kubectl config view` to obtain the KUBECONFIG and extracts the correct context from it
func (k *Kubectl) setupContext() error {
// setupContext makes sure the kubectl client is set up to use the correct
// context for the cluster IP:
// - find a context that matches the IP
// - create a patch for it to set the default namespace
func (k *Kubectl) setupContext(namespace string) error {
if k.context != nil {
return nil
}
Expand All @@ -23,9 +29,35 @@ func (k *Kubectl) setupContext() error {
if err != nil {
return err
}

nsPatch, err := writeNamespacePatch(k.context, namespace)
if err != nil {
return errors.Wrap(err, "creating $KUBECONFIG patch for default namespace")
}
k.nsPatch = nsPatch

return nil
}

func writeNamespacePatch(context objx.Map, namespace string) (string, error) {
context.Set("context.namespace", namespace)

kubectx := map[string]interface{}{
"contexts": []interface{}{context},
}
out, err := json.Marshal(kubectx)
if err != nil {
return "", err
}

f := filepath.Join(os.TempDir(), "tk-kubectx-namespace.yaml")
if err := ioutil.WriteFile(f, []byte(out), 0644); err != nil {
return "", err
}

return f, nil
}

// Kubeconfig returns the merged $KUBECONFIG of the host
func Kubeconfig() (map[string]interface{}, error) {
cmd := exec.Command("kubectl", "config", "view", "-o", "json")
Expand Down
6 changes: 2 additions & 4 deletions pkg/kubernetes/client/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@ func (k Kubectl) DeleteByLabels(namespace string, labels map[string]interface{},
}

func (k Kubectl) delete(namespace string, sel []string, opts DeleteOpts) error {
argv := append([]string{"delete",
"-n", namespace,
"--context", k.context.Get("name").MustStr(),
}, sel...)
argv := append([]string{"-n", namespace}, sel...)
k.ctl("delete", argv...)

if opts.Force {
argv = append(argv, "--force")
Expand Down
9 changes: 3 additions & 6 deletions pkg/kubernetes/client/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ func (k Kubectl) DiffServerSide(data manifest.List) (*string, error) {
}

ready, missing := separateMissingNamespace(data, ns)
argv := []string{"diff",
"--context", k.context.Get("name").MustStr(),
"-f", "-",
}
cmd := exec.Command("kubectl", argv...)
cmd := k.ctl("diff", "-f", "-")

raw := bytes.Buffer{}
cmd.Stdout = &raw
Expand Down Expand Up @@ -58,7 +54,8 @@ func (k Kubectl) DiffServerSide(data manifest.List) (*string, error) {

func separateMissingNamespace(in manifest.List, exists map[string]bool) (ready, missingNamespace manifest.List) {
for _, r := range in {
if !exists[r.Metadata().Namespace()] {
// namespace does not exist, also ignore implicit default ("")
if ns := r.Metadata().Namespace(); ns != "" && !exists[ns] {
missingNamespace = append(missingNamespace, r)
continue
}
Expand Down
91 changes: 91 additions & 0 deletions pkg/kubernetes/client/diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package client

import (
"testing"

"github.com/grafana/tanka/pkg/kubernetes/manifest"
"github.com/stretchr/testify/assert"
)

func TestSeparateMissingNamespace(t *testing.T) {
cases := []struct {
name string
td nsTd

missing bool
}{
// default should always exist
{
name: "default",
td: newNsTd(func(m manifest.Metadata) {
m["namespace"] = "default"
}, []string{}),
missing: false,
},
// implcit default (not specfiying an ns at all) also
{
name: "implicit-default",
td: newNsTd(func(m manifest.Metadata) {
delete(m, "namespace")
}, []string{}),
missing: false,
},
// custom ns that exists
{
name: "custom-ns",
td: newNsTd(func(m manifest.Metadata) {
m["namespace"] = "custom"
}, []string{"custom"}),
missing: false,
},
// custom ns that does not exist
{
name: "missing-ns",
td: newNsTd(func(m manifest.Metadata) {
m["namespace"] = "missing"
}, []string{}),
missing: true,
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ready, missing := separateMissingNamespace(manifest.List{c.td.m}, c.td.ns)
if c.missing {
assert.Lenf(t, ready, 0, "expected manifest to be missing (ready = 0)")
assert.Lenf(t, missing, 1, "expected manifest to be missing (missing = 1)")
} else {
assert.Lenf(t, ready, 1, "expected manifest to be ready (ready = 1)")
assert.Lenf(t, missing, 0, "expected manifest to be ready (missing = 0)")
}
})
}
}

type nsTd struct {
m manifest.Manifest
ns map[string]bool
}

func newNsTd(f func(m manifest.Metadata), ns []string) nsTd {
m := manifest.Manifest{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": map[string]interface{}{},
}
if f != nil {
f(m.Metadata())
}

nsMap := map[string]bool{
"default": true, // you can't get rid of this one ever
}
for _, n := range ns {
nsMap[n] = true
}

return nsTd{
m: m,
ns: nsMap,
}
}
67 changes: 67 additions & 0 deletions pkg/kubernetes/client/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package client

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)

// ctl returns an `exec.Cmd` for `kubectl`. It also forces the correct context
// and injects our patched $KUBECONFIG for the default namespace.
func (k Kubectl) ctl(action string, args ...string) *exec.Cmd {
// prepare the arguments
argv := []string{action,
"--context", k.context.Get("name").MustStr(),
}
argv = append(argv, args...)

// prepare the cmd
cmd := exec.Command("kubectl", argv...)
cmd.Env = patchKubeconfig(k.nsPatch, os.Environ())

return cmd
}

func patchKubeconfig(file string, e []string) []string {
// prepend namespace patch to $KUBECONFIG
env := newEnv(e)
if _, ok := env["KUBECONFIG"]; !ok {
env["KUBECONFIG"] = filepath.Join(homeDir(), ".kube", "config") // kubectl default
}
env["KUBECONFIG"] = fmt.Sprintf("%s:%s", file, env["KUBECONFIG"])

return env.render()
}

// environment is a helper type for manipulating os.Environ() more easily
type environment map[string]string

func newEnv(e []string) environment {
env := make(environment)
for _, s := range e {
kv := strings.SplitN(s, "=", 2)
env[kv[0]] = kv[1]
}
return env
}

func (e environment) render() []string {
s := make([]string, 0, len(e))
for k, v := range e {
s = append(s, fmt.Sprintf("%s=%s", k, v))
}
sort.Strings(s)
return s
}

func homeDir() string {
home, err := os.UserHomeDir()
// unable to find homedir. Should never happen on the supported os/arch
if err != nil {
panic("Unable to find your $HOME directory. This should not have ever happened. Please open an issue on https://github.com/grafana/tanka/issues with your OS and ARCH.")
}
return home
}
40 changes: 40 additions & 0 deletions pkg/kubernetes/client/exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package client

import (
"fmt"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
)

const patchFile = "/tmp/tk-nsPatch.yaml"

func TestPatchKubeconfig(t *testing.T) {

cases := []struct {
name string
env []string
want []string
}{
{
name: "none",
env: []string{},
want: []string{
fmt.Sprintf("KUBECONFIG=%s:%s", patchFile, filepath.Join(homeDir(), ".kube", "config")),
},
},
{
name: "custom",
env: []string{"KUBECONFIG=/home/user/.config/kube"},
want: []string{"KUBECONFIG=" + patchFile + ":/home/user/.config/kube"},
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := patchKubeconfig(patchFile, c.env)
assert.Equal(t, c.want, got)
})
}
}
6 changes: 2 additions & 4 deletions pkg/kubernetes/client/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"os/exec"
"strings"

"github.com/grafana/tanka/pkg/kubernetes/manifest"
Expand Down Expand Up @@ -41,12 +40,11 @@ func (k Kubectl) GetByLabels(namespace string, labels map[string]interface{}) (m
}

func (k Kubectl) get(namespace string, sel []string) (manifest.Manifest, error) {
argv := append([]string{"get",
argv := append([]string{
"-o", "json",
"-n", namespace,
"--context", k.context.Get("name").MustStr(),
}, sel...)
cmd := exec.Command("kubectl", argv...)
cmd := k.ctl("get", argv...)

var sout, serr bytes.Buffer
cmd.Stdout = &sout
Expand Down
Loading

0 comments on commit 499e102

Please sign in to comment.