Skip to content

Commit

Permalink
feat(process): Naive namespaces (grafana#312)
Browse files Browse the repository at this point in the history
Now that lists are always unwrapped, we assume all other objects use
`ObjectMeta`, so they do accept a namespace, regardless of whether they
actually use it.

This allows us to simplify our code, by always setting it. For yet
unknown edge-cases, we allow using the `tanka.dev/namespaced` annotation
to override this behaviour.
  • Loading branch information
sh0rez authored Jul 7, 2020
1 parent a1df9fa commit eb167c9
Show file tree
Hide file tree
Showing 13 changed files with 246 additions and 141 deletions.
46 changes: 46 additions & 0 deletions docs/docs/namespaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
name: Namespaces
route: /namespaces
---

# Namespaces

When using Tanka, namespaces are handled slightly different compared to
`kubectl`, because environments offer more granular control than contexts used
by `kubectl`.

## Default namespaces

In the [`spec.json`](/config/#file-format) of each environment, you can set the
`spec.namespace` field, which is the default namespace. The default namespace is
set for every resource that **does not** have a namespace **set from Jsonnet**.

| | Scenario | Action |
| --- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| 1. | Your resource **lacks namespace** information (`metadata.namespace`) unset or `""` | Tanka sets `metadata.namespace` to the value of `spec.namespace` in `spec.json` |
| 2. | Your resource **already has** namespace information | Tanka does nothing, accepting the explicit namespace |

While we recommend keeping environments limited to a single namespace, there are
legit cases where it's handy to have them span multiple namespaces, for example:

- Some other piece of software (Operators, etc) require resources to be in a specific namespace
- A rarely changing "base" environment holding resources deployed for many clusters in the same way
- etc.

## Cluster-wide resources

Some resources in Kubernetes are cluster-wide, meaning they don't belong to a single namespace at all.

To Tanka these appear as _Scenario 1 (see above)_, so it will set the default
namespace. In reality however, this is **not a problem**, because `kubectl`
discards this information silently. We made this design-choice, because it
simplifies our code a lot.

In case this ever becomes a problem, you can **override this** behavior
per-resource, by setting the `tanka.dev/namespaced` annotation to `"false"`
(must be of `string` type):

```jsonnet
thing: clusterRole.new("myClusterRole")
+ clusterRole.mixin.metadata.withAnnotationsMixin({ "tanka.dev/namespaced": "false" })
```
1 change: 1 addition & 0 deletions docs/doczrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default {
"Garbage collection",
"Command-line completion",
"Diff strategies",
"Namespaces",

// reference
"Configuration Reference",
Expand Down
28 changes: 0 additions & 28 deletions pkg/kubernetes/client/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"strings"

Expand All @@ -27,33 +26,6 @@ func findContext(endpoint string) (Config, error) {
}, nil
}

// writeNamespacePatch writes a temporary file that includes only the previously
// discovered context with the `context.namespace` field set to the default
// namespace from `spec.json`. Adding this file to `$KUBECONFIG` results in
// `kubectl` picking this up, effectively setting the default namespace.
func writeNamespacePatch(context Context, defaultNamespace string) (string, error) {
context.Context.Namespace = defaultNamespace

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

f, err := ioutil.TempFile("", "tk-kubectx-namespace-*.yaml")
if err != nil {
return "", err
}

if err = ioutil.WriteFile(f.Name(), out, 0644); err != nil {
return "", err
}

return f.Name(), nil
}

// Kubeconfig returns the merged $KUBECONFIG of the host
func Kubeconfig() (objx.Map, error) {
cmd := kubectlCmd("config", "view", "-o", "json")
Expand Down
44 changes: 0 additions & 44 deletions pkg/kubernetes/client/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)

// kubectlCmd returns command a object that will launch kubectl at an appropriate path.
Expand All @@ -30,51 +27,10 @@ func (k Kubectl) ctl(action string, args ...string) *exec.Cmd {

// prepare the cmd
cmd := kubectlCmd(argv...)
cmd.Env = patchKubeconfig(k.nsPatch, os.Environ())

if os.Getenv("TANKA_KUBECTL_TRACE") != "" {
fmt.Println(cmd.String())
}

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%s", file, string(os.PathListSeparator), 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
}
39 changes: 0 additions & 39 deletions pkg/kubernetes/client/exec_test.go

This file was deleted.

15 changes: 2 additions & 13 deletions pkg/kubernetes/client/kubectl.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,10 @@ import (
// Kubectl uses the `kubectl` command to operate on a Kubernetes cluster
type Kubectl struct {
info Info

// internal fields
nsPatch string
}

// New returns a instance of Kubectl with a correct context already discovered.
func New(endpoint, defaultNamespace string) (*Kubectl, error) {
func New(endpoint string) (*Kubectl, error) {
k := Kubectl{}

// discover context
Expand All @@ -30,13 +27,6 @@ func New(endpoint, defaultNamespace string) (*Kubectl, error) {
return nil, errors.Wrap(err, "finding usable context")
}

// set the default namespace by injecting it into the context
nsPatch, err := writeNamespacePatch(k.info.Kubeconfig.Context, defaultNamespace)
if err != nil {
return nil, errors.Wrap(err, "creating $KUBECONFIG patch for default namespace")
}
k.nsPatch = nsPatch

// query versions (requires context)
k.info.ClientVersion, k.info.ServerVersion, err = k.version()
if err != nil {
Expand All @@ -52,9 +42,8 @@ func (k Kubectl) Info() Info {
}

// Close runs final cleanup:
// - remove the nsPatch file
func (k Kubectl) Close() error {
return os.RemoveAll(k.nsPatch)
return nil
}

// Namespaces of the cluster
Expand Down
2 changes: 1 addition & 1 deletion pkg/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type Differ func(manifest.List) (*string, error)
// New creates a new Kubernetes with an initialized client
func New(env v1alpha1.Config) (*Kubernetes, error) {
// setup client
ctl, err := client.New(env.Spec.APIServer, env.Spec.Namespace)
ctl, err := client.New(env.Spec.APIServer)
if err != nil {
return nil, err
}
Expand Down
31 changes: 20 additions & 11 deletions pkg/kubernetes/manifest/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,42 @@ package manifest
import (
"fmt"
"strings"

"github.com/fatih/color"
)

// SchemaError means that some expected fields were missing
type SchemaError struct {
Fields map[string]bool
Fields map[string]error
Name string
Manifest Manifest
}

var (
redf = color.New(color.FgRed, color.Bold, color.Underline).Sprintf
yellowf = color.New(color.FgYellow).Sprintf
bluef = color.New(color.FgBlue, color.Bold).Sprintf
)

// Error returns the fields the manifest at the path is missing
func (s *SchemaError) Error() string {
fields := make([]string, 0, len(s.Fields))
for k, missing := range s.Fields {
if !missing {
continue
}
fields = append(fields, k)
}

if s.Name == "" {
s.Name = "Resource"
}

msg := fmt.Sprintf("%s has missing or invalid fields: %s", s.Name, strings.Join(fields, ", "))
msg := fmt.Sprintf("%s has missing or invalid fields:\n", redf(s.Name))

for k, err := range s.Fields {
if err == nil {
continue
}

msg += fmt.Sprintf(" - %s: %s\n", yellowf(k), err)
}

if s.Manifest != nil {
msg += fmt.Sprintf(":\n\n%s\n\nPlease check above object.", SampleString(s.Manifest.String()).Indent(2))
msg += bluef("\nPlease check below object:\n")
msg += SampleString(s.Manifest.String()).Indent(2)
}

return msg
Expand Down
43 changes: 38 additions & 5 deletions pkg/kubernetes/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,37 @@ func (m Manifest) String() string {
return string(y)
}

var (
ErrInvalidStr = fmt.Errorf("Missing or not of string type")
ErrInvalidMap = fmt.Errorf("Missing or not an object")
)

// Verify checks whether the manifest is correctly structured
func (m Manifest) Verify() error {
o := m2o(m)
fields := make(map[string]bool)
fields := make(map[string]error)

if !o.Get("kind").IsStr() {
fields["kind"] = true
fields["kind"] = ErrInvalidStr
}
if !o.Get("apiVersion").IsStr() {
fields["apiVersion"] = true
fields["apiVersion"] = ErrInvalidStr
}

// Lists don't have `metadata`
if !m.IsList() {
if !o.Get("metadata").IsMSI() {
fields["metadata"] = true
fields["metadata"] = ErrInvalidMap
}
if !o.Get("metadata.name").IsStr() {
fields["metadata.name"] = true
fields["metadata.name"] = ErrInvalidStr
}

if err := verifyMSS(o.Get("metadata.labels").Data()); err != nil {
fields["metadata.labels"] = err
}
if err := verifyMSS(o.Get("metadata.annotations").Data()); err != nil {
fields["metadata.annotations"] = err
}
}

Expand All @@ -70,6 +82,27 @@ func (m Manifest) Verify() error {
}
}

// verifyMSS checks that ptr is either nil or a string map
func verifyMSS(ptr interface{}) error {
if ptr == nil {
return nil
}

switch t := ptr.(type) {
case map[string]string:
return nil
case map[string]interface{}:
for k, v := range t {
if _, ok := v.(string); !ok {
return fmt.Errorf("Contains non-string field '%s' of type '%T'", k, v)
}
}
return nil
default:
return fmt.Errorf("Must be object, but got '%T' instead", ptr)
}
}

// IsList returns whether the manifest is a List type, containing other
// manifests as children. Code based on
// https://github.com/kubernetes/apimachinery/blob/61490fe38e784592212b24b9878306b09be45ab0/pkg/apis/meta/v1/unstructured/unstructured.go#L54
Expand Down
Loading

0 comments on commit eb167c9

Please sign in to comment.